diff --git a/.env.example b/.env.example deleted file mode 100644 index bbab9bb8..00000000 --- a/.env.example +++ /dev/null @@ -1,12 +0,0 @@ -# 0 or 1 -# 0 = disable -# 1 = enable -ENABLE_UPDATE_CHECK=$ENABLE_UPDATE_CHECK - -LASTFM_API_KEY=$LASTFM_API_KEY -LASTFM_API_SECRET=$LASTFM_API_SECRET - -# Release channel. Can be: nightly, stable -RELEASE_CHANNEL=$RELEASE_CHANNEL - -HIDE_DONATIONS=$HIDE_DONATIONS \ No newline at end of file diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json deleted file mode 100644 index 58b893ee..00000000 --- a/.fvm/fvm_config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "flutterSdkVersion": "3.35.2" -} \ No newline at end of file diff --git a/.fvmrc b/.fvmrc deleted file mode 100644 index 2bb4682a..00000000 --- a/.fvmrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "flutter": "3.35.2" -} \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index d4872798..8a31ee59 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -76,7 +76,7 @@ body: description: Where did you install Spotube from? multiple: true options: - - "Website (spotube.krtirtho.dev)" + - "Website (spotube.org)" - "GitHub Releases (Binary)" - "GitHub Actions (Nightly Binary)" - "Play Store (Android)" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 77189e7d..493ffc8f 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Ask a question - url: https://github.com/KRTirtho/spotube/discussions/new?category=q-a + url: https://github.com/team-spotube/spotube/discussions/new?category=q-a about: The place for questions, support and feature requests diff --git a/.github/agpl_header.txt b/.github/agpl_header.txt new file mode 100644 index 00000000..0d406077 --- /dev/null +++ b/.github/agpl_header.txt @@ -0,0 +1,14 @@ +Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero 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 Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 1e7f7d61..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: 2 - -enable-beta-ecosystems: true - -updates: - - package-ecosystem: "pub" - directory: "/" - schedule: - interval: "daily" - target-branch: "dev" - - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" - target-branch: "dev" diff --git a/.github/workflows/maven-publish.yml b/.github/workflows/maven-publish.yml new file mode 100644 index 00000000..e32be2f7 --- /dev/null +++ b/.github/workflows/maven-publish.yml @@ -0,0 +1,26 @@ +# .github/workflows/publish.yml + +name: Maven Publish +on: + workflow_dispatch: + +jobs: + publish: + name: Release build and publish + runs-on: macOS-latest + steps: + - name: Check out code + uses: actions/checkout@v7 + - name: Set up JDK 21 + uses: actions/setup-java@v6 + with: + distribution: 'zulu' + java-version: 21 + - name: Publish to MavenCentral + run: ./gradlew publishToMavenCentral --no-configuration-cache + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.MAVEN_SIGNING_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_SIGNING_KEY_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_GPG_KEY_CONTENTS }} \ No newline at end of file diff --git a/.github/workflows/potential-duplicates.yml b/.github/workflows/potential-duplicates.yml deleted file mode 100644 index 77b15e6e..00000000 --- a/.github/workflows/potential-duplicates.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Detect Potential Duplicates Issues -on: - issues: - types: - - opened - - edited -jobs: - run: - runs-on: ubuntu-latest - steps: - - uses: wow-actions/potential-duplicates@v1 - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Issue title filter work with anymatch https://www.npmjs.com/package/anymatch. - # Any matched issue will stop detection immediately. - # You can specify multi filters in each line. - filter: '' - # Exclude keywords in title before detecting. - exclude: '' - # Label to set, when potential duplicates are detected. - label: potential-duplicate - # Get issues with state to compare. Supported state: 'all', 'closed', 'open'. - state: all - # If similarity is higher than this threshold([0,1]), issue will be marked as duplicate. - threshold: 0.6 - # Reactions to be add to comment when potential duplicates are detected. - # Available reactions: "-1", "+1", "confused", "laugh", "heart", "hooray", "rocket", "eyes" - reactions: eyes - # Comment to post when potential duplicates are detected. - comment: > - Potential duplicates: {{#issues}} - - [#{{ number }}] {{ title }} ({{ accuracy }}%) - {{/issues}} \ No newline at end of file diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml deleted file mode 100644 index 3e73be4d..00000000 --- a/.github/workflows/pr-lint.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Lint - -on: - pull_request: - -env: - FLUTTER_VERSION: 3.35.2 - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - - uses: subosito/flutter-action@v2 - with: - flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Dummy Envs - run: | - envsubst < .env.example > .env - env: - ENABLE_UPDATE_CHECK: true - LASTFM_API_KEY: xxx - LASTFM_API_SECRET: xxx - RELEASE_CHANNEL: nightly - HIDE_DONATIONS: 0 - - - name: Configure repo - run: | - flutter pub get - dart run build_runner build --delete-conflicting-outputs - - - name: Lint Dart files - run: | - dart analyze --no-fatal-warnings diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index e682dbdd..0ce12c27 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -1,137 +1,274 @@ -name: Spotube Publish Binary +name: Publish + +# Publishes an existing GitHub release (made by spotube-release-binary.yml) to the package managers. +# The `render` job checks out the release tag, downloads its assets and renders packaging/ with +# Gradle (:composeApp:renderPackaging: version, checksums, git-cliff release notes). The publish +# jobs only push what it rendered. +# +# Secrets: +# FLATHUB_TOKEN PAT with push access to team-spotube/org.spotube.spotube +# AUR_USERNAME, AUR_EMAIL, AUR_SSH_PRIVATE_KEY AUR account that maintains spotube-bin +# WINGET_TOKEN classic PAT (public_repo) that can push to team-spotube/winget-pkgs and open PRs +# CHOCO_API_KEY chocolatey.org API key + on: workflow_dispatch: inputs: - version: - description: Version to publish (x.x.x) - default: 4.0.0 + tag: + description: Release tag to publish (vX.Y.Z) required: true - dry_run: - description: Dry run - required: true - type: boolean - default: true + type: string jobs: description: Jobs to run (flathub,aur,winget,chocolatey) required: true type: string - default: "flathub,aur,winget,chocolatey" + default: flathub,aur,winget,chocolatey + aur_pkgrel: + description: AUR pkgrel (bump to re-release the same version) + required: true + type: string + default: '1' + dry_run: + description: Dry run (render the packages, push nothing) + required: true + type: boolean + default: true + +permissions: + contents: read + +env: + TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ github.token }} jobs: - flathub: - runs-on: ubuntu-22.04 - if: contains(inputs.jobs, 'flathub') + render: + name: Render packages + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@v4 - with: - repository: KRTirtho/com.github.KRTirtho.Spotube - token: ${{ secrets.FLATHUB_TOKEN }} - - - uses: actions/checkout@v4 - with: - path: spotube - - - name: Update flathub version + - name: Validate tag and find release + id: version + env: + DRY_RUN: ${{ inputs.dry_run }} run: | - python3 spotube/scripts/update_flathub_version.py ${{ inputs.version }} - rm -rf spotube - git config --global user.email "krtirtho@gmail.com" - git config --global user.name "Kingkor Roy Tirtho" - git add . - git commit -m "v${{ inputs.version }} Update" - git branch update-${{ inputs.version }} - git switch update-${{ inputs.version }} + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::tag must look like vX.Y.Z, got '$TAG'" + exit 1 + fi + is_draft=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft) + # The packages download from the public release URLs, which a draft doesn't have yet + if [ "$is_draft" = "true" ] && [ "$DRY_RUN" != "true" ]; then + echo "::error::release $TAG is still a draft; publish it first" + exit 1 + fi + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" - - name: Push to flathub + # Full history: git-cliff builds the release notes from it + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + + # Configuring :composeApp runs cargo (Gobley) + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Install git-cliff + uses: taiki-e/install-action@v2 + with: + tool: git-cliff + + - name: Download release assets + run: | + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir release-assets \ + --pattern 'spotube-linux-*.tar.xz' --pattern 'Spotube-windows-x86_64-setup.exe' + + - name: Render packages + env: + PKGREL: ${{ inputs.aur_pkgrel }} + run: | + chmod +x gradlew + ./gradlew :composeApp:renderPackagingFlatpak :composeApp:renderPackagingAur \ + :composeApp:renderPackagingWinget :composeApp:renderPackagingChocolatey \ + -Pnightly=false -Ppkgrel="$PKGREL" -PpackagingArtifactsDir=release-assets + for f in composeApp/build/packaging/{flatpak/*.yml,aur/PKGBUILD,winget/*.yaml,chocolatey/*.nuspec}; do + echo "::group::$f" + cat "$f" + echo "::endgroup::" + done + + - name: Upload rendered packages + uses: actions/upload-artifact@v7 + with: + name: packaging + path: | + composeApp/build/packaging/flatpak + composeApp/build/packaging/aur + composeApp/build/packaging/winget + composeApp/build/packaging/chocolatey + + # Chocolatey embeds the installer in the package + - name: Upload Windows installer + if: contains(inputs.jobs, 'chocolatey') + uses: actions/upload-artifact@v7 + with: + name: windows-installer + path: release-assets/Spotube-windows-x86_64-setup.exe + + flathub: + name: Flathub + needs: render + if: contains(inputs.jobs, 'flathub') + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.render.outputs.version }} + FLATHUB_REPO: team-spotube/org.spotube.spotube + steps: + - uses: actions/download-artifact@v8 + with: + name: packaging + path: packaging + + - uses: actions/checkout@v7 + with: + repository: ${{ env.FLATHUB_REPO }} + token: ${{ secrets.FLATHUB_TOKEN }} + path: flathub + + - name: Commit + working-directory: flathub + run: | + cp ../packaging/flatpak/org.spotube.spotube.yml . + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "update-$VERSION" + git add org.spotube.spotube.yml + git diff --cached + git commit -m "Update to $VERSION" + + - name: Push and open PR if: ${{ !inputs.dry_run }} - run: git push -u origin update-${{ inputs.version }} + working-directory: flathub + env: + GH_TOKEN: ${{ secrets.FLATHUB_TOKEN }} + run: | + git push -u origin "update-$VERSION" + gh pr create --repo "$FLATHUB_REPO" --head "update-$VERSION" \ + --title "Update to $VERSION" \ + --body "Release notes: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG" aur: - runs-on: ubuntu-22.04 + name: AUR + needs: render if: contains(inputs.jobs, 'aur') + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - uses: dsaltares/fetch-gh-release-asset@master + - uses: actions/download-artifact@v8 with: - version: tags/v${{ inputs.version }} # mind the "v" prefix - file: spotube-linux-${{inputs.version}}-x86_64.tar.xz - token: ${{ secrets.GITHUB_TOKEN }} + name: packaging + path: packaging - - name: Update PKGBUILD versions - run: | - sed -i "s/%{{SPOTUBE_VERSION}}%/${{ inputs.version }}/" aur-struct/PKGBUILD - sed -i "s/%{{PKGREL}}%/1/" aur-struct/PKGBUILD - sed -i "s/%{{LINUX_MD5}}%/`md5sum spotube-linux-${{inputs.version}}-x86_64.tar.xz | awk '{print $1}'`/" aur-struct/PKGBUILD - - - name: Release to AUR + - name: Publish to AUR if: ${{ !inputs.dry_run }} - uses: KSXGitHub/github-actions-deploy-aur@v2.7.2 + uses: KSXGitHub/github-actions-deploy-aur@v4.2.0 with: pkgname: spotube-bin - pkgbuild: aur-struct/PKGBUILD + pkgbuild: packaging/aur/PKGBUILD commit_username: ${{ secrets.AUR_USERNAME }} commit_email: ${{ secrets.AUR_EMAIL }} ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - commit_message: Updated to v${{ inputs.version }} + commit_message: Update to ${{ needs.render.outputs.version }}-${{ inputs.aur_pkgrel }} winget: - runs-on: ubuntu-latest + name: WinGet + needs: render if: contains(inputs.jobs, 'winget') + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.render.outputs.version }} + FORK: team-spotube/winget-pkgs + MANIFEST_DIR: manifests/k/KRTirtho/Spotube steps: - - name: Release winget package - if: ${{ !inputs.dry_run }} - uses: vedantmgoyal9/winget-releaser@main + - uses: actions/download-artifact@v8 with: - version: ${{ inputs.version }} - release-tag: v${{ inputs.version }} - identifier: KRTirtho.Spotube + name: packaging + path: packaging + + - name: Sync fork with upstream + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.WINGET_TOKEN }} + run: gh repo sync "$FORK" --source microsoft/winget-pkgs --branch master + + # winget-pkgs is huge: only fetch this package's directory + - uses: actions/checkout@v7 + if: ${{ !inputs.dry_run }} + with: + repository: ${{ env.FORK }} + ref: master token: ${{ secrets.WINGET_TOKEN }} + path: winget-pkgs + sparse-checkout: ${{ env.MANIFEST_DIR }} + + - name: Push and open PR + if: ${{ !inputs.dry_run }} + working-directory: winget-pkgs + env: + GH_TOKEN: ${{ secrets.WINGET_TOKEN }} + run: | + branch="KRTirtho.Spotube-$VERSION" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + mkdir -p "$MANIFEST_DIR/$VERSION" + cp ../packaging/winget/*.yaml "$MANIFEST_DIR/$VERSION/" + git add "$MANIFEST_DIR/$VERSION" + git commit -m "New version: KRTirtho.Spotube version $VERSION" + git push -u origin "$branch" + gh pr create --repo microsoft/winget-pkgs --base master --head "${FORK%%/*}:$branch" \ + --title "New version: KRTirtho.Spotube version $VERSION" \ + --body "Release notes: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG" chocolatey: - runs-on: windows-latest + name: Chocolatey + needs: render if: contains(inputs.jobs, 'chocolatey') + runs-on: windows-latest + env: + VERSION: ${{ needs.render.outputs.version }} steps: - - uses: dsaltares/fetch-gh-release-asset@master + - uses: actions/download-artifact@v8 with: - version: tags/v${{ inputs.version }} # mind the "v" prefix - file: Spotube-windows-x86_64.nupkg - token: ${{ secrets.GITHUB_TOKEN }} + name: packaging + path: packaging - - name: Set up Chocolatey - run: choco apikey -k ${{ secrets.CHOCO_API_KEY }} --source https://push.chocolatey.org/ + - uses: actions/download-artifact@v8 + with: + name: windows-installer + path: packaging/chocolatey/tools - - name: Publish to Chocolatey Repository + - name: Pack + run: choco pack packaging/chocolatey/spotube.nuspec --outputdirectory dist + + - name: Upload package + uses: actions/upload-artifact@v7 + with: + name: chocolatey-nupkg + path: dist/*.nupkg + + - name: Publish to Chocolatey if: ${{ !inputs.dry_run }} - run: choco push Spotube-windows-x86_64.nupkg --source https://push.chocolatey.org/ - - playstore: - runs-on: ubuntu-latest - if: contains(inputs.jobs, 'playstore') - steps: - - name: Tagname (workflow dispatch) - run: echo 'TAG_NAME=${{inputs.version}}' >> $GITHUB_ENV - - # - uses: robinraju/release-downloader@main - # with: - # repository: KRTirtho/spotube - # tag: v${{ env.TAG_NAME }} - # tarBall: false - # zipBall: false - # out-file-path: dist - # fileName: "Spotube-playstore-all-arch.aab" - - # - name: Create service-account.json - # run: | - # echo "${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_BASE64 }}" | base64 -d > service-account.json - - # - name: Upload Android Release to Play Store - # if: ${{!inputs.dry_run}} - # uses: r0adkll/upload-google-play@v1 - # with: - # serviceAccountJson: ./service-account.json - # releaseFiles: ./dist/Spotube-playstore-all-arch.aab - # packageName: oss.krtirtho.spotube - # track: production - # status: draft - # releaseName: ${{ env.TAG_NAME }} + env: + CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }} + run: choco push "dist/spotube.$env:VERSION.nupkg" --source https://push.chocolatey.org/ --api-key "$env:CHOCO_API_KEY" diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 5260eb60..c3ddf0f8 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -1,204 +1,603 @@ -name: Spotube Release Binary +name: Release + on: workflow_dispatch: inputs: - channel: + release_type: + description: 'Release type' + required: true type: choice + default: 'stable' options: - stable - nightly - default: nightly - description: The release channel - debug: - type: boolean - default: false - description: Debug with SSH toggle - required: false dry_run: + description: 'Dry run (build & upload artifacts only, skip creating release)' + required: false type: boolean default: false - description: Dry run without uploading to release + debug_ssh: + description: 'Enable SSH debug session on failure (tmate)' + required: false + type: boolean + default: false + +# Read-only by default; only create-release gets write access +permissions: + contents: read + +# Never cancel a running release when another is dispatched, and let stable and nightly run side by side +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }} + cancel-in-progress: false env: - FLUTTER_VERSION: 3.35.2 - FLUTTER_CHANNEL: master - -permissions: - contents: write + COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview + COMPOSE_WEBVIEW_COMMIT: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d jobs: - build_platform: - strategy: - matrix: - include: - - os: ubuntu-22.04 - platform: linux - arch: x86 - files: | - dist/Spotube-linux-x86_64.deb - dist/Spotube-linux-x86_64.rpm - dist/Spotube-linux-x86_64.AppImage - dist/spotube-linux-*-x86_64.tar.xz - - os: ubuntu-22.04-arm - platform: linux - arch: arm64 - files: | - dist/Spotube-linux-aarch64.deb - dist/Spotube-linux-aarch64.AppImage - dist/spotube-linux-*-aarch64.tar.xz - - os: ubuntu-22.04 - platform: android - arch: all - files: | - build/Spotube-android-all-arch.apk - - os: windows-latest - platform: windows - arch: x86 - files: | - dist/Spotube-windows-x86_64.nupkg - dist/Spotube-windows-x86_64-setup.exe - - os: macos-14 - platform: ios - arch: all - files: | - Spotube-iOS.ipa - - os: macos-14 - platform: macos - arch: all - files: | - build/Spotube-macos-universal.dmg - build/Spotube-macos-universal.pkg - runs-on: ${{matrix.os}} + prepare-deps: + name: Prepare Dependencies + runs-on: macos-latest steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2.18.0 + - name: Checkout ComposeNativeWebview + uses: actions/checkout@v7 with: - flutter-version: ${{ env.FLUTTER_VERSION }} - channel: ${{ env.FLUTTER_CHANNEL }} - cache: true - git-source: https://github.com/flutter/flutter.git + repository: ${{ env.COMPOSE_WEBVIEW_REPO }} + ref: ${{ env.COMPOSE_WEBVIEW_COMMIT }} + token: ${{ secrets.GITHUB_TOKEN }} + path: compose-webview - # - name: free disk space - # if: ${{ matrix.platform == 'android' }} - # run: | - # sudo swapoff -a - # sudo rm -f /swapfile - # sudo apt clean - # docker rmi $(docker image ls -aq) - # df -h - - name: Setup Java - if: ${{matrix.platform == 'android'}} - uses: actions/setup-java@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v6 with: - distribution: "zulu" - java-version: "17" - cache: "gradle" - check-latest: true + distribution: temurin + java-version: 21 - - name: Setup Rust toolchain + - name: Set up Android SDK + uses: android-actions/setup-android@v4 + + - name: Set up Rust uses: dtolnay/rust-toolchain@stable - with: - toolchain: stable - - name: Install Xcode - if: ${{matrix.platform == 'ios'}} - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: "16.2" + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 - - name: Install ${{matrix.platform}} dependencies + - name: Publish compose-webview to mavenLocal + working-directory: compose-webview run: | - flutter pub get - dart cli/cli.dart install-dependencies --platform=${{matrix.platform}} --arch=${{matrix.arch}} + chmod +x gradlew + ./gradlew publishToMavenLocal - - name: Sign Apk - if: ${{matrix.platform == 'android'}} - run: | - echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks - echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties - - - name: Build ${{matrix.platform}} binaries - run: dart cli/cli.dart build --arch=${{matrix.arch}} ${{matrix.platform}} - env: - CHANNEL: ${{inputs.channel}} - DOTENV: ${{secrets.DOTENV_RELEASE}} - - - uses: actions/upload-artifact@v4 + - name: Upload maven local + uses: actions/upload-artifact@v7 with: - if-no-files-found: error - name: ${{matrix.platform}}-${{matrix.arch}} - path: ${{matrix.files}} + name: maven-local + path: ~/.m2/repository/ - - name: Debug With SSH When fails - if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true - - upload: - runs-on: ubuntu-22.04 - needs: - - build_platform + build-android: + name: Android + needs: prepare-deps + timeout-minutes: 120 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@v7 + + - name: Download maven local + uses: actions/download-artifact@v8 with: - path: ./Spotube-Release-Binaries + name: maven-local + path: ~/.m2/repository/ - - name: Install dependencies - run: sudo apt-get install tree -y + - name: Set up JDK 21 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 - - name: Generate Checksums + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Decode keystore + env: + KEYSTORE: ${{ secrets.KEYSTORE }} run: | - tree . - find Spotube-Release-Binaries -type f -exec md5sum {} \; >> RELEASE.md5sum - find Spotube-Release-Binaries -type f -exec sha256sum {} \; >> RELEASE.sha256sum - sed -i 's|Spotube-Release-Binaries/.*/\([^/]*\)$|\1|' RELEASE.sha256sum RELEASE.md5sum - sed -i 's|Spotube-Release-Binaries/||' RELEASE.sha256sum RELEASE.md5sum + echo "$KEYSTORE" | base64 --decode > "${{ github.workspace }}/composeApp/upload-keystore.jks" - - name: Extract pubspec version + - name: Configure Android signing + working-directory: composeApp + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} + KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} run: | - echo "PUBSPEC_VERSION=$(grep -oP 'version:\s*\K[^+]+(?=\+)' pubspec.yaml)" >> $GITHUB_ENV + cat >> local.properties <> "$GITHUB_OUTPUT" + echo "is_draft=true" >> "$GITHUB_OUTPUT" + echo "release_name=Spotube ${TAG}" >> "$GITHUB_OUTPUT" + else + TAG="nightly" + VERSION="$(date +%Y-%m-%d)-${{ github.run_number }}" + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + echo "is_draft=false" >> "$GITHUB_OUTPUT" + echo "release_name=Nightly ${VERSION}" >> "$GITHUB_OUTPUT" + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Download all artifacts + uses: actions/download-artifact@v8 + + - name: Generate release notes + run: | + echo "## Downloads" > release-notes.md + echo "" >> release-notes.md + for dir in android-apk linux-deb linux-rpm linux-appimage linux-tarball linux-deb-arm64 linux-rpm-arm64 linux-appimage-arm64 linux-tarball-arm64 windows-exe macos-dmg; do + if [ -d "$dir" ]; then + echo "### $dir" >> release-notes.md + for f in "$dir"/*; do + echo "- \`$(basename "$f")\`" >> release-notes.md + done + echo "" >> release-notes.md + fi + done + + # The rolling nightly tag already exists, and the release action never moves an existing tag + - name: Move nightly tag to this commit + if: ${{ inputs.release_type == 'nightly' }} + run: | + git tag -f nightly "$GITHUB_SHA" + git push -f origin refs/tags/nightly + + - name: Create Release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.vars.outputs.tag }} + # A new tag (stable) would otherwise point at the default branch instead of the built commit + target_commitish: ${{ github.sha }} + fail_on_unmatched_files: true + name: ${{ steps.vars.outputs.release_name }} + body_path: release-notes.md + draft: ${{ steps.vars.outputs.is_draft }} + prerelease: ${{ steps.vars.outputs.is_prerelease }} + files: | + android-apk/*.apk + linux-deb/*.deb + linux-rpm/*.rpm + linux-appimage/*.AppImage + linux-tarball/*.tar.xz + linux-deb-arm64/*.deb + linux-rpm-arm64/*.rpm + linux-appimage-arm64/*.AppImage + linux-tarball-arm64/*.tar.xz + windows-exe/*.exe + macos-dmg/*.dmg diff --git a/.gitignore b/.gitignore index 544dbba8..68c4954a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,34 @@ +*.iml +.kotlin +.gradle +**/build/ +xcuserdata +!src/**/build/ +local.properties +.idea +.DS_Store +captures +.externalNativeBuild +.cxx +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +**/xcshareddata/WorkspaceSettings.xcsettings +node_modules/ +composeApp/release/ + +**/*.log + +/composeApp/vlc-natives/ + + # Miscellaneous *.class *.log *.pyc *.swp -.DS_Store .atom/ .buildlog/ .history @@ -11,7 +36,6 @@ # IntelliJ related -*.iml *.ipr *.iws .idea/ @@ -84,3 +108,10 @@ tm.json android/build android/app/.cxx + +/node_modules +/package.json +/package-lock.json +/bun.lock + +**/target/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..e69de29b diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..4c36a5e5 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "compose-hot-reload": { + "command": "./gradlew", + "args": [ + "--no-daemon", + "--quiet", + "--console=plain", + "hotMcpServer" + ] + }, + "studio": { + "url": "http://127.0.0.1:64342/stream", + "type": "http" + } + } +} \ No newline at end of file diff --git a/.metadata b/.metadata deleted file mode 100644 index e8b36fde..00000000 --- a/.metadata +++ /dev/null @@ -1,30 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "d7b523b356d15fb81e7d340bbe52b47f93937323" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 - base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 - - platform: windows - create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 - base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc new file mode 100644 index 00000000..f733b628 --- /dev/null +++ b/.opencode/opencode.jsonc @@ -0,0 +1,21 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "compose-hot-reload": { + "enabled": true, + "disabled": false, + "type": "local", + "command": [ + "./gradlew", + "--no-daemon", + "--quiet", + "--console=plain", + "hotMcpServer" + ] + }, + "studio": { + "url": "http://127.0.0.1:64342/stream", + "type": "remote" + } + } +} \ No newline at end of file diff --git a/.vscode/c_cpp_properties.json b/.vscode/c_cpp_properties.json deleted file mode 100644 index 6d27ad30..00000000 --- a/.vscode/c_cpp_properties.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "configurations": [ - { - "name": "Win32", - "includePath": [ - "${workspaceFolder}/**" - ], - "defines": [ - "_DEBUG", - "UNICODE", - "_UNICODE" - ], - "windowsSdkVersion": "10.0.19041.0", - "compilerPath": "C:\\Program Files (x86)\\Microsoft Visual Studio\\2019\\Community\\VC\\Tools\\MSVC\\14.29.30133\\bin\\Hostx64\\x64\\cl.exe", - "cStandard": "c17", - "cppStandard": "c++17", - "intelliSenseMode": "windows-msvc-x64", - "configurationProvider": "ms-vscode.makefile-tools" - } - ], - "version": 4 -} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index b81e2eee..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "spotube", - "type": "dart", - "request": "launch", - "program": "lib/main.dart", - }, - { - "name": "spotube (mobile)", - "type": "dart", - "request": "launch", - "program": "lib/main.dart", - "args": [ - "--flavor", - "dev" - ] - }, - { - "name": "spotube (mobile-skia)", - "type": "dart", - "request": "launch", - "program": "lib/main.dart", - "args": [ - "--flavor", - "dev", - "--no-enable-impeller" - ] - }, - { - "name": "spotube (profile)", - "type": "dart", - "request": "launch", - "program": "lib/main.dart", - "flutterMode": "profile" - }, - { - "name": "spotube (release)", - "type": "dart", - "request": "launch", - "program": "lib/main.dart", - "flutterMode": "release" - }, - { - "name": "spotube (mobile) (release)", - "type": "dart", - "request": "launch", - "program": "lib/main.dart", - "flutterMode": "release", - "args": [ - "--flavor", - "dev" - ] - } - ], - "compounds": [] -} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 6cfcec03..073e46d0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,6 @@ { + "gradle.autoDetect": "off", + "java.gradle.buildServer.enabled": "off", "cmake.configureOnOpen": false, "cSpell.words": [ "acousticness", diff --git a/.vscode/snippets.code-snippets b/.vscode/snippets.code-snippets deleted file mode 100644 index 9a18929b..00000000 --- a/.vscode/snippets.code-snippets +++ /dev/null @@ -1,170 +0,0 @@ -{ - "PaginatedState": { - "scope": "dart", - "prefix": "paginatedState", - "description": "Generate a PaginatedState", - "body": [ - "class ${1:Model}State extends PaginatedState<${2:Model}> {", - " ${1:Model}State({", - " required super.items,", - " required super.offset,", - " required super.limit,", - " required super.hasMore,", - " });", - " ", - " @override", - " ${1:Model}State copyWith({", - " List<${2:Model}>? items,", - " int? offset,", - " int? limit,", - " bool? hasMore,", - " }) {", - " return ${1:Model}State(", - " items: items ?? this.items,", - " offset: offset ?? this.offset,", - " limit: limit ?? this.limit,", - " hasMore: hasMore ?? this.hasMore,", - " );", - " }", - "}" - ] - }, - "PaginatedAsyncNotifier": { - "scope": "dart", - "prefix": "paginatedAsyncNotifier", - "description": "Generate a PaginatedAsyncNotifier", - "body": [ - "class ${1:NotifierName}Notifier extends PaginatedAsyncNotifier<${3:Item}, ${2:Model}State> {", - " ${1:NotifierName}Notifier() : super();", - " ", - " @override", - " fetch(int offset, int limit) async {", - " throw UnimplementedError();", - " }", - " ", - " @override", - " build() async {", - " throw UnimplementedError();", - " }", - "}" - ] - }, - "PaginaitedNotifierWithState": { - "scope": "dart", - "prefix": "paginatedNotifierWithState", - "description": "Generate a PaginatedNotifier with PaginatedState", - "body": [ - "class $1State extends PaginatedState<$2> {", - " $1State({", - " required super.items,", - " required super.offset,", - " required super.limit,", - " required super.hasMore,", - " });", - " ", - " @override", - " $1State copyWith({", - " List<$2>? items,", - " int? offset,", - " int? limit,", - " bool? hasMore,", - " }) {", - " return $1State(", - " items: items ?? this.items,", - " offset: offset ?? this.offset,", - " limit: limit ?? this.limit,", - " hasMore: hasMore ?? this.hasMore,", - " );", - " }", - "}", - " ", - "class $1Notifier", - " extends PaginatedAsyncNotifier<$2, $1State> {", - " $1Notifier() : super();", - " ", - " @override", - " fetch(int offset, int limit) async {", - " throw UnimplementedError();", - " }", - " ", - " @override", - " build() async {", - " throw UnimplementedError();", - " }", - "}", - " ", - "final ${1/(.*)/${1:/camelcase}/}Provider = AsyncNotifierProvider<$1Notifier, $1State>(", - " ()=> $1Notifier(),", - ");" - ] - }, - "FamilyPaginatedAsyncNotifier": { - "scope": "dart", - "prefix": "familyPaginatedAsyncNotifier", - "description": "Generate a FamilyPaginatedAsyncNotifier", - "body": [ - "class ${1:NotifierName}Notifier extends FamilyPaginatedAsyncNotifier<${3:Item}, ${2:Model}State, {$4:Arg}> {", - " ${1:NotifierName}Notifier() : super();", - " ", - " @override", - " fetch(arg, offset, limit) async {", - " throw UnimplementedError();", - " }", - " ", - " @override", - " build(arg) async {", - " throw UnimplementedError();", - " }", - "}" - ] - }, - "FamilyPaginaitedNotifierWithState": { - "scope": "dart", - "prefix": "familyPaginatedNotifierWithState", - "description": "Generate a FamilyPaginatedAsyncNotifier with PaginatedState", - "body": [ - "class $1State extends PaginatedState<$2> {", - " $1State({", - " required super.items,", - " required super.offset,", - " required super.limit,", - " required super.hasMore,", - " });", - " ", - " @override", - " $1State copyWith({", - " List<$2>? items,", - " int? offset,", - " int? limit,", - " bool? hasMore,", - " }) {", - " return $1State(", - " items: items ?? this.items,", - " offset: offset ?? this.offset,", - " limit: limit ?? this.limit,", - " hasMore: hasMore ?? this.hasMore,", - " );", - " }", - "}", - " ", - "class $1Notifier", - " extends FamilyPaginatedAsyncNotifier<$2, $1State, $3> {", - " $1Notifier() : super();", - " ", - " @override", - " fetch(arg, offset, limit) async {", - " throw UnimplementedError();", - " }", - " ", - " @override", - " build(arg) async {", - " throw UnimplementedError();", - " }", - "}", - " ", - "final ${1/(.*)/${1:/camelcase}/}Provider = AsyncNotifierProviderFamily<$1Notifier, $1State, $3>(", - " ()=> $1Notifier(),", - ");" - ] - }, -} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index f67eb4c6..00000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [] -} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..816d9590 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,92 @@ +# AGENTS + +## Repo shape (KMP + modules) +- Gradle multi-module: `:composeApp` (main app), `:plugin_interfaces` (plugin API contracts), `:js_plugin_example` (Zipline JS plugin template). +- `:composeApp` uses custom KMP source sets (`mobileMain`, `androidJvmMain`) with explicit `dependsOn`; Gradle prints "Default Kotlin Hierarchy Template Not Applied Correctly" warning. Treat as known/expected. + +## Real app entrypoints +- Desktop/JVM: `composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt` (`mainClass = org.spotube.spotube.MainKt`). +- Android: `composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt`. +- iOS bridge: `composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt` and `iosApp/iosApp/ContentView.swift`. + +## High-value commands +- Use Gradle wrapper (`./gradlew` or `./gradlew.bat`) only. +- Run desktop: `:composeApp:run` +- Build Android debug: `:composeApp:assembleStableDebug` (or `assembleNightlyDebug`). Android has `stable`/`nightly` product flavors (nightly = beta channel: `.nightly` applicationId suffix, own icons/name in `composeApp/src/nightly/res`). Desktop icons come from `composeApp/icons/{stable,nightly}`, chosen with `-Pnightly=true`. +- Module checks: `:composeApp:check`, `:plugin_interfaces:check`, `:js_plugin_example:check` +- Focused tests: `:composeApp:jvmTest`, `:composeApp:iosSimulatorArm64Test`, `:plugin_interfaces:jvmTest`, `:plugin_interfaces:jsTest`, `:js_plugin_example:jsTest` +- No lint/typecheck/formatter tasks are configured; `:composeApp:check` is the only aggregated check. + +## Dependencies +- `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations. +- Kotlin: `2.4.21`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`). +- **compose-webview (desktop) dispatcher hijack:** `dev.nucleusframework:composewebview` transitively pulls `nucleus.decorated-window-tao`, which registers a `TaoMainDispatcherFactory` (`loadPriority = 100`) via `META-INF/services` that hijacks `Dispatchers.Main` away from the Swing EDT. This breaks lifecycle's `enforceMainThreadIfNeeded` (window fails with "Method addObserver must be called on the main thread"). The app overrides it with `SwingMainDispatcherFactory` (`composeApp/src/jvmMain/.../core/coroutines/`, `loadPriority = Int.MAX_VALUE`) which pins `Dispatchers.Main` back to the Swing EDT. Keep that factory + its `META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory` resource; the long-term fix is in the webview (make the Tao dispatcher opt-in or lower its priority). + +## Networking +- Never call `HttpClient(...)` directly. Inject `HttpClientFactory` (`commonMain/.../core/network/HttpClientFactory.kt`, a Koin single) and use `.default` / `.json` (shared, never close them) or `.create { ... }` for extra plugins (caller owns and may close it). It owns the one engine-backed client, so the platform default engine is used everywhere (Darwin on iOS, OkHttp on Android/desktop). +- Do not add `ktor-client-cio`: on iOS it competes with Darwin as the default engine and has no TLS on Kotlin/Native, so every HTTPS request fails. + +## GraalVM native image (desktop) +- Enabled via `graalvm { }` inside `nucleus.application` in `composeApp/build.gradle.kts`. Nucleus downloads GraalVM CE itself (or uses `$GRAALVM_HOME`). Linux needs `gcc` + `patchelf`. +- Build: `:composeApp:createGraalvmNativeDistributable` (output `composeApp/build/compose/binaries/main/graalvm-app/`), installers via `packageGraalvm{Deb,Rpm,AppImage,Dmg,Msi,Exe}`. A build takes ~10 min and uses all cores and ~15 GB RAM. +- Hand-maintained + agent-recorded metadata lives in `composeApp/graalvm/reachability-metadata.json`. After adding features that use reflection/JNI/JNA (vlcj, Zipline, plugins), run `:composeApp:runWithNativeAgent`, exercise the feature, then close the window normally (the merge only happens on a clean exit). +- `uk.co.caprica.vlcj.binding.lib.LibVlc` must stay registered (`allDeclaredMethods` + `jniAccessible`): JNA direct mapping finds its `native` methods by reflection. +- The `*UberJarForCurrentOS` task excludes other OSes'/CPUs' native libs and stale CEF binaries, because native-image embeds every registered resource into the binary. On Linux, `copyGraalvmJvmSo` is disabled and `copyGraalvmAwtSoLibs` prefers native-image's own libs (10 KB `libjvm.so`/`libjava.so` shims instead of the JDK's 28 MB `libjvm.so`). +- `isProductionBuild` (top of `composeApp/build.gradle.kts`) is true for `package*`/`create*`/`notarize*`/`nativeImageCompile` tasks, or with `-Pproduction=true|false`. It gates the Rust release profile (Gobley `jvmVariant`) so `:composeApp:run` keeps fast debug Rust builds. Put new slow, ship-only optimizations behind it. +- Full user-facing build docs are in `README.md` ("Building from source"). + +## Codegen and plugin packaging +- JS plugin bundles: `:js_plugin_example:packageDevelopmentPlugin` and `:js_plugin_example:packageProductionPlugin`. Output is `.smplug` files in `js_plugin_example/build/distributions/`. +- Zipline plugin entrypoint: `mainFunction = "org.spotube.js_plugin_example.main"` in `js_plugin_example/build.gradle.kts`. Plugin metadata from `js_plugin_example/plugin.json`. + +## Plugin architecture +- `plugin_interfaces` exports `zipline.core` and `semver` as API. `composeApp` depends on it for the plugin system. +- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system. + +## UI component patterns +- **ALL user-facing strings MUST come from `composeApp/src/commonMain/composeResources/values/strings.xml` via `stringResource(Res.string.xxx)`.** Never hardcode a literal string in a `Text(...)`, dialog title, button label, content description, placeholder, or error message inside `@Composable` code. If the string you need doesn't exist yet, add a new `` entry to `strings.xml` (grouped near related keys, following the existing `feature_context_purpose` naming convention) and reference it — do not inline the text "just this once". This applies to every module, including ones you're about to add (dialogs, screens, snackbars, etc.), not just `modules/settings`. `strings.xml` is the single source of truth for copy, not a showcase file for one screen. +- **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors. +- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain. +- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required. +- Use `expect`/`actual` only when the component MUST use platform-specific APIs (e.g. `WindowState` for desktop window controls, native scrollbars). Pure Compose/Material3 adaptivity stays in commonMain. +- JavaFX is required; `--add-opens` flags in `compose.desktop.application.jvmArgs` must be preserved: `javafx.graphics/javafx.scene`, `javafx.graphics/com.sun.javafx.sg.prism`, `javafx.graphics/com.sun.javafx.scene`, `javafx.web/com.sun.webkit`, `javafx.media/com.sun.media.jfxmedia`, `javafx.media/com.sun.media.jfxmedia.events`. +- JavaFX dependencies are loaded from OpenJFX with platform classifiers (win/mac/linux) resolved at configuration time via `System.getProperty("os.name")`. + +## Tooling +- Gradle config cache enabled (`gradle.properties`); prefer module-scoped tasks. +- Gradle daemon JVM pinned to JetBrains JDK 21 via `gradle/gradle-daemon-jvm.properties`. +- Gradle 8.14.3 (from `gradle/wrapper/gradle-wrapper.properties`). +- Foojay toolchain resolver in use (`plugins { id("org.gradle.toolchains.foojay-resolver-convention") }`). + +## Current testing reality +- No committed `*Test*.kt` files; test tasks may run zero tests unless new tests are added. + +## Release workflow (`.github/workflows/release.yml`) +- Triggered by `workflow_dispatch` with a `release_type` choice input (`stable` | `nightly`). +- `prepare-deps` job checks out & publishes `kdroidFilter/ComposeNativeWebview` (compose-webview) to `mavenLocal`. The spotube gradle plugin and vlcj-bundler now come from Maven Central. +- Build jobs per platform (Android, Linux, Windows, macOS), all `needs: prepare-deps`. Desktop jobs ship GraalVM native installers (`packageGraalvm{Deb,Rpm,Exe,Dmg}`, output in `composeApp/build/compose/binaries/main/graalvm-/`), not jpackage/JVM ones. +- **Stable**: reads `versionName` from `composeApp/build.gradle.kts`, tag = `v{version}`, draft release. +- **Nightly**: builds with `-PversionName=nightly`, tag = `nightly` (updates existing), prerelease. +- **Android signing**: decodes `secrets.KEYSTORE` (base64) → `composeApp/upload-keystore.jks`, writes signing config into `local.properties` from secrets. +- `create-release` job (depends on all builds) uses `softprops/action-gh-release@v2` to create the GitHub release with all artifacts attached. + +## Package managers (`packaging/`, `.github/workflows/spotube-publish-binary.yml`) +- `packaging/` (`linux`, `aur`, `flatpak`, `winget`, `chocolatey`) holds JMustache templates (`*.mustache`) plus files copied as is. `:composeApp:renderPackaging` renders them into `composeApp/build/packaging/` (`RenderPackagingTask` in buildSrc documents the template data). Never hand-write versions, checksums or release notes into these files. +- Checksums come from release assets in `-PpackagingArtifactsDir=`; a template using a missing value fails the render. Release notes come from `git-cliff --context` with `cliff.toml` (`GitCliffContextTask`): needs `git-cliff` on the PATH and full git history. `gradle.properties` defaults to `nightly=true`, which leaves commits unreleased: pass `-Pnightly=false` for a stable render. +- `packaging/package-linux-tarball.sh` packs `spotube-linux--.tar.xz` (the AUR and Flathub source) from the GraalVM distributable and the rendered `linux` dir; the release workflow builds it before the AppImage step. +- The publish workflow (`workflow_dispatch`, input `tag`) renders once, then pushes to Flathub (`team-spotube/org.spotube.spotube`), the AUR (`spotube-bin`), WinGet (PR from `team-spotube/winget-pkgs`) and Chocolatey. + +## OpenCode Agent MCP Execution Directives + +### 1. Code Discovery & Context Search +- Use `android-studio-mcp` search/symbol tools to locate declarations before making edits across `:composeApp`, `:plugin_interfaces`, or `:js_plugin_example`[cite: 1, 2]. +- Keep `gradle/libs.versions.toml` as the single source of truth for dependencies and version updates. + +### 2. UI Architecture Constraints +- **Strict ViewModel Separation:** UI composables in `commonMain` must only collect `StateFlow` and emit event callbacks. Do NOT write business logic, filtering, or `mutableStateListOf` directly inside `@Composable` functions. +- **Adaptive Components:** Use `AdaptiveDropdownBottomSheet` or `AdaptiveDialogBottomSheet` inside `commonMain` for adaptive layouts[cite: 2]. Do NOT create platform-specific `expect/actual` variants unless platform-native APIs (like windowing or native scrollbars) are explicitly required[cite: 2]. + +### 3. Verification & Diagnostic Loops +- **Post-Edit Verification:** After editing Kotlin code, validate compilation by running `./gradlew :composeApp:check` or `./gradlew :composeApp:assembleStableDebug` via MCP[cite: 2]. +- **Dispatcher Integrity:** Never remove or bypass `SwingMainDispatcherFactory` in `composeApp/src/jvmMain/.../core/coroutines/`[cite: 2]. It is required to prevent `compose-webview` from hijacking `Dispatchers.Main` away from the Swing EDT[cite: 2]. +- **Live Preview Iteration:** Use `compose-hot-reload` to evaluate UI layout changes headlessly without forcing full Gradle rebuilds. diff --git a/CHANGELOG.md b/CHANGELOG.md index 70cf335a..2953c4bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [5.1.2](https://github.com/KRTirtho/spotube/compare/v5.1.1...v5.1.2) (2026-06-05) +## [5.1.2](https://github.com/team-spotube/spotube/compare/v5.1.1...v5.1.2) (2026-06-05) ### Bug Fixes @@ -8,14 +8,14 @@ - Dismiss search dropdown and keyboard on submission - Custom image helper null exception -## [5.1.1](https://github.com/KRTirtho/spotube/compare/v5.1.0...v5.1.1) (2026-02-24) +## [5.1.1](https://github.com/team-spotube/spotube/compare/v5.1.0...v5.1.1) (2026-02-24) ### Bug Fixes - Upgrade NewPipeExtractor to latest version fixing playback issue - Lyrics not working -## [5.1.0](https://github.com/KRTirtho/spotube/compare/v5.0.0...v5.1.0) (2025-11-14) +## [5.1.0](https://github.com/team-spotube/spotube/compare/v5.0.0...v5.1.0) (2025-11-14) ### Features @@ -23,6 +23,8 @@ - **playback**: Add dab music source - **playback**: Add uncompressed flac playback support - Add plugin audio source models and api service +- **plugins**: Filter plugins by abilities in plugins page and show abilities as + badge - **plugins**: Filter plugins by abilities in plugins page and show abilities as badge - Add setting default audio source support @@ -30,7 +32,7 @@ - Add NewPipe support for desktop platforms - Add default plugin loading capability - **queue**: Add multi-select and bulk actions to queue - ([#2839](https://github.com/KRTirtho/spotube/issues/2839)) + ([#2839](https://github.com/team-spotube/spotube/issues/2839)) - **android**: Add 16KB page size support ### Bug Fixes @@ -41,19 +43,21 @@ - Download not working in different devices and slow - **playback**: Use stream instead of chunked serving of audio bytes -## [5.0.0](https://github.com/KRTirtho/spotube/compare/v4.0.2...v5.0.0) (2025-09-08) +## [5.0.0](https://github.com/team-spotube/spotube/compare/v4.0.2...v5.0.0) (2025-09-08) ### Features - Add ISRC track search for YouTube - ([#2594](https://github.com/KRTirtho/spotube/issues/2594)) + ([#2594](https://github.com/team-spotube/spotube/issues/2594)) - Add new icons #2676 by @alexio-dev - ([#2678](https://github.com/KRTirtho/spotube/issues/2678)) + ([#2678](https://github.com/team-spotube/spotube/issues/2678)) - Add connect confirmation dialog - Add metadata api service and models - **metadata-plugin**: Add pagination support, feed and playlist CRUD endpoints - **metadata-plugin**: Add local storage api - Add webview, totp and setInterval apis for plugins +- Enhance local storage and webview APIs with improved error handling and + resource management - Enhance local storage and webview APIs with improved error handling and resource management - **metadata_plugin**: Add logout method @@ -92,6 +96,8 @@ - Local track not working and images of local not showing up - Local playback not working for tracks with special # (hashtag) characters - Inaccessible streaming url causing rapid skips +- **yt**: Fallback to different search result if all streaming url is + inaccessible - **yt**: Fallback to different search result if all streaming url is inaccessible - **playback**: Skip network requests if cached file already exists @@ -105,25 +111,29 @@ ### Translation - Add Traditional Chinese translation - ([#2762](https://github.com/KRTirtho/spotube/issues/2762)) + ([#2762](https://github.com/team-spotube/spotube/issues/2762)) - Fix Japanese translations - ([#2732](https://github.com/KRTirtho/spotube/issues/2732)) + ([#2732](https://github.com/team-spotube/spotube/issues/2732)) - Correction of the dutch language - ([#1306](https://github.com/KRTirtho/spotube/issues/1306)) + ([#1306](https://github.com/team-spotube/spotube/issues/1306)) -## [4.0.2](https://github.com/krtirtho/spotube/compare/v4.0.1...v4.0.2) (2025-03-16) +## [4.0.2](https://github.com/team-spotube/spotube/compare/v4.0.1...v4.0.2) (2025-03-16) ### Bug Fixes - invalid access token exception #2525 -## [4.0.1](https://github.com/krtirtho/spotube/compare/v4.0.0...v4.0.1) (2025-03-15) +## [4.0.1](https://github.com/team-spotube/spotube/compare/v4.0.0...v4.0.1) (2025-03-15) ### Bug Fixes - **android**: navigation overlaying in app navigation - add to playlist not working in smaller screen devices - language picker search broken +- **generate_playlist**: create playlist not adding tracks nor navigating to + playlist page +- **desktop**: double titlebar in local library folders and massive space in + overlay player - **generate_playlist**: create playlist not adding tracks nor navigating to playlist page - **desktop**: double titlebar in local library folders and massive space in @@ -138,7 +148,7 @@ - **translation**: add tagalog language support #2504 - **translation**: add tamil translation for spotube #2501 -## [4.0.0](https://github.com/krtirtho/spotube/compare/v3.9.0...v4.0.0) (2025-03-07) +## [4.0.0](https://github.com/team-spotube/spotube/compare/v3.9.0...v4.0.0) (2025-03-07) ## Changes @@ -154,6 +164,8 @@ - youtube source taking too long to buffer - youtube tracks keeps skipping despite being matched correctly - follow artist not working #2057 +- youtube_explode_dart failing for many videos due to youtube ios client visitor + data change - youtube_explode_dart failing for many videos due to youtube ios client visitor data change - piped api not working @@ -169,7 +181,7 @@ - implement yt-dlp for desktop and NewPipeExtractor for Android (#2316) - custom piped & invidious instance support -## [3.9.0](https://github.com/krtirtho/spotube/compare/v3.8.3...v3.9.0) (2024-12-08) +## [3.9.0](https://github.com/team-spotube/spotube/compare/v3.8.3...v3.9.0) (2024-12-08) ## Changes @@ -180,13 +192,17 @@ #2039 - **mobile**: ensure audio session is activated when playback is resumed after interruption #2092 +- selecting an Alternative Track Source removes the current song from the queue + #2039 +- **mobile**: ensure audio session is activated when playback is resumed after + interruption #2092 ### Features - add invidious audio source and fix auto skipping tracks (#2005) - track caching and cached track export support (#2117) -## [3.8.3](https://github.com/krtirtho/spotube/compare/v3.8.2...v3.8.3) (2024-10-09) +## [3.8.3](https://github.com/team-spotube/spotube/compare/v3.8.2...v3.8.3) (2024-10-09) ## Changes @@ -198,7 +214,7 @@ - **macos**: enable same window webview support -## [3.8.2](https://github.com/krtirtho/spotube/compare/v3.8.1...v3.8.2) (2024-09-30) +## [3.8.2](https://github.com/team-spotube/spotube/compare/v3.8.1...v3.8.2) (2024-09-30) ## Changes @@ -206,7 +222,7 @@ - endless song loading issue and no playback #1925 -## [3.8.1](https://github.com/krtirtho/spotube/compare/v3.8.0...v3.8.1) (2024-09-15) +## [3.8.1](https://github.com/team-spotube/spotube/compare/v3.8.0...v3.8.1) (2024-09-15) ## Changes @@ -215,11 +231,15 @@ - **translations**: correct some basque incorrect translations (#1815) - **lyrics**: LRCLIB lyrics should be usable without logging in #1803 - playlist displaying descriptions unescaped html #1784 +- **android**: pressing back while the player is open doesn't take to previous + page - **android**: pressing back while the player is open doesn't take to previous page - handle dublicated items in playback queue correctly #1852 - **desktop**: scrollbar overlapping with more options of tracks and playlists - **discord**: stop discord rpc from try update presence when not connected +- **stats**: minutes page shows plays and streams page shows minutes which + should be the opposite #1880 - **stats**: minutes page shows plays and streams page shows minutes which should be the opposite #1880 - **android**: clears queue upon swiping away notification @@ -234,7 +254,7 @@ - **desktop**: show error dialog if webview is not found on login #1871 - manually detect and define touch behavior #1763 -## [3.8.0](https://github.com/krtirtho/spotube/compare/v3.7.1...v3.8.0) (2024-06-06) +## [3.8.0](https://github.com/team-spotube/spotube/compare/v3.7.1...v3.8.0) (2024-06-06) ### Features @@ -268,1415 +288,1423 @@ - popup menu item opacity - linux: change app id in flatpak environment -## [3.7.1](https://github.com/krtirtho/spotube/compare/v3.7.0...v3.7.1) (2024-06-06) +## [3.7.1](https://github.com/team-spotube/spotube/compare/v3.7.0...v3.7.1) (2024-06-06) ### Bug Fixes - alternative sources not showing up for SongLink matched results - ([37d002d](https://github.com/krtirtho/spotube/commit/37d002d133cacb3a34884713ac8f6637694af57c)) + ([37d002d](https://github.com/team-spotube/spotube/commit/37d002d133cacb3a34884713ac8f6637694af57c)) - **android:** Media Controls not working above Android 14 - [#1561](https://github.com/krtirtho/spotube/issues/1561) - ([3394c1b](https://github.com/krtirtho/spotube/commit/3394c1b0574e44dc624b2b2f0bf32f343ae9f049)) + [#1561](https://github.com/team-spotube/spotube/issues/1561) + ([3394c1b](https://github.com/team-spotube/spotube/commit/3394c1b0574e44dc624b2b2f0bf32f343ae9f049)) - browse anonymously button takes to wrong route - ([73c5b30](https://github.com/krtirtho/spotube/commit/73c5b30b63a4c82bb7ad5b52bc10c5594566a800)) + ([73c5b30](https://github.com/team-spotube/spotube/commit/73c5b30b63a4c82bb7ad5b52bc10c5594566a800)) - **desktop:** titlebar drag to move not working - ([5f280a1](https://github.com/krtirtho/spotube/commit/5f280a19f4d5f8882ae2ff60c6cc595ded7a5a1d)) + ([5f280a1](https://github.com/team-spotube/spotube/commit/5f280a19f4d5f8882ae2ff60c6cc595ded7a5a1d)) - **desktop:** window is not centered - ([47f98b9](https://github.com/krtirtho/spotube/commit/47f98b98aafab9b426733ed44cab2be8a646a98e)) + ([47f98b9](https://github.com/team-spotube/spotube/commit/47f98b98aafab9b426733ed44cab2be8a646a98e)) - **ios:** download not working - [#1575](https://github.com/krtirtho/spotube/issues/1575) - ([6591ec0](https://github.com/krtirtho/spotube/commit/6591ec0e1b441dd8fd535eace19a58c7749389ca)) + [#1575](https://github.com/team-spotube/spotube/issues/1575) + ([6591ec0](https://github.com/team-spotube/spotube/commit/6591ec0e1b441dd8fd535eace19a58c7749389ca)) - **linux:** application window not visible after launch - ([8fc44ed](https://github.com/krtirtho/spotube/commit/8fc44ed6550e8b2b804991ff82df08afb1c94ca8)) + ([8fc44ed](https://github.com/team-spotube/spotube/commit/8fc44ed6550e8b2b804991ff82df08afb1c94ca8)) - local track not showing up in queue - ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) + ([d82261c](https://github.com/team-spotube/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) - use weak match for Jiosaavn fallback to improve matching - ([6cb2986](https://github.com/krtirtho/spotube/commit/6cb29868d2030b5e9312863c17e8f24889942e24)) + ([6cb2986](https://github.com/team-spotube/spotube/commit/6cb29868d2030b5e9312863c17e8f24889942e24)) - **windows:** media controls not showing up - [#1542](https://github.com/krtirtho/spotube/issues/1542) - ([d7d864f](https://github.com/krtirtho/spotube/commit/d7d864ff2bc937675a544a7edf645c5148ec836a)) + [#1542](https://github.com/team-spotube/spotube/issues/1542) + ([d7d864f](https://github.com/team-spotube/spotube/commit/d7d864ff2bc937675a544a7edf645c5148ec836a)) - **windows:** revert Flutter version to 3.19.6 to avoid distortion - [#1553](https://github.com/krtirtho/spotube/issues/1553) - ([982cf0b](https://github.com/krtirtho/spotube/commit/982cf0bd435638fa20f17ef527fe21d031b5ffaf)) + [#1553](https://github.com/team-spotube/spotube/issues/1553) + ([982cf0b](https://github.com/team-spotube/spotube/commit/982cf0bd435638fa20f17ef527fe21d031b5ffaf)) -## [3.7.0](https://github.com/krtirtho/spotube/compare/v3.6.0...v3.7.0) (2024-06-03) +## [3.7.0](https://github.com/team-spotube/spotube/compare/v3.6.0...v3.7.0) (2024-06-03) ### Features - local library folder cards - ([fc5bfa0](https://github.com/krtirtho/spotube/commit/fc5bfa089ce2f46ab786565d6750564d704ee7e0)) -- Local music library ([#1479](https://github.com/krtirtho/spotube/issues/1479)) - ([22caa81](https://github.com/krtirtho/spotube/commit/22caa818f4ac31626aaff6952e43512b42237d00)) + ([fc5bfa0](https://github.com/team-spotube/spotube/commit/fc5bfa089ce2f46ab786565d6750564d704ee7e0)) +- Local music library ([#1479](https://github.com/team-spotube/spotube/issues/1479)) + ([22caa81](https://github.com/team-spotube/spotube/commit/22caa818f4ac31626aaff6952e43512b42237d00)) - personalized stats based on local music history - ([#1522](https://github.com/krtirtho/spotube/issues/1522)) - ([82307bc](https://github.com/krtirtho/spotube/commit/82307bc030035b03ab1b8d8ec7b24da19a866b12)) + ([#1522](https://github.com/team-spotube/spotube/issues/1522)) + ([82307bc](https://github.com/team-spotube/spotube/commit/82307bc030035b03ab1b8d8ec7b24da19a866b12)) - play initially available tracks of playlist/album immediately and fetch rest - in background [#670](https://github.com/krtirtho/spotube/issues/670) - ([02acbd9](https://github.com/krtirtho/spotube/commit/02acbd93271145dde365f6c547e0d9d902be65f1)) + in background [#670](https://github.com/team-spotube/spotube/issues/670) + ([02acbd9](https://github.com/team-spotube/spotube/commit/02acbd93271145dde365f6c547e0d9d902be65f1)) - **player:** add volume slider floating label showing percentage - ([#1445](https://github.com/krtirtho/spotube/issues/1445)) - ([8fad225](https://github.com/krtirtho/spotube/commit/8fad2251b3536e9468e0fb193939ead98bad3bc6)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1171](https://github.com/krtirtho/spotube/issues/1171) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1082](https://github.com/krtirtho/spotube/issues/1082) + ([#1445](https://github.com/team-spotube/spotube/issues/1445)) + ([8fad225](https://github.com/team-spotube/spotube/commit/8fad2251b3536e9468e0fb193939ead98bad3bc6)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1171](https://github.com/team-spotube/spotube/issues/1171) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1082](https://github.com/team-spotube/spotube/issues/1082) - **translations:** add Basque translation - ([#1493](https://github.com/krtirtho/spotube/issues/1493)) - ([dbc1c45](https://github.com/krtirtho/spotube/commit/dbc1c452dd53153c61589f956ea9836cea7bf2bb)) + ([#1493](https://github.com/team-spotube/spotube/issues/1493)) + ([dbc1c45](https://github.com/team-spotube/spotube/commit/dbc1c452dd53153c61589f956ea9836cea7bf2bb)) - **translations:** add Finnish translations - ([#1449](https://github.com/krtirtho/spotube/issues/1449)) - ([edc997e](https://github.com/krtirtho/spotube/commit/edc997e7470ce17f60c96b8198dc8851cbf21f18)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1171](https://github.com/krtirtho/spotube/issues/1171) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1082](https://github.com/krtirtho/spotube/issues/1082) + ([#1449](https://github.com/team-spotube/spotube/issues/1449)) + ([edc997e](https://github.com/team-spotube/spotube/commit/edc997e7470ce17f60c96b8198dc8851cbf21f18)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1171](https://github.com/team-spotube/spotube/issues/1171) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1082](https://github.com/team-spotube/spotube/issues/1082) - **translations:** add georgian language - ([#1450](https://github.com/krtirtho/spotube/issues/1450)) - ([1e7f0e1](https://github.com/krtirtho/spotube/commit/1e7f0e1fe71e0a8d86614fc884861f8791469112)) + ([#1450](https://github.com/team-spotube/spotube/issues/1450)) + ([1e7f0e1](https://github.com/team-spotube/spotube/commit/1e7f0e1fe71e0a8d86614fc884861f8791469112)) - **translations:** add Indonesian translation - ([#1426](https://github.com/krtirtho/spotube/issues/1426)) - ([0280654](https://github.com/krtirtho/spotube/commit/0280654bb6bad373aee521f5a866228d2d38f038)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1171](https://github.com/krtirtho/spotube/issues/1171) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1082](https://github.com/krtirtho/spotube/issues/1082) + ([#1426](https://github.com/team-spotube/spotube/issues/1426)) + ([0280654](https://github.com/team-spotube/spotube/commit/0280654bb6bad373aee521f5a866228d2d38f038)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1171](https://github.com/team-spotube/spotube/issues/1171) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1082](https://github.com/team-spotube/spotube/issues/1082) - **translations:** Improve tr locales - ([#1419](https://github.com/krtirtho/spotube/issues/1419)) - ([bf45681](https://github.com/krtirtho/spotube/commit/bf45681deb951c772bf6ca05e213c949c04bded1)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1171](https://github.com/krtirtho/spotube/issues/1171) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1082](https://github.com/krtirtho/spotube/issues/1082) + ([#1419](https://github.com/team-spotube/spotube/issues/1419)) + ([bf45681](https://github.com/team-spotube/spotube/commit/bf45681deb951c772bf6ca05e213c949c04bded1)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1171](https://github.com/team-spotube/spotube/issues/1171) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1082](https://github.com/team-spotube/spotube/issues/1082) - upgrade to Flutter 3.22.0 - ([71341ec](https://github.com/krtirtho/spotube/commit/71341ec0bda6ed985b43836712075b97a2cf8bac)) + ([71341ec](https://github.com/team-spotube/spotube/commit/71341ec0bda6ed985b43836712075b97a2cf8bac)) ### Bug Fixes - fallback to LRCLIB when lyrics line less than 6 lines - [#1461](https://github.com/krtirtho/spotube/issues/1461) - ([9aea354](https://github.com/krtirtho/spotube/commit/9aea35468fa7cd176ddc8810b37b90c2d8246931)) + [#1461](https://github.com/team-spotube/spotube/issues/1461) + ([9aea354](https://github.com/team-spotube/spotube/commit/9aea35468fa7cd176ddc8810b37b90c2d8246931)) - **linux:** tray icon not showing - [#541](https://github.com/krtirtho/spotube/issues/541) - ([7ac7917](https://github.com/krtirtho/spotube/commit/7ac791757abb30f40374c169c4211916287bb3f3)) + [#541](https://github.com/team-spotube/spotube/issues/541) + ([7ac7917](https://github.com/team-spotube/spotube/commit/7ac791757abb30f40374c169c4211916287bb3f3)) - local track not showing up in queue - ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) + ([d82261c](https://github.com/team-spotube/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) - **macos:** Logs directory not created by default - [#1353](https://github.com/krtirtho/spotube/issues/1353) - ([4ca8939](https://github.com/krtirtho/spotube/commit/4ca893950b07f678acf7db690112c47d21e54782)) + [#1353](https://github.com/team-spotube/spotube/issues/1353) + ([4ca8939](https://github.com/team-spotube/spotube/commit/4ca893950b07f678acf7db690112c47d21e54782)) - **playback:** skipping tracks with unplayable sources instead of falling back - [#1492](https://github.com/krtirtho/spotube/issues/1492) - ([c607a33](https://github.com/krtirtho/spotube/commit/c607a330ed279dfbebe8d4bd325745ac6301a58f)) + [#1492](https://github.com/team-spotube/spotube/issues/1492) + ([c607a33](https://github.com/team-spotube/spotube/commit/c607a330ed279dfbebe8d4bd325745ac6301a58f)) - **search:** load more button not working - [#1417](https://github.com/krtirtho/spotube/issues/1417) - ([7e07c2e](https://github.com/krtirtho/spotube/commit/7e07c2e1985da7ccb96b1fac2ecd703720068d26)) + [#1417](https://github.com/team-spotube/spotube/issues/1417) + ([7e07c2e](https://github.com/team-spotube/spotube/commit/7e07c2e1985da7ccb96b1fac2ecd703720068d26)) - some text are garbled in different parts of the app - [#1463](https://github.com/krtirtho/spotube/issues/1463) - [#1505](https://github.com/krtirtho/spotube/issues/1505) - ([d2683c5](https://github.com/krtirtho/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) + [#1463](https://github.com/team-spotube/spotube/issues/1463) + [#1505](https://github.com/team-spotube/spotube/issues/1505) + ([d2683c5](https://github.com/team-spotube/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) - spotify friends and user profile icon (mobile) showing when not authenticated - [#1410](https://github.com/krtirtho/spotube/issues/1410) - ([9bccbc9](https://github.com/krtirtho/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) + [#1410](https://github.com/team-spotube/spotube/issues/1410) + ([9bccbc9](https://github.com/team-spotube/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) - **updater:** dead link - ([#1408](https://github.com/krtirtho/spotube/issues/1408)) - ([6907f9c](https://github.com/krtirtho/spotube/commit/6907f9c756d8f49aadb1b23a2a1dc8bf7d658dc0)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1171](https://github.com/krtirtho/spotube/issues/1171) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1082](https://github.com/krtirtho/spotube/issues/1082) + ([#1408](https://github.com/team-spotube/spotube/issues/1408)) + ([6907f9c](https://github.com/team-spotube/spotube/commit/6907f9c756d8f49aadb1b23a2a1dc8bf7d658dc0)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1171](https://github.com/team-spotube/spotube/issues/1171) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1082](https://github.com/team-spotube/spotube/issues/1082) - windows SSL Certificate error breaking login - [#905](https://github.com/krtirtho/spotube/issues/905) - ([#1474](https://github.com/krtirtho/spotube/issues/1474)) - ([937a706](https://github.com/krtirtho/spotube/commit/937a706ac9c0e59943b2609e5cc398dcdbed2344)), - closes [#1468](https://github.com/krtirtho/spotube/issues/1468) + [#905](https://github.com/team-spotube/spotube/issues/905) + ([#1474](https://github.com/team-spotube/spotube/issues/1474)) + ([937a706](https://github.com/team-spotube/spotube/commit/937a706ac9c0e59943b2609e5cc398dcdbed2344)), + closes [#1468](https://github.com/team-spotube/spotube/issues/1468) - **windows:** installer tries to install in current directory - ([c3c9fc5](https://github.com/krtirtho/spotube/commit/c3c9fc544c68b3d897dd7241a61cab7a199b4539)) + ([c3c9fc5](https://github.com/team-spotube/spotube/commit/c3c9fc544c68b3d897dd7241a61cab7a199b4539)) -## [3.6.0](https://github.com/krtirtho/spotube/compare/v3.5.0...v3.6.0) (2024-04-15) +## [3.6.0](https://github.com/team-spotube/spotube/compare/v3.5.0...v3.6.0) (2024-04-15) ### Features - add Spotify homepage personalized recommendations - ([#1402](https://github.com/krtirtho/spotube/issues/1402)) - ([9e25c74](https://github.com/krtirtho/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11)) + ([#1402](https://github.com/team-spotube/spotube/issues/1402)) + ([9e25c74](https://github.com/team-spotube/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11)) - add user profile page - ([39e97ee](https://github.com/krtirtho/spotube/commit/39e97eef34d87348a264843e145f31f82832d12e)) + ([39e97ee](https://github.com/team-spotube/spotube/commit/39e97eef34d87348a264843e145f31f82832d12e)) - **android:** Filter Device To Force High Frame Rate - ([#880](https://github.com/krtirtho/spotube/issues/880)) - ([6e41b10](https://github.com/krtirtho/spotube/commit/6e41b106fa989adee393d3ce2535e75446ad3eea)) + ([#880](https://github.com/team-spotube/spotube/issues/880)) + ([6e41b10](https://github.com/team-spotube/spotube/commit/6e41b106fa989adee393d3ce2535e75446ad3eea)) - improved caching based on riverpod - ([#1343](https://github.com/krtirtho/spotube/issues/1343)) - ([6673e5a](https://github.com/krtirtho/spotube/commit/6673e5a8a86b9667cf9dbff9bb7c40ea6b7de771)) + ([#1343](https://github.com/team-spotube/spotube/issues/1343)) + ([6673e5a](https://github.com/team-spotube/spotube/commit/6673e5a8a86b9667cf9dbff9bb7c40ea6b7de771)) - LAN connect a.k.a control remote Spotube playback and local output device - selection ([#1355](https://github.com/krtirtho/spotube/issues/1355)) - ([68374ef](https://github.com/krtirtho/spotube/commit/68374efd3ec556f31b937e5b96920787b54eec78)) + selection ([#1355](https://github.com/team-spotube/spotube/issues/1355)) + ([68374ef](https://github.com/team-spotube/spotube/commit/68374efd3ec556f31b937e5b96920787b54eec78)) - **lyrics:** add LRCLIB lyrics provider as fallback - ([5afe823](https://github.com/krtirtho/spotube/commit/5afe823abdb198340b55d138d8173d886a811632)) + ([5afe823](https://github.com/team-spotube/spotube/commit/5afe823abdb198340b55d138d8173d886a811632)) - search history support - [#1236](https://github.com/krtirtho/spotube/issues/1236) - ([82b1cfa](https://github.com/krtirtho/spotube/commit/82b1cfa0d775e3958c666280943a893c9113d468)) + [#1236](https://github.com/team-spotube/spotube/issues/1236) + ([82b1cfa](https://github.com/team-spotube/spotube/commit/82b1cfa0d775e3958c666280943a893c9113d468)) - **translations:** Add Czech translation - ([#1401](https://github.com/krtirtho/spotube/issues/1401)) - ([5a6b800](https://github.com/krtirtho/spotube/commit/5a6b80091259359bc38c4b91cd8cb496c4270fa4)) + ([#1401](https://github.com/team-spotube/spotube/issues/1401)) + ([5a6b800](https://github.com/team-spotube/spotube/commit/5a6b80091259359bc38c4b91cd8cb496c4270fa4)) - **translations:** add Thai Language - ([#1319](https://github.com/krtirtho/spotube/issues/1319)) - ([b70f250](https://github.com/krtirtho/spotube/commit/b70f250e8d5137fd990787ec9e3d058126cf14f3)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) + ([#1319](https://github.com/team-spotube/spotube/issues/1319)) + ([b70f250](https://github.com/team-spotube/spotube/commit/b70f250e8d5137fd990787ec9e3d058126cf14f3)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) ### Bug Fixes - instance of Artist bug - [#1362](https://github.com/krtirtho/spotube/issues/1362) - ([c8dd802](https://github.com/krtirtho/spotube/commit/c8dd8025ec96bd78ed77cae35f1429aa48c16fde)) + [#1362](https://github.com/team-spotube/spotube/issues/1362) + ([c8dd802](https://github.com/team-spotube/spotube/commit/c8dd8025ec96bd78ed77cae35f1429aa48c16fde)) - **playback:** sponsor block skips and stutters in same position - ([0d080b7](https://github.com/krtirtho/spotube/commit/0d080b77b72529c0be5ebc27ace1c52307511f73)) + ([0d080b7](https://github.com/team-spotube/spotube/commit/0d080b77b72529c0be5ebc27ace1c52307511f73)) -## [3.5.0](https://github.com/krtirtho/spotube/compare/v3.4.1...v3.5.0) (2024-03-08) +## [3.5.0](https://github.com/team-spotube/spotube/compare/v3.4.1...v3.5.0) (2024-03-08) ### Features - add endless playback support - [#285](https://github.com/krtirtho/spotube/issues/285) - ([9dfd49c](https://github.com/krtirtho/spotube/commit/9dfd49ca04f0e915e333e205b17ac70456873f6e)) + [#285](https://github.com/team-spotube/spotube/issues/285) + ([9dfd49c](https://github.com/team-spotube/spotube/commit/9dfd49ca04f0e915e333e205b17ac70456873f6e)) - add getting started page - ([96a2a1f](https://github.com/krtirtho/spotube/commit/96a2a1f5a622cb3c580041417d5023e37fa69716)) + ([96a2a1f](https://github.com/team-spotube/spotube/commit/96a2a1f5a622cb3c580041417d5023e37fa69716)) - Add iOS background play support - ([#1166](https://github.com/krtirtho/spotube/issues/1166)) - ([095587e](https://github.com/krtirtho/spotube/commit/095587ee84f7d867c69fcf4b09ed608d63478e1e)) + ([#1166](https://github.com/team-spotube/spotube/issues/1166)) + ([095587e](https://github.com/team-spotube/spotube/commit/095587ee84f7d867c69fcf4b09ed608d63478e1e)) - add songlink based track matching for youtube and open song link button - ([9095a8c](https://github.com/krtirtho/spotube/commit/9095a8c8f849e42daabb7efcc20085cfb863c974)) + ([9095a8c](https://github.com/team-spotube/spotube/commit/9095a8c8f849e42daabb7efcc20085cfb863c974)) - **playlist:** show confirmation before deleting user playlist - [#1222](https://github.com/krtirtho/spotube/issues/1222) - ([9f92440](https://github.com/krtirtho/spotube/commit/9f9244062a39759aa0ce28d2d5f7c8fa53d73003)) -- Sort by Duration ([#1238](https://github.com/krtirtho/spotube/issues/1238)) - ([6f8271f](https://github.com/krtirtho/spotube/commit/6f8271f5e9394cb4053e41dd222aa2844c34d609)) + [#1222](https://github.com/team-spotube/spotube/issues/1222) + ([9f92440](https://github.com/team-spotube/spotube/commit/9f9244062a39759aa0ce28d2d5f7c8fa53d73003)) +- Sort by Duration ([#1238](https://github.com/team-spotube/spotube/issues/1238)) + ([6f8271f](https://github.com/team-spotube/spotube/commit/6f8271f5e9394cb4053e41dd222aa2844c34d609)) - start radio support - ([4defeef](https://github.com/krtirtho/spotube/commit/4defeefe7e5947aa00a2afb2a06577ec141cdc52)) + ([4defeef](https://github.com/team-spotube/spotube/commit/4defeefe7e5947aa00a2afb2a06577ec141cdc52)) - **translations:** add Korean translation - ([#1275](https://github.com/krtirtho/spotube/issues/1275)) - ([fdea930](https://github.com/krtirtho/spotube/commit/fdea9307bbfb8f3f62cfb795bfb3ca58c38c33d9)) + ([#1275](https://github.com/team-spotube/spotube/issues/1275)) + ([fdea930](https://github.com/team-spotube/spotube/commit/fdea9307bbfb8f3f62cfb795bfb3ca58c38c33d9)) - **translations:** Added Vietnamese - ([#1135](https://github.com/krtirtho/spotube/issues/1135)) - ([019ba86](https://github.com/krtirtho/spotube/commit/019ba865e20a8b54ea3490c01e47158eaf3a4c8d)) + ([#1135](https://github.com/team-spotube/spotube/issues/1135)) + ([019ba86](https://github.com/team-spotube/spotube/commit/019ba865e20a8b54ea3490c01e47158eaf3a4c8d)) - **windows:** Install Visual C++ 2015-2022 Redistributable if missing when installing - ([ba69496](https://github.com/krtirtho/spotube/commit/ba69496dcc9a1b7f6ea4e104e71764a854d27f1f)) + ([ba69496](https://github.com/team-spotube/spotube/commit/ba69496dcc9a1b7f6ea4e104e71764a854d27f1f)) ### Bug Fixes - album images are small in certain places - ([ca76a39](https://github.com/krtirtho/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2)) + ([ca76a39](https://github.com/team-spotube/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2)) - album, artist page not loading - [#1282](https://github.com/krtirtho/spotube/issues/1282) - ([a9a1d4c](https://github.com/krtirtho/spotube/commit/a9a1d4c9dc24aaf3181dc4090d1822ebfe755991)) + [#1282](https://github.com/team-spotube/spotube/issues/1282) + ([a9a1d4c](https://github.com/team-spotube/spotube/commit/a9a1d4c9dc24aaf3181dc4090d1822ebfe755991)) - **android:** audio issue when screen is off and broadcast audio session id - ([#1221](https://github.com/krtirtho/spotube/issues/1221) & - [#1247](https://github.com/krtirtho/spotube/issues/1247)) - ([17105a6](https://github.com/krtirtho/spotube/commit/17105a640bf5107bd5d333b9b4d097c14a3949a2)), - closes [KRTirtho/spotube#571](https://github.com/KRTirtho/spotube/issues/571) + ([#1221](https://github.com/team-spotube/spotube/issues/1221) & + [#1247](https://github.com/team-spotube/spotube/issues/1247)) + ([17105a6](https://github.com/team-spotube/spotube/commit/17105a640bf5107bd5d333b9b4d097c14a3949a2)), + closes [KRTirtho/spotube#571](https://github.com/team-spotube/spotube/issues/571) - **android:** only ask battery optimization once - [#1252](https://github.com/krtirtho/spotube/issues/1252) - ([e516afb](https://github.com/krtirtho/spotube/commit/e516afb185f616471822ea745495a3d1d1281bd3)) + [#1252](https://github.com/team-spotube/spotube/issues/1252) + ([e516afb](https://github.com/team-spotube/spotube/commit/e516afb185f616471822ea745495a3d1d1281bd3)) - **android:** pressing back button in any other tab other than home exits the app - ([c3289a0](https://github.com/krtirtho/spotube/commit/c3289a0ba4e7de094a15246677ffcb940504ebde)) + ([c3289a0](https://github.com/team-spotube/spotube/commit/c3289a0ba4e7de094a15246677ffcb940504ebde)) - **android:** system back button in player page exits the app - ([3294f65](https://github.com/krtirtho/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d)) + ([3294f65](https://github.com/team-spotube/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d)) - cleanTitle removing feat and ft from words instead of whole words - ([8612345](https://github.com/krtirtho/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5)) + ([8612345](https://github.com/team-spotube/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5)) - friends list not scrollable with mouse drag - ([ab08c82](https://github.com/krtirtho/spotube/commit/ab08c82c8dd501263049f3adcbd48907ba13e3a9)) + ([ab08c82](https://github.com/team-spotube/spotube/commit/ab08c82c8dd501263049f3adcbd48907ba13e3a9)) - no draggable scrollbar in playlist/album page - [#1158](https://github.com/krtirtho/spotube/issues/1158) - ([6f71e52](https://github.com/krtirtho/spotube/commit/6f71e52ea8a5712d2c3527f2a524af9fbb718bef)) + [#1158](https://github.com/team-spotube/spotube/issues/1158) + ([6f71e52](https://github.com/team-spotube/spotube/commit/6f71e52ea8a5712d2c3527f2a524af9fbb718bef)) - non-banger songs breaking the queue if sources not found - ([90f7c53](https://github.com/krtirtho/spotube/commit/90f7c531cdc8640afdbabf5a0592159715ea1e6f)) + ([90f7c53](https://github.com/team-spotube/spotube/commit/90f7c531cdc8640afdbabf5a0592159715ea1e6f)) - track loading when not found in Youtube - ([e964f61](https://github.com/krtirtho/spotube/commit/e964f61d38cb303e3d3fd60c866414f57207181c)) + ([e964f61](https://github.com/team-spotube/spotube/commit/e964f61d38cb303e3d3fd60c866414f57207181c)) - **translations:** Update app_nl.arb - ([#1168](https://github.com/krtirtho/spotube/issues/1168)) - ([8167963](https://github.com/krtirtho/spotube/commit/8167963212eeb5dfb0b4fb2eadf81d466659a9f1)) + ([#1168](https://github.com/team-spotube/spotube/issues/1168)) + ([8167963](https://github.com/team-spotube/spotube/commit/8167963212eeb5dfb0b4fb2eadf81d466659a9f1)) -## [3.4.1](https://personal.github.com/krtirtho/spotube/compare/v3.4.0...v3.4.1) (2024-01-27) +## [3.4.1](https://personal.github.com/team-spotube/spotube/compare/v3.4.0...v3.4.1) (2024-01-27) ### Features - add create playlist button in add playlist dialog - ([2168a64](https://personal.github.com/krtirtho/spotube/commit/2168a640af3104a43139c303d78e2c2326a1bda7)) + ([2168a64](https://personal.github.com/team-spotube/spotube/commit/2168a640af3104a43139c303d78e2c2326a1bda7)) - add spotify friends activity - ([#1130](https://personal.github.com/krtirtho/spotube/issues/1130)) - ([7983932](https://personal.github.com/krtirtho/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557)) + ([#1130](https://personal.github.com/team-spotube/spotube/issues/1130)) + ([7983932](https://personal.github.com/team-spotube/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557)) - **deep-link:** add track opening page - ([988a975](https://personal.github.com/krtirtho/spotube/commit/988a975bf1a675df0cfc7b17776bcec74c67f1f2)) + ([988a975](https://personal.github.com/team-spotube/spotube/commit/988a975bf1a675df0cfc7b17776bcec74c67f1f2)) - haptic feedback on long press and reordering actions - ([6242200](https://personal.github.com/krtirtho/spotube/commit/624220090572eb643dce37ca8ffd85d2b3f5c9df)) + ([6242200](https://personal.github.com/team-spotube/spotube/commit/624220090572eb643dce37ca8ffd85d2b3f5c9df)) - improve youtube/piped matching by suffixing "- Topic" - ([8184555](https://personal.github.com/krtirtho/spotube/commit/8184555ee89fd30aaf886af9fc1d52c142fdebb0)) + ([8184555](https://personal.github.com/team-spotube/spotube/commit/8184555ee89fd30aaf886af9fc1d52c142fdebb0)) - **translations:** add Nepali (नेपाली) translations - ([#1111](https://personal.github.com/krtirtho/spotube/issues/1111)) - ([c3ebf56](https://personal.github.com/krtirtho/spotube/commit/c3ebf56ac149b0af8815a5533fe6c386df743440)), - closes [#1074](https://personal.github.com/krtirtho/spotube/issues/1074) - [#1100](https://personal.github.com/krtirtho/spotube/issues/1100) + ([#1111](https://personal.github.com/team-spotube/spotube/issues/1111)) + ([c3ebf56](https://personal.github.com/team-spotube/spotube/commit/c3ebf56ac149b0af8815a5533fe6c386df743440)), + closes [#1074](https://personal.github.com/team-spotube/spotube/issues/1074) + [#1100](https://personal.github.com/team-spotube/spotube/issues/1100) ### Bug Fixes - alternative searched sources doesn't play - [#1059](https://personal.github.com/krtirtho/spotube/issues/1059) - ([a8e9b82](https://personal.github.com/krtirtho/spotube/commit/a8e9b824f33add8f6a83f0d147e889eb6beeb442)) + [#1059](https://personal.github.com/team-spotube/spotube/issues/1059) + ([a8e9b82](https://personal.github.com/team-spotube/spotube/commit/a8e9b824f33add8f6a83f0d147e889eb6beeb442)) - alternative source doesn't persist on next restart - [#840](https://personal.github.com/krtirtho/spotube/issues/840) - ([62fde50](https://personal.github.com/krtirtho/spotube/commit/62fde50442f04f93255b5b1b1dcca23d116a13ec)) + [#840](https://personal.github.com/team-spotube/spotube/issues/840) + ([62fde50](https://personal.github.com/team-spotube/spotube/commit/62fde50442f04f93255b5b1b1dcca23d116a13ec)) - **android:** download failing for permission issues - [#1015](https://personal.github.com/krtirtho/spotube/issues/1015) - ([5509cae](https://personal.github.com/krtirtho/spotube/commit/5509cae91c8b1f5cb9fac179060f477397a4a27f)) + [#1015](https://personal.github.com/team-spotube/spotube/issues/1015) + ([5509cae](https://personal.github.com/team-spotube/spotube/commit/5509cae91c8b1f5cb9fac179060f477397a4a27f)) - artist page error - [#1018](https://personal.github.com/krtirtho/spotube/issues/1018) - ([8cd650b](https://personal.github.com/krtirtho/spotube/commit/8cd650b07e5f4c4c2f296bf4374e5ee67fb3eb50)) + [#1018](https://personal.github.com/team-spotube/spotube/issues/1018) + ([8cd650b](https://personal.github.com/team-spotube/spotube/commit/8cd650b07e5f4c4c2f296bf4374e5ee67fb3eb50)) - audio resumes after a phone call even if it was paused before - [#926](https://personal.github.com/krtirtho/spotube/issues/926) - ([fd1899f](https://personal.github.com/krtirtho/spotube/commit/fd1899f162395752142d7aa7320d1c39b0995070)) + [#926](https://personal.github.com/team-spotube/spotube/issues/926) + ([fd1899f](https://personal.github.com/team-spotube/spotube/commit/fd1899f162395752142d7aa7320d1c39b0995070)) - better error message for failing to find lyrics - [#1085](https://personal.github.com/krtirtho/spotube/issues/1085) - ([e58e18d](https://personal.github.com/krtirtho/spotube/commit/e58e18de33d7bc6fb0e4ddd7ccf6ea14472642b1)) + [#1085](https://personal.github.com/team-spotube/spotube/issues/1085) + ([e58e18d](https://personal.github.com/team-spotube/spotube/commit/e58e18de33d7bc6fb0e4ddd7ccf6ea14472642b1)) - Black window flash when starting the app - ([#1003](https://personal.github.com/krtirtho/spotube/issues/1003)) - ([02e44fc](https://personal.github.com/krtirtho/spotube/commit/02e44fc6b849a873adad382f5d46ed8caf32359f)) + ([#1003](https://personal.github.com/team-spotube/spotube/issues/1003)) + ([02e44fc](https://personal.github.com/team-spotube/spotube/commit/02e44fc6b849a873adad382f5d46ed8caf32359f)) - **linux:** crash after login - ([0dfd401](https://personal.github.com/krtirtho/spotube/commit/0dfd40153714b7a4b83ac30f0c56830bc0c05ffd)) + ([0dfd401](https://personal.github.com/team-spotube/spotube/commit/0dfd40153714b7a4b83ac30f0c56830bc0c05ffd)) - **macos:** backbutton and window button overlap and unused empty space on home - ([b9417ca](https://personal.github.com/krtirtho/spotube/commit/b9417ca3575992673357230dab49e0124dd576b1)) + ([b9417ca](https://personal.github.com/team-spotube/spotube/commit/b9417ca3575992673357230dab49e0124dd576b1)) - **macos:** download folder unchangeable - ([9d74cf5](https://personal.github.com/krtirtho/spotube/commit/9d74cf5fc250a6a143321d49b8e045519b4c2872)) + ([9d74cf5](https://personal.github.com/team-spotube/spotube/commit/9d74cf5fc250a6a143321d49b8e045519b4c2872)) - **macos:** Respect Minimize to tray option - ([#1001](https://personal.github.com/krtirtho/spotube/issues/1001)) - ([69559ba](https://personal.github.com/krtirtho/spotube/commit/69559ba24285636e42b2f2231f956c31388c5cf3)) + ([#1001](https://personal.github.com/team-spotube/spotube/issues/1001)) + ([69559ba](https://personal.github.com/team-spotube/spotube/commit/69559ba24285636e42b2f2231f956c31388c5cf3)) - **macos:** system tray shows name and sidebar weird gap - [#1083](https://personal.github.com/krtirtho/spotube/issues/1083) - ([27057ea](https://personal.github.com/krtirtho/spotube/commit/27057ea0c8d83c9701057c18b473f1af4e4e82be)) + [#1083](https://personal.github.com/team-spotube/spotube/issues/1083) + ([27057ea](https://personal.github.com/team-spotube/spotube/commit/27057ea0c8d83c9701057c18b473f1af4e4e82be)) - releases section is empty when user doesn't follow any artists - [#1104](https://personal.github.com/krtirtho/spotube/issues/1104) - ([682e88e](https://personal.github.com/krtirtho/spotube/commit/682e88e0c55bc0f4708bc0b4681b129e5c61c999)) + [#1104](https://personal.github.com/team-spotube/spotube/issues/1104) + ([682e88e](https://personal.github.com/team-spotube/spotube/commit/682e88e0c55bc0f4708bc0b4681b129e5c61c999)) - search page vertical scrollbar moves on horizontal scroll - [#1017](https://personal.github.com/krtirtho/spotube/issues/1017) - ([c203ac6](https://personal.github.com/krtirtho/spotube/commit/c203ac69ee74ba8722dae3da4b47761cd8d59c34)) + [#1017](https://personal.github.com/team-spotube/spotube/issues/1017) + ([c203ac6](https://personal.github.com/team-spotube/spotube/commit/c203ac69ee74ba8722dae3da4b47761cd8d59c34)) - songs doesn't play when sources with preferred audio codec is empty - ([#976](https://personal.github.com/krtirtho/spotube/issues/976)) - ([ba4e11a](https://personal.github.com/krtirtho/spotube/commit/ba4e11a40ab18308437a05333a46eace6f8eeb5a)) + ([#976](https://personal.github.com/team-spotube/spotube/issues/976)) + ([ba4e11a](https://personal.github.com/team-spotube/spotube/commit/ba4e11a40ab18308437a05333a46eace6f8eeb5a)) - track index not showing after 200 - ([a752cf4](https://personal.github.com/krtirtho/spotube/commit/a752cf4c978d1b05851aabb6c84c7862de551320)) + ([a752cf4](https://personal.github.com/team-spotube/spotube/commit/a752cf4c978d1b05851aabb6c84c7862de551320)) - track pad horizontal scrolling not working - ([59e0e6b](https://personal.github.com/krtirtho/spotube/commit/59e0e6bb659b70831f6e0ae064100381c57f149c)) + ([59e0e6b](https://personal.github.com/team-spotube/spotube/commit/59e0e6bb659b70831f6e0ae064100381c57f149c)) -## [3.4.0](https://github.com/KRTirtho/spotube/compare/v3.3.0...v3.4.0) (2023-12-30) +## [3.4.0](https://github.com/team-spotube/spotube/compare/v3.3.0...v3.4.0) (2023-12-30) ### Features - Add Go to Album option in track option - [#917](https://github.com/KRTirtho/spotube/issues/917) - ([b0beeca](https://github.com/KRTirtho/spotube/commit/b0beeca0cbaf810fae27832cff98cfda95715050)) + [#917](https://github.com/team-spotube/spotube/issues/917) + ([b0beeca](https://github.com/team-spotube/spotube/commit/b0beeca0cbaf810fae27832cff98cfda95715050)) - **translations:** add Italian language translations - ([#818](https://github.com/KRTirtho/spotube/issues/818)) - ([e4eb0e2](https://github.com/KRTirtho/spotube/commit/e4eb0e2596ade2bb5195e183f03af42742fc8486)), - closes [#676](https://github.com/KRTirtho/spotube/issues/676) - [#676](https://github.com/KRTirtho/spotube/issues/676) + ([#818](https://github.com/team-spotube/spotube/issues/818)) + ([e4eb0e2](https://github.com/team-spotube/spotube/commit/e4eb0e2596ade2bb5195e183f03af42742fc8486)), + closes [#676](https://github.com/team-spotube/spotube/issues/676) + [#676](https://github.com/team-spotube/spotube/issues/676) - compact genre view in home page - ([82ed5e9](https://github.com/KRTirtho/spotube/commit/82ed5e90576b57ef32e61a65015e04862ab15461)) -- Deep link support ([#950](https://github.com/KRTirtho/spotube/issues/950)) - ([4050f55](https://github.com/KRTirtho/spotube/commit/4050f556400aaec5515231578512cf1a6b990110)) + ([82ed5e9](https://github.com/team-spotube/spotube/commit/82ed5e90576b57ef32e61a65015e04862ab15461)) +- Deep link support ([#950](https://github.com/team-spotube/spotube/issues/950)) + ([4050f55](https://github.com/team-spotube/spotube/commit/4050f556400aaec5515231578512cf1a6b990110)) - improve loading animations - ([b92583d](https://github.com/KRTirtho/spotube/commit/b92583d0df7b8dee0d121cd2bb666b14c77d8c86)) + ([b92583d](https://github.com/team-spotube/spotube/commit/b92583d0df7b8dee0d121cd2bb666b14c77d8c86)) - toggle for discord rpc - ([24a2294](https://github.com/KRTirtho/spotube/commit/24a2294512bb0c4aff77bc8dcad9b4de3e8b45c6)) + ([24a2294](https://github.com/team-spotube/spotube/commit/24a2294512bb0c4aff77bc8dcad9b4de3e8b45c6)) - **translations:** add Dutch Language - ([#969](https://github.com/KRTirtho/spotube/issues/969)) - ([3ad7ba6](https://github.com/KRTirtho/spotube/commit/3ad7ba66b56e93e69d2181d47029b7549ed225fc)) + ([#969](https://github.com/team-spotube/spotube/issues/969)) + ([3ad7ba6](https://github.com/team-spotube/spotube/commit/3ad7ba66b56e93e69d2181d47029b7549ed225fc)) ### Bug Fixes - add safe area in home - ([9ee6067](https://github.com/KRTirtho/spotube/commit/9ee60677f6d50df7468e12dc6653ecedefa2494f)) + ([9ee6067](https://github.com/team-spotube/spotube/commit/9ee60677f6d50df7468e12dc6653ecedefa2494f)) - amoled mode and color scheme can't be changed - ([840e014](https://github.com/KRTirtho/spotube/commit/840e014f2b18f193d040baef0e0cd595088a4a84)) + ([840e014](https://github.com/team-spotube/spotube/commit/840e014f2b18f193d040baef0e0cd595088a4a84)) - doesn't minimize to tray when system title bar close button is used - [#866](https://github.com/KRTirtho/spotube/issues/866) - ([bb8f250](https://github.com/KRTirtho/spotube/commit/bb8f250f5f351c1a353791b77b25b9de7586191f)) + [#866](https://github.com/team-spotube/spotube/issues/866) + ([bb8f250](https://github.com/team-spotube/spotube/commit/bb8f250f5f351c1a353791b77b25b9de7586191f)) - genre border issues - ([2fb16e6](https://github.com/KRTirtho/spotube/commit/2fb16e64e9cdfca54d633cdf287b0544ecdda3b6)) + ([2fb16e6](https://github.com/team-spotube/spotube/commit/2fb16e64e9cdfca54d633cdf287b0544ecdda3b6)) - Incorrect "Artist" label/heading on Search Results Page - [#920](https://github.com/KRTirtho/spotube/issues/920) - ([f86d544](https://github.com/KRTirtho/spotube/commit/f86d5449168068e338f769d7f504d2146b86dc79)) + [#920](https://github.com/team-spotube/spotube/issues/920) + ([f86d544](https://github.com/team-spotube/spotube/commit/f86d5449168068e338f769d7f504d2146b86dc79)) - metadata not getting added for YouTube tracks - [#916](https://github.com/KRTirtho/spotube/issues/916) and Wrong duration of - downloaded tracks [#912](https://github.com/KRTirtho/spotube/issues/912) - ([a7b9398](https://github.com/KRTirtho/spotube/commit/a7b9398708ede865dc2c25fb791c8e98eeff7a38)) + [#916](https://github.com/team-spotube/spotube/issues/916) and Wrong duration of + downloaded tracks [#912](https://github.com/team-spotube/spotube/issues/912) + ([a7b9398](https://github.com/team-spotube/spotube/commit/a7b9398708ede865dc2c25fb791c8e98eeff7a38)) - Playlist refresh not working - [#915](https://github.com/KRTirtho/spotube/issues/915) - ([5f1df5a](https://github.com/KRTirtho/spotube/commit/5f1df5a87d8fb7980b52cf57b7b6bedea57a1269)) + [#915](https://github.com/team-spotube/spotube/issues/915) + ([5f1df5a](https://github.com/team-spotube/spotube/commit/5f1df5a87d8fb7980b52cf57b7b6bedea57a1269)) - track view header title overflow and player view drag glitch - ([b04d884](https://github.com/KRTirtho/spotube/commit/b04d8849e7169824ec5b980236b5d61b2629f56e)) + ([b04d884](https://github.com/team-spotube/spotube/commit/b04d8849e7169824ec5b980236b5d61b2629f56e)) - wrong artist name sent while scrobbling - [#958](https://github.com/KRTirtho/spotube/issues/958) - ([dcbe729](https://github.com/KRTirtho/spotube/commit/dcbe7294b742d43fbff4e89ab4c4825e94421dd9)) + [#958](https://github.com/team-spotube/spotube/issues/958) + ([dcbe729](https://github.com/team-spotube/spotube/commit/dcbe7294b742d43fbff4e89ab4c4825e94421dd9)) -## [3.3.0](https://github.com/KRTirtho/spotube/compare/v3.2.0...v3.3.0) (2023-11-27) +## [3.3.0](https://github.com/team-spotube/spotube/compare/v3.2.0...v3.3.0) (2023-11-27) ### Features - Add JioSaavn as audio source - ([#881](https://github.com/KRTirtho/spotube/issues/881)) - ([14069cd](https://github.com/KRTirtho/spotube/commit/14069cd4fe08597c8d9aa0810270fb4c386c1d55)) + ([#881](https://github.com/team-spotube/spotube/issues/881)) + ([14069cd](https://github.com/team-spotube/spotube/commit/14069cd4fe08597c8d9aa0810270fb4c386c1d55)) - **android:** better quick scroll/drag to scroll implementation - ([2e2c44f](https://github.com/KRTirtho/spotube/commit/2e2c44f0afef69bf9bc485db97d45127a0847c8e)) + ([2e2c44f](https://github.com/team-spotube/spotube/commit/2e2c44f0afef69bf9bc485db97d45127a0847c8e)) - **artist:** modularize page and add wikipedia section - ([2a69886](https://github.com/KRTirtho/spotube/commit/2a698865567883271471ace9a44123bbfd8fcd2f)) -- discord RPC integration [#98](https://github.com/KRTirtho/spotube/issues/98) - ([88b8785](https://github.com/KRTirtho/spotube/commit/88b8785cb86a19900f3a867b044c1ccb2fe400bb)) + ([2a69886](https://github.com/team-spotube/spotube/commit/2a698865567883271471ace9a44123bbfd8fcd2f)) +- discord RPC integration [#98](https://github.com/team-spotube/spotube/issues/98) + ([88b8785](https://github.com/team-spotube/spotube/commit/88b8785cb86a19900f3a867b044c1ccb2fe400bb)) - **mini_player:** show/hide lyrics - [#851](https://github.com/KRTirtho/spotube/issues/851) - ([dcbb156](https://github.com/KRTirtho/spotube/commit/dcbb1568337969841acc0abe0e7185ee5e4c3590)) + [#851](https://github.com/team-spotube/spotube/issues/851) + ([dcbb156](https://github.com/team-spotube/spotube/commit/dcbb1568337969841acc0abe0e7185ee5e4c3590)) - paginated playlist and album page - ([28a5d6b](https://github.com/KRTirtho/spotube/commit/28a5d6bb3820ab0bd4007664f73d685f6e1d2c90)) + ([28a5d6b](https://github.com/team-spotube/spotube/commit/28a5d6bb3820ab0bd4007664f73d685f6e1d2c90)) - **translations:** add Turkish translations - ([0c22469](https://github.com/KRTirtho/spotube/commit/0c22469503f32dbbf1a5d31419c1b76c699fa966)) + ([0c22469](https://github.com/team-spotube/spotube/commit/0c22469503f32dbbf1a5d31419c1b76c699fa966)) ### Bug Fixes - "Add () to Playlist" option not showing in favorited playlists - [#904](https://github.com/KRTirtho/spotube/issues/904) - ([96021e1](https://github.com/KRTirtho/spotube/commit/96021e1a49d22bd25fd052c122f49f439c2bea43)) + [#904](https://github.com/team-spotube/spotube/issues/904) + ([96021e1](https://github.com/team-spotube/spotube/commit/96021e1a49d22bd25fd052c122f49f439c2bea43)) - 0:00 media duration in queue after application restart - [#782](https://github.com/KRTirtho/spotube/issues/782) - ([83c0b49](https://github.com/KRTirtho/spotube/commit/83c0b49da962d9f3d40de9525f90f0b320e8f7b8)) + [#782](https://github.com/team-spotube/spotube/issues/782) + ([83c0b49](https://github.com/team-spotube/spotube/commit/83c0b49da962d9f3d40de9525f90f0b320e8f7b8)) - Add to Playlist Dialog memory leak - [#817](https://github.com/KRTirtho/spotube/issues/817) - ([fed36ec](https://github.com/KRTirtho/spotube/commit/fed36ecdd81e8a0f8358693eff0a6233dea32e5d)) + [#817](https://github.com/team-spotube/spotube/issues/817) + ([fed36ec](https://github.com/team-spotube/spotube/commit/fed36ecdd81e8a0f8358693eff0a6233dea32e5d)) - **album_card:** show loading state during adding track to queue/play - ([5633367](https://github.com/KRTirtho/spotube/commit/5633367397812148f6d712d06e97a4f84033f968)) + ([5633367](https://github.com/team-spotube/spotube/commit/5633367397812148f6d712d06e97a4f84033f968)) - alternative track source safearea overflow - [#876](https://github.com/KRTirtho/spotube/issues/876) - ([7b72a90](https://github.com/KRTirtho/spotube/commit/7b72a90bc65b541cbe2e24ef2234524b522ad71d)) + [#876](https://github.com/team-spotube/spotube/issues/876) + ([7b72a90](https://github.com/team-spotube/spotube/commit/7b72a90bc65b541cbe2e24ef2234524b522ad71d)) - android invalid download location Download not starting or not explaining - error [#720](https://github.com/KRTirtho/spotube/issues/720) - ([d056dbf](https://github.com/KRTirtho/spotube/commit/d056dbf9eeef7033dbc012d0c05800063e820042)) + error [#720](https://github.com/team-spotube/spotube/issues/720) + ([d056dbf](https://github.com/team-spotube/spotube/commit/d056dbf9eeef7033dbc012d0c05800063e820042)) - changed settings are not persisting after force stop - [#821](https://github.com/KRTirtho/spotube/issues/821) - ([e29a38d](https://github.com/KRTirtho/spotube/commit/e29a38dfa43ddf7a38046d1d40424f01dbe62261)) + [#821](https://github.com/team-spotube/spotube/issues/821) + ([e29a38d](https://github.com/team-spotube/spotube/commit/e29a38dfa43ddf7a38046d1d40424f01dbe62261)) - check for unsynced lyrics and error handling for timed lyrics query - ([1d77556](https://github.com/KRTirtho/spotube/commit/1d77556157d158600f29cf2ea5f26c567607dec7)) + ([1d77556](https://github.com/team-spotube/spotube/commit/1d77556157d158600f29cf2ea5f26c567607dec7)) - **genres:** lag while scrolling - ([dc980b0](https://github.com/KRTirtho/spotube/commit/dc980b024edad3132e72cbb2f0087297a4b76469)) + ([dc980b0](https://github.com/team-spotube/spotube/commit/dc980b024edad3132e72cbb2f0087297a4b76469)) - infinite list disappearing for a moment everytime new page is fetched - ([1334a62](https://github.com/KRTirtho/spotube/commit/1334a62aaea31f97031b3ebf455e94c583f37314)) + ([1334a62](https://github.com/team-spotube/spotube/commit/1334a62aaea31f97031b3ebf455e94c583f37314)) - last track of queue keeps repeating - [#718](https://github.com/KRTirtho/spotube/issues/718) - ([58e5698](https://github.com/KRTirtho/spotube/commit/58e569864dddd74c3064624998dfc184046e97eb)) + [#718](https://github.com/team-spotube/spotube/issues/718) + ([58e5698](https://github.com/team-spotube/spotube/commit/58e569864dddd74c3064624998dfc184046e97eb)) - Navigating to settings, redirects to home page - [#812](https://github.com/KRTirtho/spotube/issues/812) - ([da04f06](https://github.com/KRTirtho/spotube/commit/da04f068f9b7effff8d50cb5714d93ea80c22b7f)) + [#812](https://github.com/team-spotube/spotube/issues/812) + ([da04f06](https://github.com/team-spotube/spotube/commit/da04f068f9b7effff8d50cb5714d93ea80c22b7f)) - new releases section flickering on scroll glitch - ([ee94b7c](https://github.com/KRTirtho/spotube/commit/ee94b7cbb24e0f0bc22a6d49c830d4055aa02895)) + ([ee94b7c](https://github.com/team-spotube/spotube/commit/ee94b7cbb24e0f0bc22a6d49c830d4055aa02895)) - **playbutton_card:** annoying animation - ([574406d](https://github.com/KRTirtho/spotube/commit/574406dd5fc410914b27e7fce374323696845012)) + ([574406d](https://github.com/team-spotube/spotube/commit/574406dd5fc410914b27e7fce374323696845012)) - scrobbling not working for first track or single track - ([0a6b54d](https://github.com/KRTirtho/spotube/commit/0a6b54da367345b73fe6e954f1d9368d9f9ead71)) + ([0a6b54d](https://github.com/team-spotube/spotube/commit/0a6b54da367345b73fe6e954f1d9368d9f9ead71)) - settings page scrollbar position - ([ee82290](https://github.com/KRTirtho/spotube/commit/ee8229020b3b03fc074b316db4b322af13b807bd)) + ([ee82290](https://github.com/team-spotube/spotube/commit/ee8229020b3b03fc074b316db4b322af13b807bd)) - shuffle doesn't move active track to top - ([4956bf3](https://github.com/KRTirtho/spotube/commit/4956bf367baae39c88b5de7c6c136513a14f8ad2)) + ([4956bf3](https://github.com/team-spotube/spotube/commit/4956bf367baae39c88b5de7c6c136513a14f8ad2)) - spotube doesn't exit properly, hangs in infinite loop - [#768](https://github.com/KRTirtho/spotube/issues/768) - ([353ca79](https://github.com/KRTirtho/spotube/commit/353ca79be334077c3ac27b4f64e8b4b15eca7175)) + [#768](https://github.com/team-spotube/spotube/issues/768) + ([353ca79](https://github.com/team-spotube/spotube/commit/353ca79be334077c3ac27b4f64e8b4b15eca7175)) - trim login field padding - ([286ef83](https://github.com/KRTirtho/spotube/commit/286ef83e8ec516db70019398d9e3e724437a4172)) + ([286ef83](https://github.com/team-spotube/spotube/commit/286ef83e8ec516db70019398d9e3e724437a4172)) - use CustomScrollView for personalized page - ([7d05c40](https://github.com/KRTirtho/spotube/commit/7d05c40dc0d04208b059f2483c1e4de199c8b51d)) + ([7d05c40](https://github.com/team-spotube/spotube/commit/7d05c40dc0d04208b059f2483c1e4de199c8b51d)) - user_playlists layout, track tile index, - ([487c2ed](https://github.com/KRTirtho/spotube/commit/487c2ed6bdc4af33006ba52532eb4eaaa261dceb)) + ([487c2ed](https://github.com/team-spotube/spotube/commit/487c2ed6bdc4af33006ba52532eb4eaaa261dceb)) - **windows:** media control not working - [#641](https://github.com/KRTirtho/spotube/issues/641) - ([7818574](https://github.com/KRTirtho/spotube/commit/7818574356d0fb8ff567e1f6a83fd0b6f2ee7c8a)) + [#641](https://github.com/team-spotube/spotube/issues/641) + ([7818574](https://github.com/team-spotube/spotube/commit/7818574356d0fb8ff567e1f6a83fd0b6f2ee7c8a)) -## [3.2.0](https://github.com/KRTirtho/spotube/compare/v3.1.2...v3.2.0) (2023-10-16) +## [3.2.0](https://github.com/team-spotube/spotube/compare/v3.1.2...v3.2.0) (2023-10-16) ### Features - ability to select/copy lyrics - [#802](https://github.com/KRTirtho/spotube/issues/802) - ([0eb9ee8](https://github.com/KRTirtho/spotube/commit/0eb9ee8648bee43a8009e6752674b1be646c0916)) -- add Amoled theme [#724](https://github.com/KRTirtho/spotube/issues/724) - ([5c5dbf6](https://github.com/KRTirtho/spotube/commit/5c5dbf69ecea95c92d3c3900ad690a500d75b4e2)) -- add audio normalization [#164](https://github.com/KRTirtho/spotube/issues/164) - ([da10ab2](https://github.com/KRTirtho/spotube/commit/da10ab2e291d4ba4d3082b9a6ae535639fb8f1b7)) + [#802](https://github.com/team-spotube/spotube/issues/802) + ([0eb9ee8](https://github.com/team-spotube/spotube/commit/0eb9ee8648bee43a8009e6752674b1be646c0916)) +- add Amoled theme [#724](https://github.com/team-spotube/spotube/issues/724) + ([5c5dbf6](https://github.com/team-spotube/spotube/commit/5c5dbf69ecea95c92d3c3900ad690a500d75b4e2)) +- add audio normalization [#164](https://github.com/team-spotube/spotube/issues/164) + ([da10ab2](https://github.com/team-spotube/spotube/commit/da10ab2e291d4ba4d3082b9a6ae535639fb8f1b7)) - add restore default settings button - ([94c3866](https://github.com/KRTirtho/spotube/commit/94c386638f2e5a42d21c8f157835443333ee6d5c)) + ([94c3866](https://github.com/team-spotube/spotube/commit/94c386638f2e5a42d21c8f157835443333ee6d5c)) - configurable audio normalization switch - ([c325911](https://github.com/KRTirtho/spotube/commit/c325911c0d87758a203a52df02179c1513bad3fd)) + ([c325911](https://github.com/team-spotube/spotube/commit/c325911c0d87758a203a52df02179c1513bad3fd)) - customizable stream/download file formats - ([#757](https://github.com/KRTirtho/spotube/issues/757)) - ([e54762b](https://github.com/KRTirtho/spotube/commit/e54762be6add6524ab614d103fc3557a101c75f4)) + ([#757](https://github.com/team-spotube/spotube/issues/757)) + ([e54762b](https://github.com/team-spotube/spotube/commit/e54762be6add6524ab614d103fc3557a101c75f4)) - improve and unify the logging framework - ([#738](https://github.com/KRTirtho/spotube/issues/738)) - ([c7432bb](https://github.com/KRTirtho/spotube/commit/c7432bbd986d576a93957f0a22bdbca5c1e87f20)) + ([#738](https://github.com/team-spotube/spotube/issues/738)) + ([c7432bb](https://github.com/team-spotube/spotube/commit/c7432bbd986d576a93957f0a22bdbca5c1e87f20)) - LastFM scrobbling support - ([#761](https://github.com/KRTirtho/spotube/issues/761)) - ([f5bd907](https://github.com/KRTirtho/spotube/commit/f5bd90731d9abc19d684c8bcb231eff399e73023)) + ([#761](https://github.com/team-spotube/spotube/issues/761)) + ([f5bd907](https://github.com/team-spotube/spotube/commit/f5bd90731d9abc19d684c8bcb231eff399e73023)) - loading indicator for genre and personalized pages - ([ffe8d9c](https://github.com/KRTirtho/spotube/commit/ffe8d9ca6da25cb3e6fd2c781d5ed3a7b919510e)) + ([ffe8d9c](https://github.com/team-spotube/spotube/commit/ffe8d9ca6da25cb3e6fd2c781d5ed3a7b919510e)) - manual offline detection - ([854ab89](https://github.com/KRTirtho/spotube/commit/854ab8910dffb2837c011d3439173a1f0ebe9c6c)) + ([854ab89](https://github.com/team-spotube/spotube/commit/854ab8910dffb2837c011d3439173a1f0ebe9c6c)) - show error dialog on failed to login - ([101c325](https://github.com/KRTirtho/spotube/commit/101c32523d3be8c05527261f6f63f939d388ad79)) + ([101c325](https://github.com/team-spotube/spotube/commit/101c32523d3be8c05527261f6f63f939d388ad79)) - sliding up player support - ([083319f](https://github.com/KRTirtho/spotube/commit/083319fd2445ab179e3dcda0a6aeaca6f13dda29)) + ([083319f](https://github.com/team-spotube/spotube/commit/083319fd2445ab179e3dcda0a6aeaca6f13dda29)) - swipe to open player view - ([#765](https://github.com/KRTirtho/spotube/issues/765)) - ([9aee056](https://github.com/KRTirtho/spotube/commit/9aee0568bf42eed9fea8d517e960a010abf0ebf2)) + ([#765](https://github.com/team-spotube/spotube/issues/765)) + ([9aee056](https://github.com/team-spotube/spotube/commit/9aee0568bf42eed9fea8d517e960a010abf0ebf2)) - thicken the scrollbars & make 'em interactive for mobile - ([#764](https://github.com/KRTirtho/spotube/issues/764)) - ([84a4bcd](https://github.com/KRTirtho/spotube/commit/84a4bcd948ab459489aaf6f39d6954776c3401d7)) + ([#764](https://github.com/team-spotube/spotube/issues/764)) + ([84a4bcd](https://github.com/team-spotube/spotube/commit/84a4bcd948ab459489aaf6f39d6954776c3401d7)) - **translations:** add Arabic Translations - ([#740](https://github.com/KRTirtho/spotube/issues/740)) - ([38493f9](https://github.com/KRTirtho/spotube/commit/38493f9dd75303890857a626c0b276ee1ab75bb2)) + ([#740](https://github.com/team-spotube/spotube/issues/740)) + ([38493f9](https://github.com/team-spotube/spotube/commit/38493f9dd75303890857a626c0b276ee1ab75bb2)) - **translations:** add Farsi Translations - ([#760](https://github.com/KRTirtho/spotube/issues/760)) - ([fe42cfe](https://github.com/KRTirtho/spotube/commit/fe42cfe8430035d9b67dd158fb7b835ee4071497)) + ([#760](https://github.com/team-spotube/spotube/issues/760)) + ([fe42cfe](https://github.com/team-spotube/spotube/commit/fe42cfe8430035d9b67dd158fb7b835ee4071497)) ### Bug Fixes - add libmpv1 for ubuntu-based systems - ([#739](https://github.com/KRTirtho/spotube/issues/739)) - ([5115e04](https://github.com/KRTirtho/spotube/commit/5115e041e78c20fce798a80f1d844bfc60746958)) + ([#739](https://github.com/team-spotube/spotube/issues/739)) + ([5115e04](https://github.com/team-spotube/spotube/commit/5115e041e78c20fce798a80f1d844bfc60746958)) - add xdg-user-dirs as deps - ([f3e331e](https://github.com/KRTirtho/spotube/commit/f3e331ecf733995da24c9b907efc5ed4bd02ffdd)) + ([f3e331e](https://github.com/team-spotube/spotube/commit/f3e331ecf733995da24c9b907efc5ed4bd02ffdd)) - **android :** file_selector getDirectoryPath returns unusable content urls - [#720](https://github.com/KRTirtho/spotube/issues/720) - ([b3cf639](https://github.com/KRTirtho/spotube/commit/b3cf639ee2f970f4df9b394b260c3ad8a5732a9c)) + [#720](https://github.com/team-spotube/spotube/issues/720) + ([b3cf639](https://github.com/team-spotube/spotube/commit/b3cf639ee2f970f4df9b394b260c3ad8a5732a9c)) - **android:** audio doesn't resume on interruption end - ([15d466a](https://github.com/KRTirtho/spotube/commit/15d466a04538ec70c3a0c132f2baaaf8690f8d4e)) + ([15d466a](https://github.com/team-spotube/spotube/commit/15d466a04538ec70c3a0c132f2baaaf8690f8d4e)) - **android:** system navigator back doesn't close player - ([20d7092](https://github.com/KRTirtho/spotube/commit/20d70927c909347e84ffa8e456f8fab88d49d179)) + ([20d7092](https://github.com/team-spotube/spotube/commit/20d70927c909347e84ffa8e456f8fab88d49d179)) - get rid of overflow errors & status bar dark color - ([5bb8231](https://github.com/KRTirtho/spotube/commit/5bb8231782287faf75c778fadb3a03ac774d14f0)) + ([5bb8231](https://github.com/team-spotube/spotube/commit/5bb8231782287faf75c778fadb3a03ac774d14f0)) - keyboard shortcuts changing route but not update sidebar - ([2d93441](https://github.com/KRTirtho/spotube/commit/2d934411887bd104d8265236df5bf595c5ad2278)) + ([2d93441](https://github.com/team-spotube/spotube/commit/2d934411887bd104d8265236df5bf595c5ad2278)) - last track repeats - ([ed6ca00](https://github.com/KRTirtho/spotube/commit/ed6ca006ce237ed8d509cde9ed47cd6ea3396b63)) + ([ed6ca00](https://github.com/team-spotube/spotube/commit/ed6ca006ce237ed8d509cde9ed47cd6ea3396b63)) - minor glitches - ([e5d0aaf](https://github.com/KRTirtho/spotube/commit/e5d0aaf80d22b2291b6f7e7c5e18dd99ae1a7a82)) + ([e5d0aaf](https://github.com/team-spotube/spotube/commit/e5d0aaf80d22b2291b6f7e7c5e18dd99ae1a7a82)) - not fetching all followed artists - ([#759](https://github.com/KRTirtho/spotube/issues/759)) - ([c09a572](https://github.com/KRTirtho/spotube/commit/c09a5729251d8df820442d55477455f78c19c52e)) + ([#759](https://github.com/team-spotube/spotube/issues/759)) + ([c09a572](https://github.com/team-spotube/spotube/commit/c09a5729251d8df820442d55477455f78c19c52e)) - use audio_service_mpris plugin - ([e29cc25](https://github.com/KRTirtho/spotube/commit/e29cc2578cab36729e235b117c1b5489c3452902)) + ([e29cc25](https://github.com/team-spotube/spotube/commit/e29cc2578cab36729e235b117c1b5489c3452902)) - valid non-ASCII characters get removed from downloaded file name - [#745](https://github.com/KRTirtho/spotube/issues/745) - ([a7e102f](https://github.com/KRTirtho/spotube/commit/a7e102ffc726d00df369560ec9a7f742f9d387bb)) + [#745](https://github.com/team-spotube/spotube/issues/745) + ([a7e102f](https://github.com/team-spotube/spotube/commit/a7e102ffc726d00df369560ec9a7f742f9d387bb)) -## [3.1.2](https://github.com/KRTirtho/spotube/compare/v3.1.1...v3.1.2) (2023-09-15) +## [3.1.2](https://github.com/team-spotube/spotube/compare/v3.1.1...v3.1.2) (2023-09-15) ### Features - **player_queue:** filtering track support - ([d4f99ec](https://github.com/KRTirtho/spotube/commit/d4f99ec89927ea78f070707509ff3222ec402942)) + ([d4f99ec](https://github.com/team-spotube/spotube/commit/d4f99ec89927ea78f070707509ff3222ec402942)) - right click to open track option - ([1540999](https://github.com/KRTirtho/spotube/commit/1540999f50d7ba78d9706d73127483b98d800d86)) + ([1540999](https://github.com/team-spotube/spotube/commit/1540999f50d7ba78d9706d73127483b98d800d86)) - search loading animation - ([b9d5c70](https://github.com/KRTirtho/spotube/commit/b9d5c70301dd33ec26332e5e9a456ce5bfe73da0)) + ([b9d5c70](https://github.com/team-spotube/spotube/commit/b9d5c70301dd33ec26332e5e9a456ce5bfe73da0)) - show loading indicator on play track - ([d12ea48](https://github.com/KRTirtho/spotube/commit/d12ea48b97596205d6309012d561ce83e5cbc9c1)) + ([d12ea48](https://github.com/team-spotube/spotube/commit/d12ea48b97596205d6309012d561ce83e5cbc9c1)) ### Bug Fixes - add missing dependency in debian package - ([#704](https://github.com/KRTirtho/spotube/issues/704)) - ([c987ea7](https://github.com/KRTirtho/spotube/commit/c987ea78414f094dead2c2b35ddf8ae83a70f2fe)) + ([#704](https://github.com/team-spotube/spotube/issues/704)) + ([c987ea7](https://github.com/team-spotube/spotube/commit/c987ea78414f094dead2c2b35ddf8ae83a70f2fe)) - hour not showing for tracks longer than 60 minutes - ([#648](https://github.com/KRTirtho/spotube/issues/648)) - ([de335f4](https://github.com/KRTirtho/spotube/commit/de335f48342e45a077d6c3202706ef48dfb0a326)) + ([#648](https://github.com/team-spotube/spotube/issues/648)) + ([de335f4](https://github.com/team-spotube/spotube/commit/de335f48342e45a077d6c3202706ef48dfb0a326)) - liked tracks card play not working - ([d3e1cef](https://github.com/KRTirtho/spotube/commit/d3e1cef8a21ef7d64e74ca4e99b4b57b653b60a7)) + ([d3e1cef](https://github.com/team-spotube/spotube/commit/d3e1cef8a21ef7d64e74ca4e99b4b57b653b60a7)) - limit cover image upload to allowed 256kb size - ([1c50612](https://github.com/KRTirtho/spotube/commit/1c50612559a78dce9c108f7e7b816d1b84540fe4)) -- playlist grey page ([#707](https://github.com/KRTirtho/spotube/issues/707)) - ([0df8d9c](https://github.com/KRTirtho/spotube/commit/0df8d9cacee718fbb4cf3ec7b950b489630f3145)) + ([1c50612](https://github.com/team-spotube/spotube/commit/1c50612559a78dce9c108f7e7b816d1b84540fe4)) +- playlist grey page ([#707](https://github.com/team-spotube/spotube/issues/707)) + ([0df8d9c](https://github.com/team-spotube/spotube/commit/0df8d9cacee718fbb4cf3ec7b950b489630f3145)) - rewind breaks track progress bar - ([#695](https://github.com/KRTirtho/spotube/issues/695)) - ([e321743](https://github.com/KRTirtho/spotube/commit/e3217436c9985b86c68dab93ea65ee414b32fb49)) + ([#695](https://github.com/team-spotube/spotube/issues/695)) + ([e321743](https://github.com/team-spotube/spotube/commit/e3217436c9985b86c68dab93ea65ee414b32fb49)) - Windows memory leak due refetchOnStale user-liked-tracks - ([#705](https://github.com/KRTirtho/spotube/issues/705)) - ([142dc49](https://github.com/KRTirtho/spotube/commit/142dc498f8f9d26e6b370c9c52f790a20832fc38)) + ([#705](https://github.com/team-spotube/spotube/issues/705)) + ([142dc49](https://github.com/team-spotube/spotube/commit/142dc498f8f9d26e6b370c9c52f790a20832fc38)) -## [3.1.1](https://github.com/KRTirtho/spotube/compare/v3.1.0...v3.1.1) (2023-08-28) +## [3.1.1](https://github.com/team-spotube/spotube/compare/v3.1.0...v3.1.1) (2023-08-28) ### Features - ability to toggle system title bar & custom title bar - ([#185](https://github.com/KRTirtho/spotube/issues/185)) - ([8d46029](https://github.com/KRTirtho/spotube/commit/8d4602962be20ea4bafc20db10eae1160f83ac52)) + ([#185](https://github.com/team-spotube/spotube/issues/185)) + ([8d46029](https://github.com/team-spotube/spotube/commit/8d4602962be20ea4bafc20db10eae1160f83ac52)) - jump to specific time on lyric click - ([#590](https://github.com/KRTirtho/spotube/issues/590)) - ([a14fb9e](https://github.com/KRTirtho/spotube/commit/a14fb9ec389822e5ffa0c537e162b87cbba34e6c)) + ([#590](https://github.com/team-spotube/spotube/issues/590)) + ([a14fb9e](https://github.com/team-spotube/spotube/commit/a14fb9ec389822e5ffa0c537e162b87cbba34e6c)) - paginated user albums - ([d239d64](https://github.com/KRTirtho/spotube/commit/d239d641ff8f1b3edd64243994fd4a58cf71a5d3)) + ([d239d64](https://github.com/team-spotube/spotube/commit/d239d641ff8f1b3edd64243994fd4a58cf71a5d3)) - **translations:** add Russian translation - ([#661](https://github.com/KRTirtho/spotube/issues/661)) - ([e9a0911](https://github.com/KRTirtho/spotube/commit/e9a0911bfcea2374ee282aee738c12ad9ed93b02)), - closes [#625](https://github.com/KRTirtho/spotube/issues/625) + ([#661](https://github.com/team-spotube/spotube/issues/661)) + ([e9a0911](https://github.com/team-spotube/spotube/commit/e9a0911bfcea2374ee282aee738c12ad9ed93b02)), + closes [#625](https://github.com/team-spotube/spotube/issues/625) - **translations:** added Portuguese (Brazil) translation - ([#634](https://github.com/KRTirtho/spotube/issues/634)) - ([76f30a0](https://github.com/KRTirtho/spotube/commit/76f30a0f20f2b09680d27525cde3d1c9617fad5a)) + ([#634](https://github.com/team-spotube/spotube/issues/634)) + ([76f30a0](https://github.com/team-spotube/spotube/commit/76f30a0f20f2b09680d27525cde3d1c9617fad5a)) ### Bug Fixes - always fetching SponsorBlock if no segments found & download failing - ([6ced0a0](https://github.com/KRTirtho/spotube/commit/6ced0a0fad06f9f431636ca0fe5dae83eafe33ce)) + ([6ced0a0](https://github.com/team-spotube/spotube/commit/6ced0a0fad06f9f431636ca0fe5dae83eafe33ce)) - debian bookworm invalid dependencies - ([633415d](https://github.com/KRTirtho/spotube/commit/633415dd3e702a38c5a7e7d7b3b1c2713d9c9cc9)) + ([633415d](https://github.com/team-spotube/spotube/commit/633415dd3e702a38c5a7e7d7b3b1c2713d9c9cc9)) - disable android auto for playstore version :"( - ([0f0d240](https://github.com/KRTirtho/spotube/commit/0f0d240c04d77db6f7c127d59ba8b331d5534469)) + ([0f0d240](https://github.com/team-spotube/spotube/commit/0f0d240c04d77db6f7c127d59ba8b331d5534469)) - infinite route push glitch - ([e90eceb](https://github.com/KRTirtho/spotube/commit/e90eceb285a84028df690c25a687ff9b5168bba8)) + ([e90eceb](https://github.com/team-spotube/spotube/commit/e90eceb285a84028df690c25a687ff9b5168bba8)) - jump to track going to wrong track - ([190df17](https://github.com/KRTirtho/spotube/commit/190df17adcf4c01cb2bcebfdec47908828b33816)) + ([190df17](https://github.com/team-spotube/spotube/commit/190df17adcf4c01cb2bcebfdec47908828b33816)) - last track of queue never plays & repeat playlist never works - ([c3c09f5](https://github.com/KRTirtho/spotube/commit/c3c09f5b76c9547a306d15cd3768dacc1622876d)) + ([c3c09f5](https://github.com/team-spotube/spotube/commit/c3c09f5b76c9547a306d15cd3768dacc1622876d)) - lyrics page text contrast - ([179d536](https://github.com/KRTirtho/spotube/commit/179d536ccc10a5e63f11a63680a6e61c2d1314c8)) + ([179d536](https://github.com/team-spotube/spotube/commit/179d536ccc10a5e63f11a63680a6e61c2d1314c8)) - replace connectivity_plus with internet_connection_checker - ([f23e871](https://github.com/KRTirtho/spotube/commit/f23e8719eec7f5bed677ea866cb4bfab7aee5373)) + ([f23e871](https://github.com/team-spotube/spotube/commit/f23e8719eec7f5bed677ea866cb4bfab7aee5373)) - sanitize song title for file name - ([#644](https://github.com/KRTirtho/spotube/issues/644)) - ([1a7ea0c](https://github.com/KRTirtho/spotube/commit/1a7ea0ce6aae1a7cbe195f6b2fae7d99082bb828)) + ([#644](https://github.com/team-spotube/spotube/issues/644)) + ([1a7ea0c](https://github.com/team-spotube/spotube/commit/1a7ea0ce6aae1a7cbe195f6b2fae7d99082bb828)) - sorting by date crashes app - ([#551](https://github.com/KRTirtho/spotube/issues/551)) - ([48e90a4](https://github.com/KRTirtho/spotube/commit/48e90a42294a6287cad65f840a7cc305988d34ff)) + ([#551](https://github.com/team-spotube/spotube/issues/551)) + ([48e90a4](https://github.com/team-spotube/spotube/commit/48e90a42294a6287cad65f840a7cc305988d34ff)) - window size remains same after exiting mini player - ([#618](https://github.com/KRTirtho/spotube/issues/618)) - ([fb36003](https://github.com/KRTirtho/spotube/commit/fb360035ade09c270b46a0c3b99ab1594ece07c0)) + ([#618](https://github.com/team-spotube/spotube/issues/618)) + ([fb36003](https://github.com/team-spotube/spotube/commit/fb360035ade09c270b46a0c3b99ab1594ece07c0)) -## [3.1.0](https://github.com/KRTirtho/spotube/compare/v3.0.1...v3.1.0) (2023-08-18) +## [3.1.0](https://github.com/team-spotube/spotube/compare/v3.0.1...v3.1.0) (2023-08-18) ### Features - add android auto media session control support - ([0f5748a](https://github.com/KRTirtho/spotube/commit/0f5748a24b4b5a32862f7b2f28151e2d42bcce33)) + ([0f5748a](https://github.com/team-spotube/spotube/commit/0f5748a24b4b5a32862f7b2f28151e2d42bcce33)) - better track matching on youtube API - ([904a0d3](https://github.com/KRTirtho/spotube/commit/904a0d3e15a8e4a76d0842e978809d2838439f86)) + ([904a0d3](https://github.com/team-spotube/spotube/commit/904a0d3e15a8e4a76d0842e978809d2838439f86)) - blazingly™ fast download manager - ([#619](https://github.com/KRTirtho/spotube/issues/619)) - ([38dc4be](https://github.com/KRTirtho/spotube/commit/38dc4beb44827a20044afd120d7c32f097938660)) + ([#619](https://github.com/team-spotube/spotube/issues/619)) + ([38dc4be](https://github.com/team-spotube/spotube/commit/38dc4beb44827a20044afd120d7c32f097938660)) - paginated user playlists - ([e7c6813](https://github.com/KRTirtho/spotube/commit/e7c6813ccb2afcda9a8b044570a1c0a27785a59f)) + ([e7c6813](https://github.com/team-spotube/spotube/commit/e7c6813ccb2afcda9a8b044570a1c0a27785a59f)) - show error dialog on piped API 500 error - ([c69f81e](https://github.com/KRTirtho/spotube/commit/c69f81ec6f01f0f67ad06446e890aa1351516626)) + ([c69f81e](https://github.com/team-spotube/spotube/commit/c69f81ec6f01f0f67ad06446e890aa1351516626)) - **translation:** add catalan translations - ([#621](https://github.com/KRTirtho/spotube/issues/621)) - ([c94e5ba](https://github.com/KRTirtho/spotube/commit/c94e5ba4301ed0cb760daff56c56d2701b35131f)) + ([#621](https://github.com/team-spotube/spotube/issues/621)) + ([c94e5ba](https://github.com/team-spotube/spotube/commit/c94e5ba4301ed0cb760daff56c56d2701b35131f)) - **translations:** add polish translation - ([#631](https://github.com/KRTirtho/spotube/issues/631)) - ([f90e9be](https://github.com/KRTirtho/spotube/commit/f90e9bee3104a3812c2f775dd16cabbb56f668cb)) + ([#631](https://github.com/team-spotube/spotube/issues/631)) + ([f90e9be](https://github.com/team-spotube/spotube/commit/f90e9bee3104a3812c2f775dd16cabbb56f668cb)) - web compatibility - ([cf7b849](https://github.com/KRTirtho/spotube/commit/cf7b849cddca3260d9c3a6a064418b0ba2d63270)) + ([cf7b849](https://github.com/team-spotube/spotube/commit/cf7b849cddca3260d9c3a6a064418b0ba2d63270)) ### Bug Fixes - always showing play in playlist/album views - ([8521cc5](https://github.com/KRTirtho/spotube/commit/8521cc5c88730caa9db74da6c04b679bf29ed56d)) + ([8521cc5](https://github.com/team-spotube/spotube/commit/8521cc5c88730caa9db74da6c04b679bf29ed56d)) - **android:** android 13 local tracks not showing up - ([e3f4344](https://github.com/KRTirtho/spotube/commit/e3f4344ae9c1ec93860d4c5d1b8de1a803b29569)) + ([e3f4344](https://github.com/team-spotube/spotube/commit/e3f4344ae9c1ec93860d4c5d1b8de1a803b29569)) - default to youtube API by default - ([5a8a1e4](https://github.com/KRTirtho/spotube/commit/5a8a1e41e93fb74756b8c88f6325b8b46d7af131)) + ([5a8a1e4](https://github.com/team-spotube/spotube/commit/5a8a1e41e93fb74756b8c88f6325b8b46d7af131)) - generate playlist page max width - ([4adf695](https://github.com/KRTirtho/spotube/commit/4adf6951d9ee78ac4b198d541dada28dc00ca0cb)) + ([4adf695](https://github.com/team-spotube/spotube/commit/4adf6951d9ee78ac4b198d541dada28dc00ca0cb)) - tracks doesn't change when ended - ([aa4ac86](https://github.com/KRTirtho/spotube/commit/aa4ac8641a7dceb4626ab675ba376b24a3480d30)) + ([aa4ac86](https://github.com/team-spotube/spotube/commit/aa4ac8641a7dceb4626ab675ba376b24a3480d30)) - windows media controls not working - ([ae5edd1](https://github.com/KRTirtho/spotube/commit/ae5edd17ef24f2a38ec1cc9c9623868b6ee9e352)) + ([ae5edd1](https://github.com/team-spotube/spotube/commit/ae5edd17ef24f2a38ec1cc9c9623868b6ee9e352)) -## [3.0.1](https://github.com/KRTirtho/spotube/compare/v3.0.0...v3.1.0) (2023-08-04) +## [3.0.1](https://github.com/team-spotube/spotube/compare/v3.0.0...v3.1.0) (2023-08-04) ### Features - Force High Refresh Rate on some Android devices - ([#607](https://github.com/KRTirtho/spotube/issues/607)) - ([6dff099](https://github.com/KRTirtho/spotube/commit/6dff0996bdfee603acf242b1316f8793d625267c)) + ([#607](https://github.com/team-spotube/spotube/issues/607)) + ([6dff099](https://github.com/team-spotube/spotube/commit/6dff0996bdfee603acf242b1316f8793d625267c)) - **translations:** add spanish translations - ([#585](https://github.com/KRTirtho/spotube/issues/585)) - ([042d7a4](https://github.com/KRTirtho/spotube/commit/042d7a4a10c78dd93a56a2f32d18a0fb74dbe697)) + ([#585](https://github.com/team-spotube/spotube/issues/585)) + ([042d7a4](https://github.com/team-spotube/spotube/commit/042d7a4a10c78dd93a56a2f32d18a0fb74dbe697)) - **translations:** add Simplified Chinese translation. - ([#556](https://github.com/KRTirtho/spotube/issues/556)) - ([26dbd52](https://github.com/KRTirtho/spotube/commit/26dbd523737d868114a47e82acd412cdae622b7c)) + ([#556](https://github.com/team-spotube/spotube/issues/556)) + ([26dbd52](https://github.com/team-spotube/spotube/commit/26dbd523737d868114a47e82acd412cdae622b7c)) ### Bug Fixes - alternative track source textfield safe area - ([b8c6d7e](https://github.com/KRTirtho/spotube/commit/b8c6d7eb6ae1c54bdc83a455850dfca0f27bd881)) + ([b8c6d7e](https://github.com/team-spotube/spotube/commit/b8c6d7eb6ae1c54bdc83a455850dfca0f27bd881)) - avoid sponsor block for first few seconds to not break the stream - ([d8cf2ae](https://github.com/KRTirtho/spotube/commit/d8cf2ae1315dc3848fe1ac12286faafe90fdbed7)) + ([d8cf2ae](https://github.com/team-spotube/spotube/commit/d8cf2ae1315dc3848fe1ac12286faafe90fdbed7)) - cache segments casting error - ([dfd60bd](https://github.com/KRTirtho/spotube/commit/dfd60bd4cc0fe8fe90e0cbfd26331df505cde2aa)) + ([dfd60bd](https://github.com/team-spotube/spotube/commit/dfd60bd4cc0fe8fe90e0cbfd26331df505cde2aa)) - duration is always zero in PlayerView - ([4885dca](https://github.com/KRTirtho/spotube/commit/4885dca04f06658391d1063e6c5a009547391a6f)) + ([4885dca](https://github.com/team-spotube/spotube/commit/4885dca04f06658391d1063e6c5a009547391a6f)) - flags not showing up and html in descriptions - ([5a563ef](https://github.com/KRTirtho/spotube/commit/5a563ef4289423ceb5c44ba13f3cfda34b2d16dd)) + ([5a563ef](https://github.com/team-spotube/spotube/commit/5a563ef4289423ceb5c44ba13f3cfda34b2d16dd)) - **linux:** crash when no secret service provider found - ([#608](https://github.com/KRTirtho/spotube/issues/608)) - ([888a4b1](https://github.com/KRTirtho/spotube/commit/888a4b1162c25371d7f6e88fae3a2473cabf1434)) + ([#608](https://github.com/team-spotube/spotube/issues/608)) + ([888a4b1](https://github.com/team-spotube/spotube/commit/888a4b1162c25371d7f6e88fae3a2473cabf1434)) - login dialog stays after login, mention sp_gaid in tutorial - ([b492840](https://github.com/KRTirtho/spotube/commit/b4928405122ae5e5d4d4560f316f2a546a2fabe4)) + ([b492840](https://github.com/team-spotube/spotube/commit/b4928405122ae5e5d4d4560f316f2a546a2fabe4)) - **album_sync**: negative index exception in update palette - ([#561](https://github.com/KRTirtho/spotube/issues/561)) - ([0089d47](https://github.com/KRTirtho/spotube/commit/0089d471ae6d595e058061e3ac44caecdba12f61)) + ([#561](https://github.com/team-spotube/spotube/issues/561)) + ([0089d47](https://github.com/team-spotube/spotube/commit/0089d471ae6d595e058061e3ac44caecdba12f61)) - remove adaptive widgets - ([#520](https://github.com/KRTirtho/spotube/issues/520)) - ([e4cbdd3](https://github.com/KRTirtho/spotube/commit/e4cbdd37479a572198c1ca27fcbbba0232275513)) -- shuffle not working ([#562](https://github.com/KRTirtho/spotube/issues/562)) - ([dc76634](https://github.com/KRTirtho/spotube/commit/dc76634a6e4ccdca0f09d63a2db82cce53d950d7)) + ([#520](https://github.com/team-spotube/spotube/issues/520)) + ([e4cbdd3](https://github.com/team-spotube/spotube/commit/e4cbdd37479a572198c1ca27fcbbba0232275513)) +- shuffle not working ([#562](https://github.com/team-spotube/spotube/issues/562)) + ([dc76634](https://github.com/team-spotube/spotube/commit/dc76634a6e4ccdca0f09d63a2db82cce53d950d7)) - track not skipping to next even when source is available - ([0b7affd](https://github.com/KRTirtho/spotube/commit/0b7affdc058c028982266d5c93215697301846bd)) + ([0b7affd](https://github.com/team-spotube/spotube/commit/0b7affdc058c028982266d5c93215697301846bd)) -## [3.0.0](https://github.com/KRTirtho/spotube/compare/v2.7.1...v3.0.0) (2023-07-02) +## [3.0.0](https://github.com/team-spotube/spotube/compare/v2.7.1...v3.0.0) (2023-07-02) ### Features - adaptive controllers - ([c8b7de0](https://github.com/KRTirtho/spotube/commit/c8b7de087917ec3037c015d5b55693cb3dbdecca)) + ([c8b7de0](https://github.com/team-spotube/spotube/commit/c8b7de087917ec3037c015d5b55693cb3dbdecca)) - adaptive popup and bottom sheet list widget - ([ddc1c5f](https://github.com/KRTirtho/spotube/commit/ddc1c5f373a4d72cc231c35dd70c3d577b84f7f5)) + ([ddc1c5f](https://github.com/team-spotube/spotube/commit/ddc1c5f373a4d72cc231c35dd70c3d577b84f7f5)) - add generated to playlist(s) - ([c91d8c8](https://github.com/KRTirtho/spotube/commit/c91d8c8efa8b526c64881fa829992b8c250e7c89)) + ([c91d8c8](https://github.com/team-spotube/spotube/commit/c91d8c8efa8b526c64881fa829992b8c250e7c89)) - add german locale - ([ba3f428](https://github.com/KRTirtho/spotube/commit/ba3f4281f1a6bc7ddf38775e9d40dad863ed3692)) + ([ba3f428](https://github.com/team-spotube/spotube/commit/ba3f4281f1a6bc7ddf38775e9d40dad863ed3692)) - add piped search mode - ([17a25a5](https://github.com/KRTirtho/spotube/commit/17a25a501e0d5e2512d8de0921fd602ea906d30f)) + ([17a25a5](https://github.com/team-spotube/spotube/commit/17a25a501e0d5e2512d8de0921fd602ea906d30f)) - add sleep timer support - ([4a75f3d](https://github.com/KRTirtho/spotube/commit/4a75f3dbd1e7e6f68899de001df70e809533f142)) + ([4a75f3d](https://github.com/team-spotube/spotube/commit/4a75f3dbd1e7e6f68899de001df70e809533f142)) - adjust lyric page blurriness and player playbutton - ([54d5907](https://github.com/KRTirtho/spotube/commit/54d5907f14df04f3f983ba2b0401ba05785da03b)) + ([54d5907](https://github.com/team-spotube/spotube/commit/54d5907f14df04f3f983ba2b0401ba05785da03b)) - album art dominant color as accent color - ([#447](https://github.com/KRTirtho/spotube/issues/447)) - ([31b9249](https://github.com/KRTirtho/spotube/commit/31b9249cc8f7313a132a514a9ca825c2ae1e2256)) + ([#447](https://github.com/team-spotube/spotube/issues/447)) + ([31b9249](https://github.com/team-spotube/spotube/commit/31b9249cc8f7313a132a514a9ca825c2ae1e2256)) - **android:** add splash screen - ([c232fcc](https://github.com/KRTirtho/spotube/commit/c232fcc6dd1479ed33a8baa9887de2702a8ea22e)) + ([c232fcc](https://github.com/team-spotube/spotube/commit/c232fcc6dd1479ed33a8baa9887de2702a8ea22e)) - **android:** disable battery optimization for better playback - ([fe5b429](https://github.com/KRTirtho/spotube/commit/fe5b429ddacc576fc9fdb5e66718782cda163b27)) + ([fe5b429](https://github.com/team-spotube/spotube/commit/fe5b429ddacc576fc9fdb5e66718782cda163b27)) - artist card redesign - ([92a418c](https://github.com/KRTirtho/spotube/commit/92a418c8a8a9df99e27407b628e5e3cc9ccb4115)) + ([92a418c](https://github.com/team-spotube/spotube/commit/92a418c8a8a9df99e27407b628e5e3cc9ccb4115)) - Better download manager with download progress - ([6752adc](https://github.com/KRTirtho/spotube/commit/6752adc9398818f51b69fced226b4b8410fb9e9b)) + ([6752adc](https://github.com/team-spotube/spotube/commit/6752adc9398818f51b69fced226b4b8410fb9e9b)) - better language picker, adaptive select tile and settings section contrast - ([6430a25](https://github.com/KRTirtho/spotube/commit/6430a2587075aa24483ab26ce6f0f6b2b630e139)) + ([6430a25](https://github.com/team-spotube/spotube/commit/6430a2587075aa24483ab26ce6f0f6b2b630e139)) - cache encryption for sensitive data - ([b110d83](https://github.com/KRTirtho/spotube/commit/b110d834561ac53129ac9ec80238c014c84832ec)) + ([b110d83](https://github.com/team-spotube/spotube/commit/b110d834561ac53129ac9ec80238c014c84832ec)) - color scheme picker dialog vertical list view instead of wrap - ([bb60b01](https://github.com/KRTirtho/spotube/commit/bb60b01ef2f2ba3da8f6fe3a19add168b2ee8a4e)) + ([bb60b01](https://github.com/team-spotube/spotube/commit/bb60b01ef2f2ba3da8f6fe3a19add168b2ee8a4e)) - compact and adaptive playbutton card design - ([eeb8cab](https://github.com/KRTirtho/spotube/commit/eeb8cabf491d5242bd434b3c71c39363f24bdcf9)) + ([eeb8cab](https://github.com/team-spotube/spotube/commit/eeb8cabf491d5242bd434b3c71c39363f24bdcf9)) - compact button tabbar - ([67380f6](https://github.com/KRTirtho/spotube/commit/67380f68765f18e4dcd3d60117083c7e9c6761c2)) + ([67380f6](https://github.com/team-spotube/spotube/commit/67380f68765f18e4dcd3d60117083c7e9c6761c2)) - create a basic installer script - ([1763a36](https://github.com/KRTirtho/spotube/commit/1763a36a262178306df61d4588c17ff795a32790)) + ([1763a36](https://github.com/team-spotube/spotube/commit/1763a36a262178306df61d4588c17ff795a32790)) - curved navigation bar - ([776edf8](https://github.com/KRTirtho/spotube/commit/776edf84afcf99f96cf6e337b0c84ed89034ca8e)) + ([776edf8](https://github.com/team-spotube/spotube/commit/776edf84afcf99f96cf6e337b0c84ed89034ca8e)) - custom error toast - ([96f04c1](https://github.com/KRTirtho/spotube/commit/96f04c17565c0ab7f115d5c1f167f6660a69480d)) + ([96f04c1](https://github.com/team-spotube/spotube/commit/96f04c17565c0ab7f115d5c1f167f6660a69480d)) - custom playlist generator - ([f4b0d13](https://github.com/KRTirtho/spotube/commit/f4b0d134ca724b75bf65b885bce4dba206f1e090)) + ([f4b0d13](https://github.com/team-spotube/spotube/commit/f4b0d134ca724b75bf65b885bce4dba206f1e090)) - desktop mini player support - ([471812d](https://github.com/KRTirtho/spotube/commit/471812d789eb2c861268ab8451d50104ac2fbe2e)) + ([471812d](https://github.com/team-spotube/spotube/commit/471812d789eb2c861268ab8451d50104ac2fbe2e)) - **desktop:** close button for minimize notification - ([1688f99](https://github.com/KRTirtho/spotube/commit/1688f99096af940ead65e67832c6f061a6f635ac)) + ([1688f99](https://github.com/team-spotube/spotube/commit/1688f99096af940ead65e67832c6f061a6f635ac)) - **desktop:** show minimized to system tray notification - ([296f96c](https://github.com/KRTirtho/spotube/commit/296f96cf17cf21ff09b406cc24952ff60da52d5e)) + ([296f96c](https://github.com/team-spotube/spotube/commit/296f96cf17cf21ff09b406cc24952ff60da52d5e)) - disable/enable smtc on demand - ([7fa50e5](https://github.com/KRTirtho/spotube/commit/7fa50e5c5ee9ca3ba95dca55f8a4831047f17570)) + ([7fa50e5](https://github.com/team-spotube/spotube/commit/7fa50e5c5ee9ca3ba95dca55f8a4831047f17570)) - download button on each track - ([925fa86](https://github.com/KRTirtho/spotube/commit/925fa86271fa10ff77b8137ba8d09b8067d0e819)) + ([925fa86](https://github.com/team-spotube/spotube/commit/925fa86271fa10ff77b8137ba8d09b8067d0e819)) - enable caching of queue - ([ec11af5](https://github.com/KRTirtho/spotube/commit/ec11af53a16c435fbea3d0c81910dca371be9ce7)) + ([ec11af5](https://github.com/team-spotube/spotube/commit/ec11af53a16c435fbea3d0c81910dca371be9ce7)) - heart button animation - ([8432dc6](https://github.com/KRTirtho/spotube/commit/8432dc6286fbdfda52bbeb39c6d4ababa05881bc)) + ([8432dc6](https://github.com/team-spotube/spotube/commit/8432dc6286fbdfda52bbeb39c6d4ababa05881bc)) - improved track item API and UI - ([617aa89](https://github.com/KRTirtho/spotube/commit/617aa89409ce29eb3c197aee5c1189d763a3913c)) + ([617aa89](https://github.com/team-spotube/spotube/commit/617aa89409ce29eb3c197aee5c1189d763a3913c)) - **installer:** get latest version from Github API - ([957c085](https://github.com/KRTirtho/spotube/commit/957c085e1243ec0af5bc45d38691c74e2ba91ad8)) + ([957c085](https://github.com/team-spotube/spotube/commit/957c085e1243ec0af5bc45d38691c74e2ba91ad8)) - **local_tracks:** delete local track - ([#484](https://github.com/KRTirtho/spotube/issues/484)) - ([52835b2](https://github.com/KRTirtho/spotube/commit/52835b2ce2212925f80e1a1595c0ca30e6860a8d)) + ([#484](https://github.com/team-spotube/spotube/issues/484)) + ([52835b2](https://github.com/team-spotube/spotube/commit/52835b2ce2212925f80e1a1595c0ca30e6860a8d)) - locale category/genre title - ([88137f0](https://github.com/KRTirtho/spotube/commit/88137f01b27150b306327a01e67ec8a35a60e82e)) + ([88137f0](https://github.com/team-spotube/spotube/commit/88137f01b27150b306327a01e67ec8a35a60e82e)) - **locale:** add bengali translations for search page - ([a1cdbad](https://github.com/KRTirtho/spotube/commit/a1cdbad18782a74b43f0625facfe3e35c516bf43)) + ([a1cdbad](https://github.com/team-spotube/spotube/commit/a1cdbad18782a74b43f0625facfe3e35c516bf43)) - **locale:** localize search, library, lyrics, artist with both Bengali and English - ([11fe9ec](https://github.com/KRTirtho/spotube/commit/11fe9ec74462441b67a0ab0df73824f36dd15e2d)) + ([11fe9ec](https://github.com/team-spotube/spotube/commit/11fe9ec74462441b67a0ab0df73824f36dd15e2d)) - **locale:** player, playlist view, track tile bengali and english translations - ([c55133d](https://github.com/KRTirtho/spotube/commit/c55133dc8bba307823e5b67a30cfab03c923cb7f)) + ([c55133d](https://github.com/team-spotube/spotube/commit/c55133dc8bba307823e5b67a30cfab03c923cb7f)) - localize settings, about, login, player queue with Bengali and English translations - ([a5c36bb](https://github.com/KRTirtho/spotube/commit/a5c36bbb20cc69d609bfb5ab973c7e288c1ea9de)) + ([a5c36bb](https://github.com/team-spotube/spotube/commit/a5c36bbb20cc69d609bfb5ab973c7e288c1ea9de)) - logs page in settings - ([b78e7f5](https://github.com/KRTirtho/spotube/commit/b78e7f57a05db344aae59206cbb0f43b3ee199a9)) + ([b78e7f5](https://github.com/team-spotube/spotube/commit/b78e7f57a05db344aae59206cbb0f43b3ee199a9)) - macos title bar spacing and lyrics page margin separate - ([a0b3771](https://github.com/KRTirtho/spotube/commit/a0b377104f9822561d3b46dbc6551bb561842480)) + ([a0b3771](https://github.com/team-spotube/spotube/commit/a0b377104f9822561d3b46dbc6551bb561842480)) - make snackbar floating - ([9dbb817](https://github.com/KRTirtho/spotube/commit/9dbb8171a6d6b81120ca7ccd74577e5c890ff930)) + ([9dbb817](https://github.com/team-spotube/spotube/commit/9dbb8171a6d6b81120ca7ccd74577e5c890ff930)) - merge floating player with nav bar and nav bar translucent bg - ([a90261e](https://github.com/KRTirtho/spotube/commit/a90261ed199f6cff9e8d0fe24934f8f1d8e9ed98)) + ([a90261e](https://github.com/team-spotube/spotube/commit/a90261ed199f6cff9e8d0fe24934f8f1d8e9ed98)) - **mini_player:** remove window shadow - ([6259014](https://github.com/KRTirtho/spotube/commit/625901482ada4b441b838f640c0ab7167119b321)) + ([6259014](https://github.com/team-spotube/spotube/commit/625901482ada4b441b838f640c0ab7167119b321)) - **mini_player:** show/hide UI on hover toggle - ([2e8b647](https://github.com/KRTirtho/spotube/commit/2e8b647a51f87840c2bd39f0a1dc25ddc91528fc)) + ([2e8b647](https://github.com/team-spotube/spotube/commit/2e8b647a51f87840c2bd39f0a1dc25ddc91528fc)) - new sidebar widget and translucent bottom player - ([4ba1e70](https://github.com/KRTirtho/spotube/commit/4ba1e70636b4ba43697663128fc5422b1d0b2a2f)) + ([4ba1e70](https://github.com/team-spotube/spotube/commit/4ba1e70636b4ba43697663128fc5422b1d0b2a2f)) - newly released albums of user followed artist - ([33cb794](https://github.com/KRTirtho/spotube/commit/33cb7947d63d0a2692a004f87a2ccd5777bf054e)) + ([33cb794](https://github.com/team-spotube/spotube/commit/33cb7947d63d0a2692a004f87a2ccd5777bf054e)) - optimize image load + genre page and reduce page size of loaded categories - ([7131efa](https://github.com/KRTirtho/spotube/commit/7131efa07fdbcf17965fc59ff635a6198b0e5e25)) + ([7131efa](https://github.com/team-spotube/spotube/commit/7131efa07fdbcf17965fc59ff635a6198b0e5e25)) - persistent volume percentage - ([3724bd5](https://github.com/KRTirtho/spotube/commit/3724bd5a10eef7a099d6f596fd038e6fea228359)) + ([3724bd5](https://github.com/team-spotube/spotube/commit/3724bd5a10eef7a099d6f596fd038e6fea228359)) - personal playlist recommendations - ([ae820a2](https://github.com/KRTirtho/spotube/commit/ae820a22f291082c49554d621c25cc62212a6708)) + ([ae820a2](https://github.com/team-spotube/spotube/commit/ae820a22f291082c49554d621c25cc62212a6708)) - piped instance picker on settings - ([bed0d3b](https://github.com/KRTirtho/spotube/commit/bed0d3bd70438df413633ee03fd258a2ca4a1688)) + ([bed0d3b](https://github.com/team-spotube/spotube/commit/bed0d3bd70438df413633ee03fd258a2ca4a1688)) - platform specific title bar buttons - ([6267720](https://github.com/KRTirtho/spotube/commit/62677209a23172162defb7a8e542d981569eba08)) + ([6267720](https://github.com/team-spotube/spotube/commit/62677209a23172162defb7a8e542d981569eba08)) - **playback:** integrate android, ios, macos with JustAudio - ([d487fe5](https://github.com/KRTirtho/spotube/commit/d487fe55630993e2b729050ebd0bf4e1e4be1fb3)) + ([d487fe5](https://github.com/team-spotube/spotube/commit/d487fe55630993e2b729050ebd0bf4e1e4be1fb3)) - **playback:** use assets_audio_player to fix macos double duration problems and android high loading latency - ([1fff0f1](https://github.com/KRTirtho/spotube/commit/1fff0f1bd0d811c348f293f733cbcb7cd57e02f8)) + ([1fff0f1](https://github.com/team-spotube/spotube/commit/1fff0f1bd0d811c348f293f733cbcb7cd57e02f8)) - player details dialog and separate location of lyrics button in player page - ([ce38233](https://github.com/KRTirtho/spotube/commit/ce38233de8f4775018a1d01e951b1635776fe743)) + ([ce38233](https://github.com/team-spotube/spotube/commit/ce38233de8f4775018a1d01e951b1635776fe743)) - **player:** add playlist related methods to audio player - ([f1080e1](https://github.com/KRTirtho/spotube/commit/f1080e1675aee1208d05658adfabfbed04ff45b6)) + ([f1080e1](https://github.com/team-spotube/spotube/commit/f1080e1675aee1208d05658adfabfbed04ff45b6)) - **player:** animated gradient background - ([49b5d0e](https://github.com/KRTirtho/spotube/commit/49b5d0e6948d80abb8ee09203e0d655d68377245)) + ([49b5d0e](https://github.com/team-spotube/spotube/commit/49b5d0e6948d80abb8ee09203e0d655d68377245)) - **player:** custom playlist implementation for media_kit to replace unpredictable playlist of mpv - ([eaf65b6](https://github.com/KRTirtho/spotube/commit/eaf65b6db208aaad745821d4d42afc05f51cee7c)) + ([eaf65b6](https://github.com/team-spotube/spotube/commit/eaf65b6db208aaad745821d4d42afc05f51cee7c)) - **player:** proper coloring of elements - ([b2c4ea1](https://github.com/KRTirtho/spotube/commit/b2c4ea13f6157c2b7bec3957e1f7f50fbf0002c7)) + ([b2c4ea1](https://github.com/team-spotube/spotube/commit/b2c4ea13f6157c2b7bec3957e1f7f50fbf0002c7)) - **player:** replace bg blur with gradient, proper fg color and align title and artist name - ([159f03e](https://github.com/KRTirtho/spotube/commit/159f03e7ca62e6b3b86389e2795da84de61fba78)) + ([159f03e](https://github.com/team-spotube/spotube/commit/159f03e7ca62e6b3b86389e2795da84de61fba78)) - playlist create support for generated playlist - ([91c72f9](https://github.com/KRTirtho/spotube/commit/91c72f9ec9556f301c5d129fc82e19e791a02fbe)) + ([91c72f9](https://github.com/team-spotube/spotube/commit/91c72f9ec9556f301c5d129fc82e19e791a02fbe)) - playlist generation all parameters support - ([9877d5f](https://github.com/KRTirtho/spotube/commit/9877d5f51736db03d5839dadf164d11d0cce82f0)) + ([9877d5f](https://github.com/team-spotube/spotube/commit/9877d5f51736db03d5839dadf164d11d0cce82f0)) - **playlist,album page:** play and shuffle take full width on smaller screens, add new xs breakpoint - ([dce1b88](https://github.com/KRTirtho/spotube/commit/dce1b88694cfcb6b7e63d6ee614ac1dbbd017f6e)) + ([dce1b88](https://github.com/team-spotube/spotube/commit/dce1b88694cfcb6b7e63d6ee614ac1dbbd017f6e)) - **queue:** add track(s) for playing next - ([#460](https://github.com/KRTirtho/spotube/issues/460)) - ([cac8ea6](https://github.com/KRTirtho/spotube/commit/cac8ea638812f5d9cb4305144b6351141a2cf407)) + ([#460](https://github.com/team-spotube/spotube/issues/460)) + ([cac8ea6](https://github.com/team-spotube/spotube/commit/cac8ea638812f5d9cb4305144b6351141a2cf407)) - **queue:** reorder tracks support - ([441b43b](https://github.com/KRTirtho/spotube/commit/441b43bef6b92fd7df6c4e1bef39d67b4a76cd22)) + ([441b43b](https://github.com/team-spotube/spotube/commit/441b43bef6b92fd7df6c4e1bef39d67b4a76cd22)) - re-designed playlist/album page - ([0cedc7a](https://github.com/KRTirtho/spotube/commit/0cedc7a4187771efce8152003f890e242116c78c)) + ([0cedc7a](https://github.com/team-spotube/spotube/commit/0cedc7a4187771efce8152003f890e242116c78c)) - re-introduce youtube API along with piped - ([b54ee96](https://github.com/KRTirtho/spotube/commit/b54ee96233b29d7517eba66e3f8dd9270c2790df)) + ([b54ee96](https://github.com/team-spotube/spotube/commit/b54ee96233b29d7517eba66e3f8dd9270c2790df)) - reactive volume slider and slicker bottom bar with lowered height - ([9d14517](https://github.com/KRTirtho/spotube/commit/9d14517202d5c9d993a947808bf0c6520ed54ea3)) + ([9d14517](https://github.com/team-spotube/spotube/commit/9d14517202d5c9d993a947808bf0c6520ed54ea3)) - remove SponsorBlock in favor of YT Music and remove pocketbase backend track support - ([fb780da](https://github.com/KRTirtho/spotube/commit/fb780da327a213d7a82cbc3b567ece858dc2f0e8)) + ([fb780da](https://github.com/team-spotube/spotube/commit/fb780da327a213d7a82cbc3b567ece858dc2f0e8)) - repeat button all 3 mode and disable player controls when track is fetching - ([1418378](https://github.com/KRTirtho/spotube/commit/14183781dd3f1e16c121e78ad637a326de7b5dcf)) + ([1418378](https://github.com/team-spotube/spotube/commit/14183781dd3f1e16c121e78ad637a326de7b5dcf)) - replace YouTube API with piped API - ([1ecc36d](https://github.com/KRTirtho/spotube/commit/1ecc36da57af61fd9c2ca928589088cd4325f605)) + ([1ecc36d](https://github.com/team-spotube/spotube/commit/1ecc36da57af61fd9c2ca928589088cd4325f605)) - responsive playlist generate page and scrollable multi autocomplete - ([d57aad5](https://github.com/KRTirtho/spotube/commit/d57aad5612f7622dcd638ea8c0ec4d96f741de2b)) + ([d57aad5](https://github.com/team-spotube/spotube/commit/d57aad5612f7622dcd638ea8c0ec4d96f741de2b)) - search alternative track source - ([dfea195](https://github.com/KRTirtho/spotube/commit/dfea195ec178de733717cfe3226cede7521ee2d3)) + ([dfea195](https://github.com/team-spotube/spotube/commit/dfea195ec178de733717cfe3226cede7521ee2d3)) - setup localization (l10n) and language switcher, add sidebar and navbar locale - ([f12d812](https://github.com/KRTirtho/spotube/commit/f12d81259f9e7005e681a7ca9867291d9228a8b1)) + ([f12d812](https://github.com/team-spotube/spotube/commit/f12d81259f9e7005e681a7ca9867291d9228a8b1)) - show album release year - ([#387](https://github.com/KRTirtho/spotube/issues/387)) and fix layout of + ([#387](https://github.com/team-spotube/spotube/issues/387)) and fix layout of artist's album - ([6a6ddf6](https://github.com/KRTirtho/spotube/commit/6a6ddf6e1f6dc72b794cae49adf8348da272babd)) + ([6a6ddf6](https://github.com/team-spotube/spotube/commit/6a6ddf6e1f6dc72b794cae49adf8348da272babd)) - show country code piped instance list - ([60328a6](https://github.com/KRTirtho/spotube/commit/60328a6bafcbff1b7d0ee5099825f0e3d545b60f)) + ([60328a6](https://github.com/team-spotube/spotube/commit/60328a6bafcbff1b7d0ee5099825f0e3d545b60f)) - show loading when track metadata is being fetched, android, ios, macos enable shuffling - ([bf59570](https://github.com/KRTirtho/spotube/commit/bf59570251720a80efe0aa6be481899864da5079)) + ([bf59570](https://github.com/team-spotube/spotube/commit/bf59570251720a80efe0aa6be481899864da5079)) - sort tracks by newest and oldest dates - ([b4713e3](https://github.com/KRTirtho/spotube/commit/b4713e377a938cbebe70089874216f86fe550c34)) + ([b4713e3](https://github.com/team-spotube/spotube/commit/b4713e377a938cbebe70089874216f86fe550c34)) - supabase integration - ([8bcce92](https://github.com/KRTirtho/spotube/commit/8bcce9282eae08c5996a27f16f89cbc187a06823)) -- system tray support ([#31](https://github.com/KRTirtho/spotube/issues/31)) - ([06a0437](https://github.com/KRTirtho/spotube/commit/06a043764d9f65fb448fcf088ccf2737145e23e8)) + ([8bcce92](https://github.com/team-spotube/spotube/commit/8bcce9282eae08c5996a27f16f89cbc187a06823)) +- system tray support ([#31](https://github.com/team-spotube/spotube/issues/31)) + ([06a0437](https://github.com/team-spotube/spotube/commit/06a043764d9f65fb448fcf088ccf2737145e23e8)) - track populate sibling support - ([3aeb026](https://github.com/KRTirtho/spotube/commit/3aeb026776716b6e2eb89c8406a4996a86c7ca60)) + ([3aeb026](https://github.com/team-spotube/spotube/commit/3aeb026776716b6e2eb89c8406a4996a86c7ca60)) - **translation:** add hindi and french translations using ChatGPT - ([6d836bd](https://github.com/KRTirtho/spotube/commit/6d836bdb658c180ca8e2c71e7e290fafa3520727)) + ([6d836bd](https://github.com/team-spotube/spotube/commit/6d836bdb658c180ca8e2c71e7e290fafa3520727)) - **translation:** add Japanase locale - ([4b52a71](https://github.com/KRTirtho/spotube/commit/4b52a71c0914bda6c831d8f637a5934f7bcf8fcb)) + ([4b52a71](https://github.com/team-spotube/spotube/commit/4b52a71c0914bda6c831d8f637a5934f7bcf8fcb)) - use system color scheme - ([862c4b8](https://github.com/KRTirtho/spotube/commit/862c4b8faf2c751d803e373e29981a116bf08ed5)) + ([862c4b8](https://github.com/team-spotube/spotube/commit/862c4b8faf2c751d803e373e29981a116bf08ed5)) - volume slider in player page - ([7abe2c1](https://github.com/KRTirtho/spotube/commit/7abe2c10735bc38c644487139557a731d25e80e6)) + ([7abe2c1](https://github.com/team-spotube/spotube/commit/7abe2c10735bc38c644487139557a731d25e80e6)) - windows OS media control panel support - ([f0b426a](https://github.com/KRTirtho/spotube/commit/f0b426ae89f2e01f4a9c8757ef4e0b4a21b50c7b)) + ([f0b426a](https://github.com/team-spotube/spotube/commit/f0b426ae89f2e01f4a9c8757ef4e0b4a21b50c7b)) ### Bug Fixes - add to playlist dialog not showing playlist name - ([8944581](https://github.com/KRTirtho/spotube/commit/8944581c09eec0162220e7ff684205484fafb599)) + ([8944581](https://github.com/team-spotube/spotube/commit/8944581c09eec0162220e7ff684205484fafb599)) - album sync not working - ([74906f3](https://github.com/KRTirtho/spotube/commit/74906f393250934c36530a73ad7312f59f8627ed)) + ([74906f3](https://github.com/team-spotube/spotube/commit/74906f393250934c36530a73ad7312f59f8627ed)) - alternative track source not playing new source - ([a9b5a71](https://github.com/KRTirtho/spotube/commit/a9b5a714e47d40407d799966ae95f84338f9b59a)) + ([a9b5a71](https://github.com/team-spotube/spotube/commit/a9b5a714e47d40407d799966ae95f84338f9b59a)) - **android:** use multi assetAudioPlayer instance fix patch and disable Pre-download and play by default in Android too - ([cdb3268](https://github.com/KRTirtho/spotube/commit/cdb32685e4bbb899706ed16d58ef9a3a074e283a)) + ([cdb3268](https://github.com/team-spotube/spotube/commit/cdb32685e4bbb899706ed16d58ef9a3a074e283a)) - **artist:** follower count shows as float when < 1000 - ([#482](https://github.com/KRTirtho/spotube/issues/482)) - ([fd1846e](https://github.com/KRTirtho/spotube/commit/fd1846eecf9632e59e4b70fb70e97c556b6374f5)) + ([#482](https://github.com/team-spotube/spotube/issues/482)) + ([fd1846e](https://github.com/team-spotube/spotube/commit/fd1846eecf9632e59e4b70fb70e97c556b6374f5)) - bottom navbar first item icon color not changing on primary color change - ([6eb4244](https://github.com/KRTirtho/spotube/commit/6eb4244f3244a96fe6858261534cc03eb3de803c)) + ([6eb4244](https://github.com/team-spotube/spotube/commit/6eb4244f3244a96fe6858261534cc03eb3de803c)) - cached currently playing track infinite loading - ([9401718](https://github.com/KRTirtho/spotube/commit/94017189c6b9bf55ec62cbf29cd6b0e9fffca42a)) + ([9401718](https://github.com/team-spotube/spotube/commit/94017189c6b9bf55ec62cbf29cd6b0e9fffca42a)) - cached queue tracks expired stream - ([ed29ab5](https://github.com/KRTirtho/spotube/commit/ed29ab5137416d9fb2e7e9fe840f56ef52df6f61)) + ([ed29ab5](https://github.com/team-spotube/spotube/commit/ed29ab5137416d9fb2e7e9fe840f56ef52df6f61)) - collection currently playing state persist on restart - ([1c89e3e](https://github.com/KRTirtho/spotube/commit/1c89e3efb0f05c648fc1c8e09039e62333de18d1)) + ([1c89e3e](https://github.com/team-spotube/spotube/commit/1c89e3efb0f05c648fc1c8e09039e62333de18d1)) - color not syncing and add new screenshot - ([6205501](https://github.com/KRTirtho/spotube/commit/62055018feade0b895663a0bfc5f85f265ae2154)) + ([6205501](https://github.com/team-spotube/spotube/commit/62055018feade0b895663a0bfc5f85f265ae2154)) - content going below bottom player or nav bar - ([1bdce9f](https://github.com/KRTirtho/spotube/commit/1bdce9fe964de88a667bb160846c11dc70b77c00)) + ([1bdce9f](https://github.com/team-spotube/spotube/commit/1bdce9fe964de88a667bb160846c11dc70b77c00)) - disable background_downloader due to android build failures - ([7d23bee](https://github.com/KRTirtho/spotube/commit/7d23beec5ef07c4d649185a69e7a2b9697dc6953)) + ([7d23bee](https://github.com/team-spotube/spotube/commit/7d23beec5ef07c4d649185a69e7a2b9697dc6953)) - disable play when loading track and buffering event - ([30c933c](https://github.com/KRTirtho/spotube/commit/30c933cdf3d4524be164e171094afdd27b0252b7)) + ([30c933c](https://github.com/team-spotube/spotube/commit/30c933cdf3d4524be164e171094afdd27b0252b7)) - error log - ([e3d8239](https://github.com/KRTirtho/spotube/commit/e3d8239b9f5700bfb17c4758d95ba1db1f0e718a)) + ([e3d8239](https://github.com/team-spotube/spotube/commit/e3d8239b9f5700bfb17c4758d95ba1db1f0e718a)) - excessive repaints caused by Player progress bar - ([09b24cf](https://github.com/KRTirtho/spotube/commit/09b24cf1fd1644c549f85904545db54b39cc2431)) + ([09b24cf](https://github.com/team-spotube/spotube/commit/09b24cf1fd1644c549f85904545db54b39cc2431)) - failed download no error icon - ([1266a3f](https://github.com/KRTirtho/spotube/commit/1266a3f1607de11e793a294071850996527d494a)) + ([1266a3f](https://github.com/team-spotube/spotube/commit/1266a3f1607de11e793a294071850996527d494a)) - **home:** bottom player transparency - ([20c424c](https://github.com/KRTirtho/spotube/commit/20c424c77fe273a693213ebf88d50e4025bc8608)) + ([20c424c](https://github.com/team-spotube/spotube/commit/20c424c77fe273a693213ebf88d50e4025bc8608)) - language changer not working - ([7b7b1f2](https://github.com/KRTirtho/spotube/commit/7b7b1f2647591b7cd4cc7841526716c6a2877e55)) + ([7b7b1f2](https://github.com/team-spotube/spotube/commit/7b7b1f2647591b7cd4cc7841526716c6a2877e55)) - less frequent position updates - ([0a49b56](https://github.com/KRTirtho/spotube/commit/0a49b56566abd00cf7703e4207cfa90f93c381fd)) + ([0a49b56](https://github.com/team-spotube/spotube/commit/0a49b56566abd00cf7703e4207cfa90f93c381fd)) - linux mpris not showing up and overall media notification service - ([1abcad1](https://github.com/KRTirtho/spotube/commit/1abcad1de510c209a34196f2de17045af4dd3bc2)) + ([1abcad1](https://github.com/team-spotube/spotube/commit/1abcad1de510c209a34196f2de17045af4dd3bc2)) - local tracks getting fetched on first load - ([73c012c](https://github.com/KRTirtho/spotube/commit/73c012c71ab5050636f79e010d654b4390978ee7)) + ([73c012c](https://github.com/team-spotube/spotube/commit/73c012c71ab5050636f79e010d654b4390978ee7)) - local tracks not working when there's a invalid music file in the folder - ([5855820](https://github.com/KRTirtho/spotube/commit/5855820569dfad7cd26f1e0f0c985babd0d9485d)) + ([5855820](https://github.com/team-spotube/spotube/commit/5855820569dfad7cd26f1e0f0c985babd0d9485d)) - lyrics page blur in player and cut off text when line too big - ([6b4584e](https://github.com/KRTirtho/spotube/commit/6b4584e91bd4f4aee0c56e48a7aec7015c7c418b)) + ([6b4584e](https://github.com/team-spotube/spotube/commit/6b4584e91bd4f4aee0c56e48a7aec7015c7c418b)) - macos build by removing media_kit native event loop - ([62fc773](https://github.com/KRTirtho/spotube/commit/62fc7739b508f0e874978408a2bab0a1d422deb6)) + ([62fc773](https://github.com/team-spotube/spotube/commit/62fc7739b508f0e874978408a2bab0a1d422deb6)) - macos build error, mobile player duration and playing state and background disposal of player - ([be91e33](https://github.com/KRTirtho/spotube/commit/be91e33828630c7062886cd15e4d57496daaa4d5)) + ([be91e33](https://github.com/team-spotube/spotube/commit/be91e33828630c7062886cd15e4d57496daaa4d5)) - **macos,ios:** use regular shared prefs - ([1b5bfec](https://github.com/KRTirtho/spotube/commit/1b5bfec27fbcfe9faabff64d46296bdeebe00161)) + ([1b5bfec](https://github.com/team-spotube/spotube/commit/1b5bfec27fbcfe9faabff64d46296bdeebe00161)) - memoize child of animated widget and make player bg animation faster - ([fcb5c8f](https://github.com/KRTirtho/spotube/commit/fcb5c8f8dabd0d4e3033f80ea3e5d006243cdfb5)) + ([fcb5c8f](https://github.com/team-spotube/spotube/commit/fcb5c8f8dabd0d4e3033f80ea3e5d006243cdfb5)) - mini player not working in release mode - ([28ff321](https://github.com/KRTirtho/spotube/commit/28ff3216efee81184798eedfbb10ba66395bbf36)) + ([28ff321](https://github.com/team-spotube/spotube/commit/28ff3216efee81184798eedfbb10ba66395bbf36)) - **mkPlayer:** remove method and wrong active index on modifying playlist - ([3bafa7b](https://github.com/KRTirtho/spotube/commit/3bafa7b80c963fa52b90ed4cb1393fb121cac713)) + ([3bafa7b](https://github.com/team-spotube/spotube/commit/3bafa7b80c963fa52b90ed4cb1393fb121cac713)) - mobile audio notification not working - ([8f9303b](https://github.com/KRTirtho/spotube/commit/8f9303bc0fddb9d179303a1f0eb76dd5b02410e7)) + ([8f9303b](https://github.com/team-spotube/spotube/commit/8f9303bc0fddb9d179303a1f0eb76dd5b02410e7)) - multiple instance of theme - ([4ec0424](https://github.com/KRTirtho/spotube/commit/4ec04240a5bde6af5c920a61ab6260e7a93bfc54)) + ([4ec0424](https://github.com/team-spotube/spotube/commit/4ec04240a5bde6af5c920a61ab6260e7a93bfc54)) - navigation to settings not working - ([ce10aa1](https://github.com/KRTirtho/spotube/commit/ce10aa1fe2c95d4738835687f613930cf7829f3a)) + ([ce10aa1](https://github.com/team-spotube/spotube/commit/ce10aa1fe2c95d4738835687f613930cf7829f3a)) - no progress update when track changed - ([6ae8964](https://github.com/KRTirtho/spotube/commit/6ae896441a787fce1bc6e5eb5379856dc2f4e96d)) + ([6ae8964](https://github.com/team-spotube/spotube/commit/6ae896441a787fce1bc6e5eb5379856dc2f4e96d)) - null exception on proxy playlist and audio player - ([a455a89](https://github.com/KRTirtho/spotube/commit/a455a89c5861fd455f6950c7b68beae24bdcc6ed)) + ([a455a89](https://github.com/team-spotube/spotube/commit/a455a89c5861fd455f6950c7b68beae24bdcc6ed)) - overflowing clickable artists links - ([4077fac](https://github.com/KRTirtho/spotube/commit/4077fac39fb667b87e959e53d2dcaceefb63cd2d)) + ([4077fac](https://github.com/team-spotube/spotube/commit/4077fac39fb667b87e959e53d2dcaceefb63cd2d)) - personalized playlists not loading - ([caa3408](https://github.com/KRTirtho/spotube/commit/caa340803fdf3859fe5a8a996abae1502ef2e4e7)) + ([caa3408](https://github.com/team-spotube/spotube/commit/caa340803fdf3859fe5a8a996abae1502ef2e4e7)) - playback not moving to next track after a track ends - ([27e8acb](https://github.com/KRTirtho/spotube/commit/27e8acbfe75a37c0a8fa69a444fdd86e92dbe4f0)) + ([27e8acb](https://github.com/team-spotube/spotube/commit/27e8acbfe75a37c0a8fa69a444fdd86e92dbe4f0)) - **player:** gradient bg not taking full height - ([62ad86e](https://github.com/KRTirtho/spotube/commit/62ad86e88d74b5114af78138b221314192e5a801)) + ([62ad86e](https://github.com/team-spotube/spotube/commit/62ad86e88d74b5114af78138b221314192e5a801)) - **player:** playback element placement - ([5e47faa](https://github.com/KRTirtho/spotube/commit/5e47faa6060d7a8aa0d143060e812dc06b8dd790)) + ([5e47faa](https://github.com/team-spotube/spotube/commit/5e47faa6060d7a8aa0d143060e812dc06b8dd790)) - **player:** queue button not showing when not logged in - ([6c2d655](https://github.com/KRTirtho/spotube/commit/6c2d65587b0e6e167be1d0b086df103c7e72d4b2)) + ([6c2d655](https://github.com/team-spotube/spotube/commit/6c2d65587b0e6e167be1d0b086df103c7e72d4b2)) - **player:** volume slider, prefetching of media_kit and stuttering on sponsorblock skip - ([1f32554](https://github.com/KRTirtho/spotube/commit/1f3255481f058c50968561db88172e56b58494f4)) + ([1f32554](https://github.com/team-spotube/spotube/commit/1f3255481f058c50968561db88172e56b58494f4)) - playlist generate slider shape - ([2b35c04](https://github.com/KRTirtho/spotube/commit/2b35c044adb15a97a58692e7880694a251899732)) + ([2b35c04](https://github.com/team-spotube/spotube/commit/2b35c044adb15a97a58692e7880694a251899732)) - pop sheet list not scrollable - ([cca5625](https://github.com/KRTirtho/spotube/commit/cca5625df7e432da8581a4504306baad154deb48)) + ([cca5625](https://github.com/team-spotube/spotube/commit/cca5625df7e432da8581a4504306baad154deb48)) - re-enable add to queue and play next support, favorite button query exceptions - ([e529c79](https://github.com/KRTirtho/spotube/commit/e529c79c4f0cd964b7d89e010d3fe51378ea7222)) + ([e529c79](https://github.com/team-spotube/spotube/commit/e529c79c4f0cd964b7d89e010d3fe51378ea7222)) - re-enable download manager - ([ea45c4f](https://github.com/KRTirtho/spotube/commit/ea45c4f42ae89b8991e470e84a5290b3be3b0f36)) + ([ea45c4f](https://github.com/team-spotube/spotube/commit/ea45c4f42ae89b8991e470e84a5290b3be3b0f36)) - remove unnecessary broadcast stream conversions - ([bf04962](https://github.com/KRTirtho/spotube/commit/bf04962e90ea5345a2bc0d4793999f7db712cab2)) + ([bf04962](https://github.com/team-spotube/spotube/commit/bf04962e90ea5345a2bc0d4793999f7db712cab2)) - remove useBreakpoints as it clogs up memory with unnecessary state updates - ([e1c0f5c](https://github.com/KRTirtho/spotube/commit/e1c0f5cf1e4cece2c4aa235bfbf8511ad7b1fe59)) + ([e1c0f5c](https://github.com/team-spotube/spotube/commit/e1c0f5cf1e4cece2c4aa235bfbf8511ad7b1fe59)) - replace download multiple pops and add translations - ([4a21249](https://github.com/KRTirtho/spotube/commit/4a21249ee386426b0974451542a93e84f532fb3f)) + ([4a21249](https://github.com/team-spotube/spotube/commit/4a21249ee386426b0974451542a93e84f532fb3f)) - screen breakpoints and persist lyrics delay across screens - ([df79638](https://github.com/KRTirtho/spotube/commit/df79638fb622a55aaa2b36c9a1425c2d9c4a8e52)) + ([df79638](https://github.com/team-spotube/spotube/commit/df79638fb622a55aaa2b36c9a1425c2d9c4a8e52)) - sidebar task counter badge and bottom player play button progress color - ([af278d8](https://github.com/KRTirtho/spotube/commit/af278d8feaa08c14528627766bce6d724b846954)) + ([af278d8](https://github.com/team-spotube/spotube/commit/af278d8feaa08c14528627766bce6d724b846954)) - status bar color of playlist/album page - ([65fa3cb](https://github.com/KRTirtho/spotube/commit/65fa3cb624c240360de5a06778a1f72ad10bbe2d)) + ([65fa3cb](https://github.com/team-spotube/spotube/commit/65fa3cb624c240360de5a06778a1f72ad10bbe2d)) - system color scheme not persisting on restart when system color scheme changed - ([e04515d](https://github.com/KRTirtho/spotube/commit/e04515d8e213b4c7f85d11385959a33b042bd9b1)) + ([e04515d](https://github.com/team-spotube/spotube/commit/e04515d8e213b4c7f85d11385959a33b042bd9b1)) - track collection view status bar not transparent - ([9251121](https://github.com/KRTirtho/spotube/commit/9251121ba0154599975e33819a43719477c644f8)) + ([9251121](https://github.com/team-spotube/spotube/commit/9251121ba0154599975e33819a43719477c644f8)) - track doesn't play after change - ([17e5ab6](https://github.com/KRTirtho/spotube/commit/17e5ab611cc417cce7c17cafc7045f5aa2eb970e)) + ([17e5ab6](https://github.com/team-spotube/spotube/commit/17e5ab611cc417cce7c17cafc7045f5aa2eb970e)) - track stops at last second - ([f554f6d](https://github.com/KRTirtho/spotube/commit/f554f6d43bb714f662a27977f501d7ad44b070c3)) + ([f554f6d](https://github.com/team-spotube/spotube/commit/f554f6d43bb714f662a27977f501d7ad44b070c3)) - **track_collection_view:** keyboard focus on scroll and no space for search results in playlist/album - ([7a8bd92](https://github.com/KRTirtho/spotube/commit/7a8bd921047e3766dbbf24449e2873afe3dbecf8)) + ([7a8bd92](https://github.com/team-spotube/spotube/commit/7a8bd921047e3766dbbf24449e2873afe3dbecf8)) - track_table_view table headers - ([d88d287](https://github.com/KRTirtho/spotube/commit/d88d287fc586ec33351de9f3b4359f189054868b)) + ([d88d287](https://github.com/team-spotube/spotube/commit/d88d287fc586ec33351de9f3b4359f189054868b)) - track_tile active and blacklist color, playbutton card action positioning - ([3f5a1b9](https://github.com/KRTirtho/spotube/commit/3f5a1b9587efe9b7b2c69008345867933f79ec67)) + ([3f5a1b9](https://github.com/team-spotube/spotube/commit/3f5a1b9587efe9b7b2c69008345867933f79ec67)) - use id based source getters instead of index - ([a074463](https://github.com/KRTirtho/spotube/commit/a0744630ba2dc713babdb8db6500f9dd0f1e6096)) + ([a074463](https://github.com/team-spotube/spotube/commit/a0744630ba2dc713babdb8db6500f9dd0f1e6096)) -### [2.7.1](https://github.com/KRTirtho/spotube/compare/v2.7.0...v2.7.1) (2023-04-10) +### [2.7.1](https://github.com/team-spotube/spotube/compare/v2.7.0...v2.7.1) (2023-04-10) ### Bug Fixes - fallback for lyrics when anonymous - ([f160ec7](https://github.com/KRTirtho/spotube/commit/f160ec767d9941d33f83aba1752b28df629d0e10)) + ([f160ec7](https://github.com/team-spotube/spotube/commit/f160ec767d9941d33f83aba1752b28df629d0e10)) - **android:** audio notification stuck in play state - ([448c9b3](https://github.com/KRTirtho/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d)) + ([448c9b3](https://github.com/team-spotube/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d)) - **macos:** crashing on startup - ([c46b428](https://github.com/KRTirtho/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b)) + ([c46b428](https://github.com/team-spotube/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b)) - spotify query hooks overriding default query params - ([ec9a02e](https://github.com/KRTirtho/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873)) + ([ec9a02e](https://github.com/team-spotube/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873)) -## [2.7.0](https://github.com/KRTirtho/spotube/compare/v2.6.0...v2.7.0) (2023-03-07) +## [2.7.0](https://github.com/team-spotube/spotube/compare/v2.6.0...v2.7.0) (2023-03-07) ### Features - add or remove track, playlist or album to queue support - ([b8f3493](https://github.com/KRTirtho/spotube/commit/b8f3493138a9acd91d19efe67cfd1c0c7c269ae6)) + ([b8f3493](https://github.com/team-spotube/spotube/commit/b8f3493138a9acd91d19efe67cfd1c0c7c269ae6)) - basic command line argument support - ([025c1ae](https://github.com/KRTirtho/spotube/commit/025c1ae20461c2ac9124b3ef41e21ff01f100498)) + ([025c1ae](https://github.com/team-spotube/spotube/commit/025c1ae20461c2ac9124b3ef41e21ff01f100498)) - black list artist or track - ([947c143](https://github.com/KRTirtho/spotube/commit/947c14353e15227400a6310673f3b850b2ff024f)) + ([947c143](https://github.com/team-spotube/spotube/commit/947c14353e15227400a6310673f3b850b2ff024f)) - bring pre download on desktop, disable pre download for long videos - ([1d82bb0](https://github.com/KRTirtho/spotube/commit/1d82bb098717c7321d3e338f071c7661987fc3be)) + ([1d82bb0](https://github.com/team-spotube/spotube/commit/1d82bb098717c7321d3e338f071c7661987fc3be)) - category/genre filter - ([1dfec05](https://github.com/KRTirtho/spotube/commit/1dfec05eec7ee60cc9f6a3a97af37aef112063f1)) + ([1dfec05](https://github.com/team-spotube/spotube/commit/1dfec05eec7ee60cc9f6a3a97af37aef112063f1)) - centralized icon collection with new icon set and nav bar labels hidden - ([e7acb9e](https://github.com/KRTirtho/spotube/commit/e7acb9ed5cb02826b8da559818f1fccfcf7f143c)) + ([e7acb9e](https://github.com/team-spotube/spotube/commit/e7acb9ed5cb02826b8da559818f1fccfcf7f143c)) - compact search bar for genres and user_local_tracks page - ([c343ccc](https://github.com/KRTirtho/spotube/commit/c343ccc2932868e3c1205d8cc625a9dfe9d78707)) + ([c343ccc](https://github.com/team-spotube/spotube/commit/c343ccc2932868e3c1205d8cc625a9dfe9d78707)) - compatibility with fl-query nextPage method change - ([7617439](https://github.com/KRTirtho/spotube/commit/761743991520609dd2b2dcb12cd6e4e75a8f6925)) + ([7617439](https://github.com/team-spotube/spotube/commit/761743991520609dd2b2dcb12cd6e4e75a8f6925)) - configure pocketbase, generate dart types, update playback to use server instead of hive cache - ([ad90c11](https://github.com/KRTirtho/spotube/commit/ad90c11ab0c9f1aaba9ae9226d6076ea590f1a29)) + ([ad90c11](https://github.com/team-spotube/spotube/commit/ad90c11ab0c9f1aaba9ae9226d6076ea590f1a29)) - failsafe pocketbase requests, removal of unneeded preferences options & vertical playbutton actions - ([d68d150](https://github.com/KRTirtho/spotube/commit/d68d150d3f42260f889d86927378c2f746bb6993)) + ([d68d150](https://github.com/team-spotube/spotube/commit/d68d150d3f42260f889d86927378c2f746bb6993)) - **home:** personalized section - ([9080441](https://github.com/KRTirtho/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95)) + ([9080441](https://github.com/team-spotube/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95)) - individual shuffle and repeat/loop button of player - ([f79223c](https://github.com/KRTirtho/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8)) + ([f79223c](https://github.com/team-spotube/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8)) - **lyrics:** use official spotify API for fetching lyrics and add zoom controls - ([10d0660](https://github.com/KRTirtho/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b)) + ([10d0660](https://github.com/team-spotube/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b)) - **mobile:** pull to refresh support in all refreshable list views - ([9f959ce](https://github.com/KRTirtho/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6)) + ([9f959ce](https://github.com/team-spotube/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6)) - new logo and compact search in playlist/album in mobile - ([dc96cb3](https://github.com/KRTirtho/spotube/commit/dc96cb38cea8dc13738083f4850d22792d071019)) + ([dc96cb3](https://github.com/team-spotube/spotube/commit/dc96cb38cea8dc13738083f4850d22792d071019)) - search/filter tracks inside playlist or album - ([a06cd0d](https://github.com/KRTirtho/spotube/commit/a06cd0da84cc03a2a7cadbc80d70556cb0cf9310)) + ([a06cd0d](https://github.com/team-spotube/spotube/commit/a06cd0da84cc03a2a7cadbc80d70556cb0cf9310)) - show snackbar on adding playlist or tracks to queue - ([6bc1d32](https://github.com/KRTirtho/spotube/commit/6bc1d32a88ae516f77d149b83bcd536d2c888513)) + ([6bc1d32](https://github.com/team-spotube/spotube/commit/6bc1d32a88ae516f77d149b83bcd536d2c888513)) - **theme:** use material3 monet for colors and remove background color preference - ([60ede5f](https://github.com/KRTirtho/spotube/commit/60ede5f92b732691d53850290d9667435298a857)) + ([60ede5f](https://github.com/team-spotube/spotube/commit/60ede5f92b732691d53850290d9667435298a857)) - use catcher to handle exceptions - ([84d94b0](https://github.com/KRTirtho/spotube/commit/84d94b05bc269a1676a261df2b12e508e10e4c0e)) + ([84d94b0](https://github.com/team-spotube/spotube/commit/84d94b05bc269a1676a261df2b12e508e10e4c0e)) - use typed assets instead of hard coded paths - ([59561ab](https://github.com/KRTirtho/spotube/commit/59561abdc2540576fc95b34b3b55def63567000a)) + ([59561ab](https://github.com/team-spotube/spotube/commit/59561abdc2540576fc95b34b3b55def63567000a)) - user local tracks searchbar - ([e7f3f4e](https://github.com/KRTirtho/spotube/commit/e7f3f4eae49fe27a52fc3866fa4f6f2efb2aa479)) + ([e7f3f4e](https://github.com/team-spotube/spotube/commit/e7f3f4eae49fe27a52fc3866fa4f6f2efb2aa479)) - **user-library:** filtering support for user albums and user artists - ([0b58155](https://github.com/KRTirtho/spotube/commit/0b58155d434f2de6359be77d7beee4484dbb7b2a)) + ([0b58155](https://github.com/team-spotube/spotube/commit/0b58155d434f2de6359be77d7beee4484dbb7b2a)) - **user-library:** search for user playlists - ([af4d56f](https://github.com/KRTirtho/spotube/commit/af4d56fd41e57cbe6d87883e87e6b4469aaba52f)) + ([af4d56f](https://github.com/team-spotube/spotube/commit/af4d56fd41e57cbe6d87883e87e6b4469aaba52f)) ### Bug Fixes - **about:** license text hidden in the bottom of smaller screen devices - ([e158dd0](https://github.com/KRTirtho/spotube/commit/e158dd0cec5657e495b538e86c412b06974a9f49)) + ([e158dd0](https://github.com/team-spotube/spotube/commit/e158dd0cec5657e495b538e86c412b06974a9f49)) - **about:** wrong link of License - ([a4a7f1a](https://github.com/KRTirtho/spotube/commit/a4a7f1a74f9df82927403ca93aec508a13315ae8)) + ([a4a7f1a](https://github.com/team-spotube/spotube/commit/a4a7f1a74f9df82927403ca93aec508a13315ae8)) - genre and sidebar user logo not loading - ([710f172](https://github.com/KRTirtho/spotube/commit/710f172dee45f60ed3e5ed83017eb538d6a626bf)) + ([710f172](https://github.com/team-spotube/spotube/commit/710f172dee45f60ed3e5ed83017eb538d6a626bf)) - lyrics modal sheet out of safe area so use 80% of screen height instead of full - ([3db28f4](https://github.com/KRTirtho/spotube/commit/3db28f43b4200d03f7758e8c395d8430e0f89333)) + ([3db28f4](https://github.com/team-spotube/spotube/commit/3db28f43b4200d03f7758e8c395d8430e0f89333)) - lyrics not changing on track change - ([c809d2d](https://github.com/KRTirtho/spotube/commit/c809d2daba4beaea7c4f16c6bb0edef9efa825b8)) + ([c809d2d](https://github.com/team-spotube/spotube/commit/c809d2daba4beaea7c4f16c6bb0edef9efa825b8)) - lyrics not refetching when tracked changed while being in another page and sidebar user avatar not showing on startup - ([bd12675](https://github.com/KRTirtho/spotube/commit/bd126751e9594fbc926bbcad7b9a2c577fce074a)) + ([bd12675](https://github.com/team-spotube/spotube/commit/bd126751e9594fbc926bbcad7b9a2c577fce074a)) - macOS logo placement - ([c6a5d5f](https://github.com/KRTirtho/spotube/commit/c6a5d5f7b1b1fad3a0b5e63c02c847a149e72efe)) + ([c6a5d5f](https://github.com/team-spotube/spotube/commit/c6a5d5f7b1b1fad3a0b5e63c02c847a149e72efe)) - mobile track collection search bar position and page_window_title_bar exception on mobile platforms - ([d0aaa97](https://github.com/KRTirtho/spotube/commit/d0aaa971fe358b9cb5dc7a35cc82eaf6520f7ab4)) + ([d0aaa97](https://github.com/team-spotube/spotube/commit/d0aaa971fe358b9cb5dc7a35cc82eaf6520f7ab4)) - **play_overlay:** show progress indicator on song loading - ([7803a48](https://github.com/KRTirtho/spotube/commit/7803a48237c91f2a57bcc86fbd30ad879142c8ff)) + ([7803a48](https://github.com/team-spotube/spotube/commit/7803a48237c91f2a57bcc86fbd30ad879142c8ff)) - **playback:** not skipping track's sponsorblock segments - ([60a5847](https://github.com/KRTirtho/spotube/commit/60a5847ae68836bbbeef748254c674c81fa5c3ea)) + ([60a5847](https://github.com/team-spotube/spotube/commit/60a5847ae68836bbbeef748254c674c81fa5c3ea)) - playbutton card play state not changing - ([ee46d09](https://github.com/KRTirtho/spotube/commit/ee46d0970be9e227793494a41e25c0c469847cd0)) + ([ee46d09](https://github.com/team-spotube/spotube/commit/ee46d0970be9e227793494a41e25c0c469847cd0)) - **playbutton_card:** play and add to queue needs 2 clicks work - ([bdd7098](https://github.com/KRTirtho/spotube/commit/bdd70984e6670813e508786e74cd2ea4a1fe1d53)) + ([bdd7098](https://github.com/team-spotube/spotube/commit/bdd70984e6670813e508786e74cd2ea4a1fe1d53)) - **playbutton_card:** play and non play state correction - ([b327ffb](https://github.com/KRTirtho/spotube/commit/b327ffb1084b43e5c78e13994f65fb30b3a7e67e)) + ([b327ffb](https://github.com/team-spotube/spotube/commit/b327ffb1084b43e5c78e13994f65fb30b3a7e67e)) - **playbutton_card:** title text overflow - ([39ee0a9](https://github.com/KRTirtho/spotube/commit/39ee0a92a8f3d74d243db206fe034330f75c0588)) + ([39ee0a9](https://github.com/team-spotube/spotube/commit/39ee0a92a8f3d74d243db206fe034330f75c0588)) - **playbutton:** playing state is not updating when playlist is actually playing - ([9bad8c9](https://github.com/KRTirtho/spotube/commit/9bad8c9eb88f7c91091a669b642b92474df0f128)) + ([9bad8c9](https://github.com/team-spotube/spotube/commit/9bad8c9eb88f7c91091a669b642b92474df0f128)) - **player_queue:** large clear button and macos exception - ([0e43504](https://github.com/KRTirtho/spotube/commit/0e43504e18d2315fb1b7975b67bd2c596cbfb1bc)) + ([0e43504](https://github.com/team-spotube/spotube/commit/0e43504e18d2315fb1b7975b67bd2c596cbfb1bc)) - **playlist_queue:** load method not preserving the active track before filtering blacklisted tracks - ([42b3e11](https://github.com/KRTirtho/spotube/commit/42b3e111f844f6de6a145de2760ccfd7e97e623b)) + ([42b3e11](https://github.com/team-spotube/spotube/commit/42b3e111f844f6de6a145de2760ccfd7e97e623b)) - pre downloading not working properly, audio service circular deps and sibling not loading for backend track - ([3ccb525](https://github.com/KRTirtho/spotube/commit/3ccb525260a83ba54021a353b15ed3cda6e9c876)) + ([3ccb525](https://github.com/team-spotube/spotube/commit/3ccb525260a83ba54021a353b15ed3cda6e9c876)) - search track play button isn't working - ([0751f5e](https://github.com/KRTirtho/spotube/commit/0751f5e3173882f3aeed67027854e5054b689693)) + ([0751f5e](https://github.com/team-spotube/spotube/commit/0751f5e3173882f3aeed67027854e5054b689693)) - **search:** grey screen, only tracks update on new search string, playlists,albums,artists show up before hitting return/submit - ([a774817](https://github.com/KRTirtho/spotube/commit/a774817240ef813cb95f82f53ccb798ef9acb51d)) + ([a774817](https://github.com/team-spotube/spotube/commit/a774817240ef813cb95f82f53ccb798ef9acb51d)) - **search:** has to submit twice for search results - ([f5dc76a](https://github.com/KRTirtho/spotube/commit/f5dc76a98f55f0f032a6fe4208465899f932355a)) + ([f5dc76a](https://github.com/team-spotube/spotube/commit/f5dc76a98f55f0f032a6fe4208465899f932355a)) - titlebar maximize+restore button not working and less responsive title bar buttons - ([8a6ba3b](https://github.com/KRTirtho/spotube/commit/8a6ba3b35f0b6b42cf60920e945ac2065c886ecb)) + ([8a6ba3b](https://github.com/team-spotube/spotube/commit/8a6ba3b35f0b6b42cf60920e945ac2065c886ecb)) - **track_collection_view:** hide search bar when sliver is collapsed - ([3d6d244](https://github.com/KRTirtho/spotube/commit/3d6d2444beed153a2b6663d6153684b2974f4152)) + ([3d6d244](https://github.com/team-spotube/spotube/commit/3d6d2444beed153a2b6663d6153684b2974f4152)) - **track_tile:** cannot see track index above 99 - ([78b3273](https://github.com/KRTirtho/spotube/commit/78b3273e441cdaa6d4a410ddfe29837dc1aa7000)) + ([78b3273](https://github.com/team-spotube/spotube/commit/78b3273e441cdaa6d4a410ddfe29837dc1aa7000)) - **track_tile:** track action popup not showing on narrow screens - ([0c54f2d](https://github.com/KRTirtho/spotube/commit/0c54f2dcd4474b63db4c517b0e7332cbd3ab51e9)) + ([0c54f2d](https://github.com/team-spotube/spotube/commit/0c54f2dcd4474b63db4c517b0e7332cbd3ab51e9)) - **ui:** scaffold exception in fluent_ui - ([8ce2192](https://github.com/KRTirtho/spotube/commit/8ce2192e5cb08e3a8be5ead510ab35b274bef2ef)) + ([8ce2192](https://github.com/team-spotube/spotube/commit/8ce2192e5cb08e3a8be5ead510ab35b274bef2ef)) - use chosen market for new release - ([c6bf9b6](https://github.com/KRTirtho/spotube/commit/c6bf9b67995161a8bf7c3782188d01e8859c18e9)) + ([c6bf9b6](https://github.com/team-spotube/spotube/commit/c6bf9b67995161a8bf7c3782188d01e8859c18e9)) -## [2.6.0](https://github.com/KRTirtho/spotube/compare/v2.5.0...v2.6.0) (2022-12-09) +## [2.6.0](https://github.com/team-spotube/spotube/compare/v2.5.0...v2.6.0) (2022-12-09) ### Features - add selected tracks to playlists, optimistic playlist remove track - ([3386dac](https://github.com/KRTirtho/spotube/commit/3386dac78ee49b9e3504f5c05bf1e7b362a2e8a2)) + ([3386dac](https://github.com/team-spotube/spotube/commit/3386dac78ee49b9e3504f5c05bf1e7b362a2e8a2)) - added shuffle button in playlist and album section - ([1fad95f](https://github.com/KRTirtho/spotube/commit/1fad95f6e370606a9faf6f2bb9738dc360e23918)) + ([1fad95f](https://github.com/team-spotube/spotube/commit/1fad95f6e370606a9faf6f2bb9738dc360e23918)) - **android-playback:** option to download track bytes and play instead of Streaming - ([dcc8ba5](https://github.com/KRTirtho/spotube/commit/dcc8ba5a54286b252d53c9c14918971bf7bea8cc)) + ([dcc8ba5](https://github.com/team-spotube/spotube/commit/dcc8ba5a54286b252d53c9c14918971bf7bea8cc)) - change default platform option and platform specific back button - ([36c5e02](https://github.com/KRTirtho/spotube/commit/36c5e02f18374100f61cc3f2957c27bfc0d8511f)) + ([36c5e02](https://github.com/team-spotube/spotube/commit/36c5e02f18374100f61cc3f2957c27bfc0d8511f)) - dialog logo for macos, settings more width for country picker - ([5e96913](https://github.com/KRTirtho/spotube/commit/5e96913ba34230e7a15d62060d5dae28d80e3630)) + ([5e96913](https://github.com/team-spotube/spotube/commit/5e96913ba34230e7a15d62060d5dae28d80e3630)) - initial platform_ui integration - ([9eee573](https://github.com/KRTirtho/spotube/commit/9eee573ce928aa6c03dcb50bf1521350d2de32cc)) + ([9eee573](https://github.com/team-spotube/spotube/commit/9eee573ce928aa6c03dcb50bf1521350d2de32cc)) - libadwaita theming, track tile and PlayButtonCard play button icon fix - ([e795e23](https://github.com/KRTirtho/spotube/commit/e795e23e42e5f1f832963b2a4506df89b7df5baa)) + ([e795e23](https://github.com/team-spotube/spotube/commit/e795e23e42e5f1f832963b2a4506df89b7df5baa)) - **lyrics:** tabs for both synced and static lyrics - [#182](https://github.com/KRTirtho/spotube/issues/182) - ([6b6907a](https://github.com/KRTirtho/spotube/commit/6b6907af3fdb327312ad4dd9e16b3e2a850ed896)) + [#182](https://github.com/team-spotube/spotube/issues/182) + ([6b6907a](https://github.com/team-spotube/spotube/commit/6b6907af3fdb327312ad4dd9e16b3e2a850ed896)) - new refined about page, update checker only check for same update channel - ([4cadfa9](https://github.com/KRTirtho/spotube/commit/4cadfa93750cc9b3f8fbe7b60f8161a77d2a12f6)) + ([4cadfa9](https://github.com/team-spotube/spotube/commit/4cadfa93750cc9b3f8fbe7b60f8161a77d2a12f6)) - pause track when seeking forward/back and keep audio session alive when paused/interrupted - ([bc8a04e](https://github.com/KRTirtho/spotube/commit/bc8a04e5442ba3abe1b04ab325769559f37d9802)) + ([bc8a04e](https://github.com/team-spotube/spotube/commit/bc8a04e5442ba3abe1b04ab325769559f37d9802)) - platform bottom navigation bar add - ([ff14469](https://github.com/KRTirtho/spotube/commit/ff1446982f0260c6fe231970aa6ed61c273fdf07)) + ([ff14469](https://github.com/team-spotube/spotube/commit/ff1446982f0260c6fe231970aa6ed61c273fdf07)) - platform slider and progress indicator integration - ([46b00ba](https://github.com/KRTirtho/spotube/commit/46b00bafdf71a4add61cf50168a32198c3293181)) + ([46b00ba](https://github.com/team-spotube/spotube/commit/46b00bafdf71a4add61cf50168a32198c3293181)) - platform title bar buttons add - ([54048cb](https://github.com/KRTirtho/spotube/commit/54048cbfc37d9a40c020eb81ab67b9dd5428f0d7)) + ([54048cb](https://github.com/team-spotube/spotube/commit/54048cbfc37d9a40c020eb81ab67b9dd5428f0d7)) - **playback:** change current track youtube source panel and tooltips for player icon buttons - ([4b21cc8](https://github.com/KRTirtho/spotube/commit/4b21cc829954fb079d1a0081b9147377063da3ec)) + ([4b21cc8](https://github.com/team-spotube/spotube/commit/4b21cc829954fb079d1a0081b9147377063da3ec)) - Player and Playbutton theme respect to platform - ([512446d](https://github.com/KRTirtho/spotube/commit/512446dcab72aa1d7bce18e5a5793f6be8f30fcb)) + ([512446d](https://github.com/team-spotube/spotube/commit/512446dcab72aa1d7bce18e5a5793f6be8f30fcb)) - player queue and sibling tracks platform decoration - ([39a7794](https://github.com/KRTirtho/spotube/commit/39a77945d132d90ff323b0a22edc2a12a4749888)) + ([39a7794](https://github.com/team-spotube/spotube/commit/39a77945d132d90ff323b0a22edc2a12a4749888)) - **PlayerView:** shortcut button for opening lyrics - [#273](https://github.com/KRTirtho/spotube/issues/273) - ([1d4847a](https://github.com/KRTirtho/spotube/commit/1d4847ab0a0b18d5bc27257b3db863b995dc5843)) + [#273](https://github.com/team-spotube/spotube/issues/273) + ([1d4847a](https://github.com/team-spotube/spotube/commit/1d4847ab0a0b18d5bc27257b3db863b995dc5843)) - rename files to snake_case and reorganize folder structure - ([7c25e1c](https://github.com/KRTirtho/spotube/commit/7c25e1cc8a35eb8aee2268799c05f299204fa3f5)) + ([7c25e1c](https://github.com/team-spotube/spotube/commit/7c25e1cc8a35eb8aee2268799c05f299204fa3f5)) - replace all types of buttons with platform buttons - ([69739b4](https://github.com/KRTirtho/spotube/commit/69739b457296a6b209aa6f73beb378ae1f089ac5)) + ([69739b4](https://github.com/team-spotube/spotube/commit/69739b457296a6b209aa6f73beb378ae1f089ac5)) - rpm packaging support - ([067e9ac](https://github.com/KRTirtho/spotube/commit/067e9ac53ee85775c9ec35457fac9e064e72e4c0)) + ([067e9ac](https://github.com/team-spotube/spotube/commit/067e9ac53ee85775c9ec35457fac9e064e72e4c0)) - **search:** infinite scroll for tracks, artists, playlists and albums - ([e6761a6](https://github.com/KRTirtho/spotube/commit/e6761a6f8eadf4ab260723253a8e00121b6365b5)) + ([e6761a6](https://github.com/team-spotube/spotube/commit/e6761a6f8eadf4ab260723253a8e00121b6365b5)) - set platform to default platform on start up - ([472da6b](https://github.com/KRTirtho/spotube/commit/472da6b8b1c3e06b666da58351f3feafbfe6c98a)) + ([472da6b](https://github.com/team-spotube/spotube/commit/472da6b8b1c3e06b666da58351f3feafbfe6c98a)) - shuffle keep playing track at top, linux title bar drag no working - ([1223cf2](https://github.com/KRTirtho/spotube/commit/1223cf2629c6615b0c48e9e6742b68341f33c7f8)) + ([1223cf2](https://github.com/team-spotube/spotube/commit/1223cf2629c6615b0c48e9e6742b68341f33c7f8)) - sidebar download count and proper progress color in playbutton - ([a10bc5b](https://github.com/KRTirtho/spotube/commit/a10bc5b8d89207ac86872dd25f3258e47c2141a5)) + ([a10bc5b](https://github.com/team-spotube/spotube/commit/a10bc5b8d89207ac86872dd25f3258e47c2141a5)) - static shimmer for track tile, playbutton card and track tile - ([3ed8b0f](https://github.com/KRTirtho/spotube/commit/3ed8b0fda2cb8145a2bc6c8f8c6af82db6a40547)) + ([3ed8b0f](https://github.com/team-spotube/spotube/commit/3ed8b0fda2cb8145a2bc6c8f8c6af82db6a40547)) - tablet mode navigation bar & windows semi transparent bg, - ([3282370](https://github.com/KRTirtho/spotube/commit/3282370f74f323c00116fa8626fd1440ee9d4922)) + ([3282370](https://github.com/team-spotube/spotube/commit/3282370f74f323c00116fa8626fd1440ee9d4922)) - **title_bar:** platform specific title bar - ([e659e3c](https://github.com/KRTirtho/spotube/commit/e659e3c56fb02ad8a1c5114bf80074f0324cc4f1)) + ([e659e3c](https://github.com/team-spotube/spotube/commit/e659e3c56fb02ad8a1c5114bf80074f0324cc4f1)) - titlebar complete compatibility, platform specific login, library tabbar in titlebar - ([b3c27d1](https://github.com/KRTirtho/spotube/commit/b3c27d1fca233ea079803fe49134abc528376df3)) + ([b3c27d1](https://github.com/team-spotube/spotube/commit/b3c27d1fca233ea079803fe49134abc528376df3)) - use platform checkbox - ([2211505](https://github.com/KRTirtho/spotube/commit/2211505d713cef752d07cafb9791a49e8095eee2)) + ([2211505](https://github.com/team-spotube/spotube/commit/2211505d713cef752d07cafb9791a49e8095eee2)) - window blur effect add - ([b0db5e7](https://github.com/KRTirtho/spotube/commit/b0db5e7d8246f98835e7ce6656c8aa7620e46bec)) + ([b0db5e7](https://github.com/team-spotube/spotube/commit/b0db5e7d8246f98835e7ce6656c8aa7620e46bec)) ### Bug Fixes - **ArtistCard:** linux shadow - ([c186881](https://github.com/KRTirtho/spotube/commit/c1868817e5abb8a4152646f00a0395933fee7823)) + ([c186881](https://github.com/team-spotube/spotube/commit/c1868817e5abb8a4152646f00a0395933fee7823)) - **auth:** refresh access token timer not working - ([b3ac5ca](https://github.com/KRTirtho/spotube/commit/b3ac5ca3bbb6d5af154f4b5d715d1f19ca2f46e2)) + ([b3ac5ca](https://github.com/team-spotube/spotube/commit/b3ac5ca3bbb6d5af154f4b5d715d1f19ca2f46e2)) - bottom navigation bar settings tile not active when selected - ([43557e4](https://github.com/KRTirtho/spotube/commit/43557e40df269757c2d5236a455308ea6478d95a)) + ([43557e4](https://github.com/team-spotube/spotube/commit/43557e40df269757c2d5236a455308ea6478d95a)) - dialog logo in android, lyrics visible timer adjust button - ([3c6803b](https://github.com/KRTirtho/spotube/commit/3c6803bb3fac8eee9166764089724194a48509c6)) + ([3c6803b](https://github.com/team-spotube/spotube/commit/3c6803bb3fac8eee9166764089724194a48509c6)) - heart button showing when not logged in, wrong login redirect - ([4dc26af](https://github.com/KRTirtho/spotube/commit/4dc26af23d12f76cbfdfbf4e37b0c11fcc484d3f)) + ([4dc26af](https://github.com/team-spotube/spotube/commit/4dc26af23d12f76cbfdfbf4e37b0c11fcc484d3f)) - horizontal infinite lists doesn't fill the screen - ([69995be](https://github.com/KRTirtho/spotube/commit/69995bea1c6342c9212e5b22ef50bdfd6e7eba45)) + ([69995be](https://github.com/team-spotube/spotube/commit/69995bea1c6342c9212e5b22ef50bdfd6e7eba45)) - ios dialog action buttons, local tracks crashing app, shimmer color and android wrong status bar color - ([90c1200](https://github.com/KRTirtho/spotube/commit/90c1200a087f796690de0cfc8cc607d2bff44282)) + ([90c1200](https://github.com/team-spotube/spotube/commit/90c1200a087f796690de0cfc8cc607d2bff44282)) - **login:** not working in android in Brazil or Ukraine regions - ([0b79a11](https://github.com/KRTirtho/spotube/commit/0b79a1181c37cf06fbfa3bfb3854cfd47097016e)) + ([0b79a11](https://github.com/team-spotube/spotube/commit/0b79a1181c37cf06fbfa3bfb3854cfd47097016e)) - **macos:** black text in dark mode - ([fb9c0e4](https://github.com/KRTirtho/spotube/commit/fb9c0e44be93997fc852bf0260e8a8608000c023)) + ([fb9c0e4](https://github.com/team-spotube/spotube/commit/fb9c0e44be93997fc852bf0260e8a8608000c023)) - **macos:** white text color in dark mode, text field white background - ([e086b52](https://github.com/KRTirtho/spotube/commit/e086b520e745e65771136cbfa842ae0693c44872)) + ([e086b52](https://github.com/team-spotube/spotube/commit/e086b520e745e65771136cbfa842ae0693c44872)) - **mobile:** SafeArea bugs and back button color - ([a8330ef](https://github.com/KRTirtho/spotube/commit/a8330ef2e1112012bbae19ee6a5c27a26c5fb719)) + ([a8330ef](https://github.com/team-spotube/spotube/commit/a8330ef2e1112012bbae19ee6a5c27a26c5fb719)) - null exception in themes - ([9465d92](https://github.com/KRTirtho/spotube/commit/9465d92fa032b8598a0752767dcec9af2541d222)) + ([9465d92](https://github.com/team-spotube/spotube/commit/9465d92fa032b8598a0752767dcec9af2541d222)) - platform_ui local path - ([00d0d38](https://github.com/KRTirtho/spotube/commit/00d0d38b5450aeb877195afdfb9424f83762d178)) + ([00d0d38](https://github.com/team-spotube/spotube/commit/00d0d38b5450aeb877195afdfb9424f83762d178)) - player view artist link when local playlist is playing, lyric delay adjust button alignment - ([ee5c417](https://github.com/KRTirtho/spotube/commit/ee5c417ac396ef0b1796fa74a6a494181e6e0396)) + ([ee5c417](https://github.com/team-spotube/spotube/commit/ee5c417ac396ef0b1796fa74a6a494181e6e0396)) - remove windows background - ([6942964](https://github.com/KRTirtho/spotube/commit/694296418787c460bb3fa63ab30f3b0eed9184dc)) + ([6942964](https://github.com/team-spotube/spotube/commit/694296418787c460bb3fa63ab30f3b0eed9184dc)) - search field ios dark icon , lyrics tabbar ios background color - ([be56ad4](https://github.com/KRTirtho/spotube/commit/be56ad44773ebcd14777d80b61e26875698dc18a)) + ([be56ad4](https://github.com/team-spotube/spotube/commit/be56ad44773ebcd14777d80b61e26875698dc18a)) - settings Title alignment and play button card ripple effect in other platforms - ([3b6bf27](https://github.com/KRTirtho/spotube/commit/3b6bf27a984f5d4836143638396ed4b467c0eae7)) + ([3b6bf27](https://github.com/team-spotube/spotube/commit/3b6bf27a984f5d4836143638396ed4b467c0eae7)) - shuffle play logic - ([65cad07](https://github.com/KRTirtho/spotube/commit/65cad07e3a6e2188c53159057f9c3d4fe89706ea)) + ([65cad07](https://github.com/team-spotube/spotube/commit/65cad07e3a6e2188c53159057f9c3d4fe89706ea)) - small minwidth of window in desktop, linux wrong light theme accent color, search field transparent background - ([5b0e22c](https://github.com/KRTirtho/spotube/commit/5b0e22c1b639f2f57d92cb70cd11f56e30e0a457)) + ([5b0e22c](https://github.com/team-spotube/spotube/commit/5b0e22c1b639f2f57d92cb70cd11f56e30e0a457)) - tooltips of menu and adaptive pop up menu - ([261aaf1](https://github.com/KRTirtho/spotube/commit/261aaf191c51bc12b28c602ee160d53d3eacf3a5)) + ([261aaf1](https://github.com/team-spotube/spotube/commit/261aaf191c51bc12b28c602ee160d53d3eacf3a5)) - update download dialog blocking the UI - ([3925f74](https://github.com/KRTirtho/spotube/commit/3925f743951e51f138cc3ca865fa167c34e776ef)) + ([3925f74](https://github.com/team-spotube/spotube/commit/3925f743951e51f138cc3ca865fa167c34e776ef)) - user playlists not updating after creating/deleting, artist follow not updating after follow/unfollow - ([6cc2a18](https://github.com/KRTirtho/spotube/commit/6cc2a185d0c4c19f176e6f65b8ada19ebc76af5e)) + ([6cc2a18](https://github.com/team-spotube/spotube/commit/6cc2a185d0c4c19f176e6f65b8ada19ebc76af5e)) - **windows:** windows global title bar - ([bd18f19](https://github.com/KRTirtho/spotube/commit/bd18f198217538f0089d5a1c4288dd97f982661b)) + ([bd18f19](https://github.com/team-spotube/spotube/commit/bd18f198217538f0089d5a1c4288dd97f982661b)) -## [2.5.0](https://github.com/KRTirtho/spotube/compare/v2.4.1...v2.5.0) (2022-10-13) +## [2.5.0](https://github.com/team-spotube/spotube/compare/v2.4.1...v2.5.0) (2022-10-13) ### Features - animated transition of root PageWindowTitleBar - ([ff35e06](https://github.com/KRTirtho/spotube/commit/ff35e06a6605fc7ec762e716fb7bdf6f7eb45732)) + ([ff35e06](https://github.com/team-spotube/spotube/commit/ff35e06a6605fc7ec762e716fb7bdf6f7eb45732)) - **auth:** new authentication flow using cookies and webview in android - ([756b910](https://github.com/KRTirtho/spotube/commit/756b91007ee747c10ed10aa7060af49b555a2eaf)) + ([756b910](https://github.com/team-spotube/spotube/commit/756b91007ee747c10ed10aa7060af49b555a2eaf)) - **downloader:** replace /skip all choice for downloaded tracks - ([88d7ce5](https://github.com/KRTirtho/spotube/commit/88d7ce55a59f673d60cd9e85ab062bcb1b7dcbc3)) + ([88d7ce5](https://github.com/team-spotube/spotube/commit/88d7ce55a59f673d60cd9e85ab062bcb1b7dcbc3)) - implemented go_route shell/nested route - ([3e498a4](https://github.com/KRTirtho/spotube/commit/3e498a4827a1118e0b23faec7cf114272f7838d4)) + ([3e498a4](https://github.com/team-spotube/spotube/commit/3e498a4827a1118e0b23faec7cf114272f7838d4)) - **keyboard shortcuts:** play/pause on space, seek position on left/right - ([2734454](https://github.com/KRTirtho/spotube/commit/2734454717bbfb5d0621c6ea72fa755ef4fc8602)) + ([2734454](https://github.com/team-spotube/spotube/commit/2734454717bbfb5d0621c6ea72fa755ef4fc8602)) - **keyboard-shortcuts:** home sidebar tab navigation and close app - ([8f258e7](https://github.com/KRTirtho/spotube/commit/8f258e709ada418dbeef8d272af370b1741afd9c)) + ([8f258e7](https://github.com/team-spotube/spotube/commit/8f258e709ada418dbeef8d272af370b1741afd9c)) - smoother list using fl_query and waypoint - ([c77b0e1](https://github.com/KRTirtho/spotube/commit/c77b0e198b215180d863747e35998a17aff92720)) + ([c77b0e1](https://github.com/team-spotube/spotube/commit/c77b0e198b215180d863747e35998a17aff92720)) - sort tracks in playlist, album and local tracks - ([cb4bd25](https://github.com/KRTirtho/spotube/commit/cb4bd25df154455d225c426cfeaaea36ac09e9b7)) + ([cb4bd25](https://github.com/team-spotube/spotube/commit/cb4bd25df154455d225c426cfeaaea36ac09e9b7)) - use of smaller sized images in `TrackTile` - ([0ca97b4](https://github.com/KRTirtho/spotube/commit/0ca97b495f2a9ece8356f7813fc0e37d1cdb8608)) + ([0ca97b4](https://github.com/team-spotube/spotube/commit/0ca97b495f2a9ece8356f7813fc0e37d1cdb8608)) - volume slider mouse scroll and preference for Rotating Album Art - [#255](https://github.com/KRTirtho/spotube/issues/255) - ([edb6f3c](https://github.com/KRTirtho/spotube/commit/edb6f3cd1c9ee2961040b2fe7a91c48577cee4f7)) + [#255](https://github.com/team-spotube/spotube/issues/255) + ([edb6f3c](https://github.com/team-spotube/spotube/commit/edb6f3cd1c9ee2961040b2fe7a91c48577cee4f7)) ### Bug Fixes - **android:** file_picker and permission_handler failure for sdk < 33 - ([139d4dc](https://github.com/KRTirtho/spotube/commit/139d4dc033d9aaa1d6882bf0f53e96a3b1e87c95)) + ([139d4dc](https://github.com/team-spotube/spotube/commit/139d4dc033d9aaa1d6882bf0f53e96a3b1e87c95)) - cached local track is fetched from network - ([abf4a57](https://github.com/KRTirtho/spotube/commit/abf4a5763a2faeedeb93d54e66c1f2482295b326)) + ([abf4a57](https://github.com/team-spotube/spotube/commit/abf4a5763a2faeedeb93d54e66c1f2482295b326)) - categories not showing for oauth exception - ([4df917e](https://github.com/KRTirtho/spotube/commit/4df917e65ee20cbcf42394cc141b1cdcdd6cc914)) + ([4df917e](https://github.com/team-spotube/spotube/commit/4df917e65ee20cbcf42394cc141b1cdcdd6cc914)) - **desktop:** maximized window size is stored and window maximized state doesn't persist - ([91d5d10](https://github.com/KRTirtho/spotube/commit/91d5d1003b09530ff3bc9a0aa93e382e943977e0)) + ([91d5d10](https://github.com/team-spotube/spotube/commit/91d5d1003b09530ff3bc9a0aa93e382e943977e0)) - local audio doesn't get refreshed after getting permission - ([618c6da](https://github.com/KRTirtho/spotube/commit/618c6da0ebddf3cc8e216743bbbb9220bcf40521)) + ([618c6da](https://github.com/team-spotube/spotube/commit/618c6da0ebddf3cc8e216743bbbb9220bcf40521)) - no appropriate output when playlist is empty - [#201](https://github.com/KRTirtho/spotube/issues/201) - ([dbb81de](https://github.com/KRTirtho/spotube/commit/dbb81de763df60eba62ef1256a7161ea6ca59b66)) + [#201](https://github.com/team-spotube/spotube/issues/201) + ([dbb81de](https://github.com/team-spotube/spotube/commit/dbb81de763df60eba62ef1256a7161ea6ca59b66)) - PlayerOverlay not hiding when not playing and unneeded bottom space in TrackTableView - ([0ebac05](https://github.com/KRTirtho/spotube/commit/0ebac05a4be8e8f744a6c672d3bb9807d6f02e10)) + ([0ebac05](https://github.com/team-spotube/spotube/commit/0ebac05a4be8e8f744a6c672d3bb9807d6f02e10)) - **web:** not building due to metadata_god ffi - ([1191bf2](https://github.com/KRTirtho/spotube/commit/1191bf232d0797aaae7eff2f5d570acd49ce61bd)) + ([1191bf2](https://github.com/team-spotube/spotube/commit/1191bf232d0797aaae7eff2f5d570acd49ce61bd)) -## [2.4.1](https://github.com/KRTirtho/spotube/compare/v2.4.0...v2.4.1) (2022-09-13) +## [2.4.1](https://github.com/team-spotube/spotube/compare/v2.4.0...v2.4.1) (2022-09-13) ### Features - add macos audio metadata tags support - ([5866b0f](https://github.com/KRTirtho/spotube/commit/5866b0fcd661cf32060bb1485ea81634fbb9b90a)) + ([5866b0f](https://github.com/team-spotube/spotube/commit/5866b0fcd661cf32060bb1485ea81634fbb9b90a)) - remove macos bounds for reading and writing audio metadata - ([16064f6](https://github.com/KRTirtho/spotube/commit/16064f68e882b091401ace4b895e387f46635800)) + ([16064f6](https://github.com/team-spotube/spotube/commit/16064f68e882b091401ace4b895e387f46635800)) - **search:** horizontal swipe scroll support for Desktop platform - ([d5ff927](https://github.com/KRTirtho/spotube/commit/d5ff927c7273b6e72c5d775ee777f2cbd0d6d05c)) + ([d5ff927](https://github.com/team-spotube/spotube/commit/d5ff927c7273b6e72c5d775ee777f2cbd0d6d05c)) ### Bug Fixes - **artist-page:** SpotubeMarqueeText used in ArtistCard crashes the app - ([4279541](https://github.com/KRTirtho/spotube/commit/427954150ab65b250e79fc844fc864abff5b6972)) + ([4279541](https://github.com/team-spotube/spotube/commit/427954150ab65b250e79fc844fc864abff5b6972)) - **layout:** Fix adaptive UI not working correctly by providing a overriding option - ([8c7adde](https://github.com/KRTirtho/spotube/commit/8c7adde890105e0267b71994b7928277f84553e5)) + ([8c7adde](https://github.com/team-spotube/spotube/commit/8c7adde890105e0267b71994b7928277f84553e5)) - **local-track:** throwing exception when downloadLocation is empty - ([1a3556d](https://github.com/KRTirtho/spotube/commit/1a3556d39e8473cadb6143192c48465dc6485599)) + ([1a3556d](https://github.com/team-spotube/spotube/commit/1a3556d39e8473cadb6143192c48465dc6485599)) -## [2.4.0](https://github.com/KRTirtho/spotube/compare/v2.3.0...v2.4.0) (2022-09-09) +## [2.4.0](https://github.com/team-spotube/spotube/compare/v2.3.0...v2.4.0) (2022-09-09) ### Features - Ability to change download location added - ([816707c](https://github.com/KRTirtho/spotube/commit/816707c643f8d60d25bc08fd4c8005daa2ba9e63)) + ([816707c](https://github.com/team-spotube/spotube/commit/816707c643f8d60d25bc08fd4c8005daa2ba9e63)) - add download multi tracks support for mobile platform - ([0476bf7](https://github.com/KRTirtho/spotube/commit/0476bf7ceece034a927d1df6099d8b33036f8a9b)) + ([0476bf7](https://github.com/team-spotube/spotube/commit/0476bf7ceece034a927d1df6099d8b33036f8a9b)) - add download queue for desktop & initial playlist download support - ([08f913e](https://github.com/KRTirtho/spotube/commit/08f913e9761d0f5c447af9dfb6eedb44b675498c)) + ([08f913e](https://github.com/team-spotube/spotube/commit/08f913e9761d0f5c447af9dfb6eedb44b675498c)) - add download tab on library - ([8d77b69](https://github.com/KRTirtho/spotube/commit/8d77b6900a81aab020e19397e788964b0ac499ff)) + ([8d77b69](https://github.com/team-spotube/spotube/commit/8d77b6900a81aab020e19397e788964b0ac499ff)) - add web support although nothing works just as expected - ([2818ed5](https://github.com/KRTirtho/spotube/commit/2818ed5c9dadb9185a52762599c1dd0acd81e6bf)) + ([2818ed5](https://github.com/team-spotube/spotube/commit/2818ed5c9dadb9185a52762599c1dd0acd81e6bf)) - **broken:** Broken Warning! Initial Local Audio Player - ([c3bf511](https://github.com/KRTirtho/spotube/commit/c3bf5119ebb7c17e8c32f149598508674b0acd39)) + ([c3bf511](https://github.com/team-spotube/spotube/commit/c3bf5119ebb7c17e8c32f149598508674b0acd39)) - **download:** track table view multi select improvement, tap to play track support, existing track replace confirmation dialog and bulk download confirmation dialog - ([e217553](https://github.com/KRTirtho/spotube/commit/e21755322f2cd5f1fba00c5c8cd5c5d1f79e459d)) + ([e217553](https://github.com/team-spotube/spotube/commit/e21755322f2cd5f1fba00c5c8cd5c5d1f79e459d)) - **local-tracks:** complete support for local tracks - ([e206f16](https://github.com/KRTirtho/spotube/commit/e206f16723ac989ad58006c1b3c90c6691d8cab3)) + ([e206f16](https://github.com/team-spotube/spotube/commit/e206f16723ac989ad58006c1b3c90c6691d8cab3)) - **mpris:** MPRIS metadata are now updated in realtime - ([d9addcd](https://github.com/KRTirtho/spotube/commit/d9addcda8e9562803bd73016148fab22560ee050)) + ([d9addcd](https://github.com/team-spotube/spotube/commit/d9addcda8e9562803bd73016148fab22560ee050)) - **playback:** add repeat track support - [#166](https://github.com/KRTirtho/spotube/issues/166) - ([cae9993](https://github.com/KRTirtho/spotube/commit/cae99934299bd197c68f626d6c10158d449770b9)) + [#166](https://github.com/team-spotube/spotube/issues/166) + ([cae9993](https://github.com/team-spotube/spotube/commit/cae99934299bd197c68f626d6c10158d449770b9)) - **synced-lyrics:** animated active text size - ([531fae6](https://github.com/KRTirtho/spotube/commit/531fae64f94b21551a7a0da363a9ab0d44f5d3b1)) + ([531fae6](https://github.com/team-spotube/spotube/commit/531fae64f94b21551a7a0da363a9ab0d44f5d3b1)) - **ui:** adaptive TrackTile actions & Setting ListTile - ([615d5ce](https://github.com/KRTirtho/spotube/commit/615d5ce901eb0512e84a120b7309c9053238ee36)) + ([615d5ce](https://github.com/team-spotube/spotube/commit/615d5ce901eb0512e84a120b7309c9053238ee36)) ### Bug Fixes - **adaptive-list-tile:** dialog content not updating when content has changed - ([a1d4230](https://github.com/KRTirtho/spotube/commit/a1d423090c854ebe319a0fa03fd6e5c4007b1387)) + ([a1d4230](https://github.com/team-spotube/spotube/commit/a1d423090c854ebe319a0fa03fd6e5c4007b1387)) - album & playlist card, player view and album view play button logic - ([55852bd](https://github.com/KRTirtho/spotube/commit/55852bd15bc709d61fbba8cbea01ceca791d154c)) + ([55852bd](https://github.com/team-spotube/spotube/commit/55852bd15bc709d61fbba8cbea01ceca791d154c)) - **docs:** indentions - ([4a291d5](https://github.com/KRTirtho/spotube/commit/4a291d5f20dabe68f3ed64071624dcbed8327329)) + ([4a291d5](https://github.com/team-spotube/spotube/commit/4a291d5f20dabe68f3ed64071624dcbed8327329)) - **downloader:** downloaded track is corrupted for tagging - ([2ab1fba](https://github.com/KRTirtho/spotube/commit/2ab1fba3d64147e3c5cf34756dce1cf6046d410a)) + ([2ab1fba](https://github.com/team-spotube/spotube/commit/2ab1fba3d64147e3c5cf34756dce1cf6046d410a)) - **downloader:** flutter downloader exception on desktop platform and too much width of TrackTile index no. - ([d668760](https://github.com/KRTirtho/spotube/commit/d6687603d148ad936530cca4d09e128a59b79b19)) + ([d668760](https://github.com/team-spotube/spotube/commit/d6687603d148ad936530cca4d09e128a59b79b19)) - dropped flutter_downloader deps due to slow download speed and UserDownloads not showing for anonymous - ([307a8e2](https://github.com/KRTirtho/spotube/commit/307a8e21df1e39123a1dca4c1b063eab50359581)) + ([307a8e2](https://github.com/team-spotube/spotube/commit/307a8e21df1e39123a1dca4c1b063eab50359581)) - flutter_downloader manifest configuration breaking android support - ([f3a0f78](https://github.com/KRTirtho/spotube/commit/f3a0f78fb92ff7ee38b5a9ef9954575d4282f954)) + ([f3a0f78](https://github.com/team-spotube/spotube/commit/f3a0f78fb92ff7ee38b5a9ef9954575d4282f954)) - login screen not using safearea and no dialog bg-color found on light mode in AdaptivePopupMenuButton - ([92bc611](https://github.com/KRTirtho/spotube/commit/92bc611c5e901dcabf34086be9287ac20317259a)) + ([92bc611](https://github.com/team-spotube/spotube/commit/92bc611c5e901dcabf34086be9287ac20317259a)) - **performance:** always running marquee text causes high GPU usage - [#175](https://github.com/KRTirtho/spotube/issues/175) and UserArtist overflow + [#175](https://github.com/team-spotube/spotube/issues/175) and UserArtist overflow on smaller displays - ([a23ce61](https://github.com/KRTirtho/spotube/commit/a23ce614467b4297f495b824f0958ff07c21ae92)) + ([a23ce61](https://github.com/team-spotube/spotube/commit/a23ce614467b4297f495b824f0958ff07c21ae92)) - **playback:** shuffle button sometimes gets stuck and stops working - [#183](https://github.com/KRTirtho/spotube/issues/183) - ([4240433](https://github.com/KRTirtho/spotube/commit/4240433e3dde6ab948d2674e07e41c27c1f6eac8)) + [#183](https://github.com/team-spotube/spotube/issues/183) + ([4240433](https://github.com/team-spotube/spotube/commit/4240433e3dde6ab948d2674e07e41c27c1f6eac8)) - **player-overlay:** flickering when a track is changed or navigated to another page - ([e48b67c](https://github.com/KRTirtho/spotube/commit/e48b67cd47ae54ad9268aead268e444836a67b0d)) + ([e48b67c](https://github.com/team-spotube/spotube/commit/e48b67cd47ae54ad9268aead268e444836a67b0d)) - **sidebar:** user image url - ([747efc6](https://github.com/KRTirtho/spotube/commit/747efc6ee66bc6c7c917cc02bd134968a0781701)) + ([747efc6](https://github.com/team-spotube/spotube/commit/747efc6ee66bc6c7c917cc02bd134968a0781701)) - **synced-lyrics:** active lyrics contrast ratio - ([aba1ba9](https://github.com/KRTirtho/spotube/commit/aba1ba932592923720a36395c057f78820dafecf)) + ([aba1ba9](https://github.com/team-spotube/spotube/commit/aba1ba932592923720a36395c057f78820dafecf)) - tabbar overflow in small screen, artist card too small title and synced lyrics contrast increased - ([585de8c](https://github.com/KRTirtho/spotube/commit/585de8c1def9750826568317109b242a5e18f28c)) + ([585de8c](https://github.com/team-spotube/spotube/commit/585de8c1def9750826568317109b242a5e18f28c)) # v2.3.0 ### New +- Playback Cache Support. So unfinished playlist and tracks remains cached & + starts automatically when application is launched again - Playback Cache Support. So unfinished playlist and tracks remains cached & starts automatically when application is launched again - Login Screen guided tutorial about how to obtain Client ID & Client Secret +- Signed Android Application so now longer need to uninstall the old version for + installing the new one - Signed Android Application so now longer need to uninstall the old version for installing the new one - OS Media controls for Linux. Keyboard media keys now work in Linux - New better, consistent & predictable Audio engine with proper event firing - support (https://github.com/KRTirtho/spotube/pull/131) + support (https://github.com/team-spotube/spotube/pull/131) - Custom Lyrics delay time. Can be used to delay negative amount of time too - Playback Queue View support. Currently playing tracks or playlist can be viewed or changed from it or for doing other actions too - (https://github.com/KRTirtho/spotube/issues/126) + (https://github.com/team-spotube/spotube/issues/126) - Android SeekBar support in Notification Panel & Lock Screen - New Blur background design adapted to multiple components including Floating Player, Player View & Lyrics Tab - New HighContrast Color Scheme addition which reduces battery consumption on OLED or AMOLED display devices - (https://github.com/KRTirtho/spotube/issues/137) + (https://github.com/team-spotube/spotube/issues/137) ### Improved - Loading screens & animations. Now uses Skeleton Loading +- Playlist & Album Pages now show Album Art & extra metadata as Header with + vibrant gradient background in a Sliver +- Playback is now more consistent & the API is simpler. Also its the single + source of truth for AudioPlayback instead of the AudioServiceHandler - Playlist & Album Pages now show Album Art & extra metadata as Header with vibrant gradient background in a Sliver - Playback is now more consistent & the API is simpler. Also its the single @@ -1686,6 +1714,10 @@ in Desktop edition - Track match Cache support for previously played tracks. This dramatically reduces track change latency & load on the YouTube search engine too +- Home Genre playlists can be scrolled horizontally by dragging with mouse even + in Desktop edition +- Track match Cache support for previously played tracks. This dramatically + reduces track change latency & load on the YouTube search engine too ### Bug Fixes @@ -1693,9 +1725,9 @@ - Player doesn't stop when Application is exits or closed - First Track of Playlist doesn't load sometimes - Download Button doesn't show done symbol when track is already saved - (https://github.com/KRTirtho/spotube/issues/138) + (https://github.com/team-spotube/spotube/issues/138) - Downloaded Music is 0kb sized when lyrics are downloaded alongside - (https://github.com/KRTirtho/spotube/issues/122) + (https://github.com/team-spotube/spotube/issues/122) # v2.2.1 @@ -1717,7 +1749,7 @@ - Update checker - Share options for playlists & track - Android Skip to Next/Previous track from notification/lockscreen - (https://github.com/KRTirtho/spotube/issues/91) + (https://github.com/team-spotube/spotube/issues/91) - Custom Accent Color Scheme support (Dark + Light) - Custom Background Color Scheme support (Dark + Light) - User customizable Audio Quality Option @@ -1726,7 +1758,7 @@ - Caching in Playlists, Album, Search, Playlist Categories, Artist Profile & Lyrics - M1 Mac support via MacOS Universal Binary (untested) - (https://github.com/KRTirtho/spotube/pull/87) + (https://github.com/team-spotube/spotube/pull/87) ### Improved @@ -1737,7 +1769,7 @@ ### Bug fixes - private playlists of current user aren't shown fix - (https://github.com/KRTirtho/spotube/issues/92) + (https://github.com/team-spotube/spotube/issues/92) - refresh token error causing re-login (culprit: internal lib spotify-dart) - Typo in Login instructions URL @@ -1759,23 +1791,23 @@ - Download track. Checks if already exists & replaces on user command - Wide screen responsiveness & adaptation - Bigger Title display (replaced word-break with Marquee Text for better - visibility) (https://github.com/KRTirtho/spotube/pull/47) + visibility) (https://github.com/team-spotube/spotube/pull/47) ### Bug fixes - Sequential playlist playback not working with latest webkit2gtk - (https://github.com/KRTirtho/spotube/issues/46) + (https://github.com/team-spotube/spotube/issues/46) - Theme modification state doesn't persist - (https://github.com/KRTirtho/spotube/issues/54) + (https://github.com/team-spotube/spotube/issues/54) - Wrong URI path for "Login with Spotify" tutorial - (https://github.com/KRTirtho/spotube/issues/69) + (https://github.com/team-spotube/spotube/issues/69) - Card shadow showing in the background of TitleBar & Searchbar # v2.0.0 ### New -- Android Support https://github.com/KRTirtho/spotube/issues/24 +- Android Support https://github.com/team-spotube/spotube/issues/24 - Responsive UI (Mobile, Tablet) - Anonymous/Guest Account - Mini floating player @@ -1790,6 +1822,8 @@ - Search - now scrolls & paginates for Playlists & Albums - Authentication - allows guest accounts making authentication optional +- Lyrics - can be fetched without requiring GeniusAccessToken. This makes + geniusAccessToken optional - Lyrics - can be fetched without requiring GeniusAccessToken. This makes geniusAccessToken optional - UI snappiness & faster load times @@ -1798,7 +1832,7 @@ ### Bug fixes -- Can't play any song in macos https://github.com/KRTirtho/spotube/issues/23 +- Can't play any song in macos https://github.com/team-spotube/spotube/issues/23 - Downloaded tracks can't be played as they're WebAudio (.weba) instead of MP3 - delay while changing Playlist/Single tracks @@ -1806,6 +1840,8 @@ ### New +- Global custom reconfigurable _hotkey_ support for playback controls + (play-pause/next/previous) - Global custom reconfigurable _hotkey_ support for playback controls (play-pause/next/previous) - Credit section in the Settings page with important links @@ -1819,9 +1855,9 @@ ### Bug fixes - broken authentication IPC on Mac OS - (https://github.com/KRTirtho/spotube/pull/18) + (https://github.com/team-spotube/spotube/pull/18) - Mac OS's global appmenu's default APP_NAME replaced with Spotube -- location of back button on macOS (https://github.com/KRTirtho/spotube/pull/21) +- location of back button on macOS (https://github.com/team-spotube/spotube/pull/21) - windows titlebar buttons appears on Mac OS - genius access_token not loading on initial app start @@ -1829,7 +1865,7 @@ ### New -- MacOS support https://github.com/KRTirtho/spotube/pull/7 +- MacOS support https://github.com/team-spotube/spotube/pull/7 - Download currently playing track to `/home//Downloads/Spotube` (Linux, MacOS) or `C:\Users\\Downloads\Spotube` (Windows) - Play playlist from any song (index) instead of only the first track @@ -1853,6 +1889,10 @@ ### Bug fixes +- Unsafe access to album art/artist/user Images with `.first` or `.last` causing + accessing empty List error +- `url_launcher`'s unstable `canLaunch` method blocks OAuth login in certain + \*nix OSs - Unsafe access to album art/artist/user Images with `.first` or `.last` causing accessing empty List error - `url_launcher`'s unstable `canLaunch` method blocks OAuth login in certain @@ -1876,6 +1916,8 @@ - Complete re-write in Flutter/Dart (799e13c) - mpv & youtube-dl runtime dependencies dropped (07b1891) +- just_audio (libwinmedia + libwebkit2gtk-4.0-dev) + youtube_explode based + playback & streaming - just_audio (libwinmedia + libwebkit2gtk-4.0-dev) + youtube_explode based playback & streaming - lyrics are provided by genius.com (requires access_token) (d647d5e) @@ -1893,6 +1935,8 @@ ### New +- Automated installer for Windows (now doesn't require manual mpv-player + install) - Automated installer for Windows (now doesn't require manual mpv-player install) - Playback caching @@ -1902,11 +1946,15 @@ ### Improved +- Inapp Shortcuts.Now it doesn't interfere while typing in a input box in Search + page - Inapp Shortcuts.Now it doesn't interfere while typing in a input box in Search page ### Bug fixes +- Cached image didn't get deleted after exiting certain cache limit fix. Cache + gets recreated after exiting the limit - Cached image didn't get deleted after exiting certain cache limit fix. Cache gets recreated after exiting the limit @@ -1925,12 +1973,16 @@ ### Bug Fixes +- `access_token not found` Error after OAuth Login with Spotify credentials + (used to need a restart of the app to load the access_token) - `access_token not found` Error after OAuth Login with Spotify credentials (used to need a restart of the app to load the access_token) - Volume level wasn't cached even after changing volume # v0.0.1 +Spotube v0.0.1 - initial release of the open source software for playing Spotify +music using Youtube public API Spotube v0.0.1 - initial release of the open source software for playing Spotify music using Youtube public API @@ -1939,6 +1991,8 @@ music using Youtube public API - Local playback handling - Playback Queue - Save to Liked Tracks/Playlists +- Bypass API rate limitation on basic usage using personal developer Apps for + spotify API - Bypass API rate limitation on basic usage using personal developer Apps for spotify API - Youtube search & get handled using scrape-yt diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index db77a5f1..94f053dd 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -30,19 +30,19 @@ All types of contributions are encouraged and valued. See the [Table of Contents ## Code of Conduct This project and everyone participating in it is governed by the -[Spotube Code of Conduct](https://github.com/KRTirtho/spotube/blob/master/CODE_OF_CONDUCT.md). +[Spotube Code of Conduct](https://github.com/team-spotube/spotube/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior -to krtirtho@gmail.com. +to team.spotube@proton.me. ## I Have a Question -> If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/KRTirtho/spotube#readme). +> If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/team-spotube/spotube#readme). -Before you ask a question, it is best to search for existing [Issues](https://github.com/KRTirtho/spotube/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. +Before you ask a question, it is best to search for existing [Issues](https://github.com/team-spotube/spotube/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 [Discussion](https://github.com/KRTirtho/spotube/discussions/new) with the question label. +- Open an [Discussion](https://github.com/team-spotube/spotube/discussions/new) with the question label. - Provide as much context as you can about what you're running into. - Provide project and platform versions (flutter, dart, pub, linux etc..), depending on what seems relevant. @@ -61,8 +61,8 @@ We will then take care of the issue as soon as possible. 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://github.com/KRTirtho/spotube#readme). 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/KRTirtho/spotubeissues?q=label%3Abug). +- 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://github.com/team-spotube/spotube#readme). 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/team-spotube/spotube/issues?q=label%3Abug). - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. - Collect information about the bug: - Stack trace (Traceback) @@ -77,7 +77,7 @@ A good bug report shouldn't leave others needing to chase you up for more inform We use GitHub issues to track bugs and errors. If you run into an issue with the project: -- Open an [Issue](https://github.com/KRTirtho/spotube/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.) +- Open an [Issue](https://github.com/team-spotube/spotube/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.) - 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. @@ -97,15 +97,15 @@ This section guides you through submitting an enhancement suggestion for Spotube #### Before Submitting an Enhancement - Make sure that you are using the latest version. -- Read the [documentation](https://github.com/KRTirtho/spotube#readme) carefully and find out if the functionality is already covered, maybe by an individual configuration. -- Perform a [search](https://github.com/KRTirtho/spotube/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. +- Read the [documentation](https://github.com/team-spotube/spotube#readme) carefully and find out if the functionality is already covered, maybe by an individual configuration. +- Perform a [search](https://github.com/team-spotube/spotube/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 #### How Do I Submit a Good Enhancement Suggestion? -Enhancement suggestions are tracked as [GitHub issues](https://github.com/KRTirtho/spotube/issues). +Enhancement suggestions are tracked as [GitHub issues](https://github.com/team-spotube/spotube/issues). - 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. diff --git a/LICENSE b/LICENSE index 11aea461..0ad25db4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,12 +1,661 @@ -BSD-4-Clause License + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 -Copyright (c) 2025 Kingkor Roy Tirtho. All rights reserved. + 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. -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + Preamble -1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. -3. All advertising materials mentioning features or use of this software must display the following acknowledgement: - This product includes software developed by Kingkor Roy Tirtho. -4. Neither the name of the Software nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. - THIS SOFTWARE IS PROVIDED BY KINGKOR ROY TIRTHO AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL KINGKOR ROY TIRTHO AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + + 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. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + 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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + 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 AGPL, see +. diff --git a/Makefile b/Makefile deleted file mode 100644 index 49ae034a..00000000 --- a/Makefile +++ /dev/null @@ -1,58 +0,0 @@ -INNO_VERSION=6.2.0 -TEMP_DIR=/tmp/spotube-tar -USR_SHARE=deb-struct/usr/share -BUNDLE_DIR=build/linux/${ARCH}/release/bundle -MIRRORLIST=${PWD}/build/mirrorlist - -tar: - mkdir -p $(TEMP_DIR)\ - && cp -r $(BUNDLE_DIR)/* $(TEMP_DIR)\ - && cp linux/spotube.desktop $(TEMP_DIR)\ - && cp assets/branding/spotube-logo.png $(TEMP_DIR)\ - && cp linux/com.github.KRTirtho.Spotube.appdata.xml $(TEMP_DIR)\ - && tar -cJf build/spotube-linux-${VERSION}-${PKG_ARCH}.tar.xz -C $(TEMP_DIR) .\ - && rm -rf $(TEMP_DIR) - -aursrcinfo: - docker run -e EXPORT_SRC=1 -v ${PWD}/aur-struct:/pkg -v ${MIRRORLIST}:/etc/pacman.d/mirrorlist:ro whynothugo/makepkg - -publishaur: - echo '[Warning!]: you need SSH paired with AUR'\ - && rm -rf build/spotube\ - && git clone ssh://aur@aur.archlinux.org/spotube-bin.git build/spotube\ - && cp aur-struct/PKGBUILD aur-struct/.SRCINFO build/spotube\ - && cd build/spotube\ - && git add .\ - && git commit -m "${MSG}"\ - && git push - -innoinstall: - powershell curl -o build\installer.exe http://files.jrsoftware.org/is/6/innosetup-${INNO_VERSION}.exe - powershell git clone https://github.com/DomGries/InnoDependencyInstaller.git build\inno-depend - powershell build\installer.exe /verysilent /allusers /dir=build\iscc - -inno: - powershell .\build\iscc\iscc.exe scripts\windows-setup-creator.iss - -choco: - powershell cp dist\Spotube-windows-x86_64-setup.exe choco-struct\tools - powershell choco pack .\choco-struct\spotube.nuspec --outputdirectory dist - -apk: - mv build/app/outputs/apk/release/app-release.apk build/Spotube-android-all-arch.apk - -gensums: - sh -c scripts/gensums.sh - -migrate: - dart run drift_dev make-migrations - -dmg: - flutter build macos &&\ - if [ -f dist/Spotube-macos-universal.dmg ];\ - then rm dist/Spotube-macos-universal.dmg;\ - fi &&\ - appdmg appdmg.json dist/Spotube-macos-universal.dmg - -changelog: - git-cliff --unreleased \ No newline at end of file diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 274abdc8..e00abc7c 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -59,4 +59,4 @@ This policy is effective as of 2022-04-01 **Contact Us** -If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at krtirtho@gmail.com. \ No newline at end of file +If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at team.spotube@proton.me \ No newline at end of file diff --git a/README.md b/README.md index 1043fabc..86b305fc 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,7 @@ A cross-platform extensible open-source music streaming platform.
Bring your own music metadata/playlist/audio-source with plugins created by community or by yourself. A small step towards the decentralized music streaming era! -Btw it's not just another Electron app 😉 - -Visit the website +Visit the website Discord Server Support me on Patron @@ -37,298 +35,148 @@ Btw it's not just another Electron app 😉 - 📖 Open source/libre software. - 🔉 Playback control is done locally, not on the server. -## 📜 ⬇️ Installation guide +### ⬇️ Download -New versions usually release every 3-4 months.
-This handy table lists all the methods you can use to install Spotube: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PlatformPackage/Installation Method
Windows - - Windows Download - -
MacOS - - MacOS Download - -
Android - - APK download - -
- - Download from F-Droid - -
iOS - - Download iOS IPA - -
-
- *iPA file only. Requires sideloading with AltStore or similar tools. -
-
Flatpak -

flatpak install com.github.KRTirtho.Spotube

- - Download on Flathub - -
AppImageAppImage's lacking stability led to it's temporary removal. More information at https://github.com/KRTirtho/spotube/issues/1082
Debian/Ubuntu - - Debian/Ubuntu Download - -

Then run: sudo apt install ./Spotube-linux-x86_64.deb

-
Arch/Manjaro -

With pamac: sudo pamac install spotube-bin

-

With yay: yay -Sy spotube-bin

-
Fedora/OpenSuse - - Fedora/OpenSuse Download - -

For Fedora: sudo dnf install ./Spotube-linux-x86_64.rpm

-

For OpenSuse: sudo zypper in ./Spotube-linux-x86_64.rpm

-
Linux (tarball) - - Tarball Download - -
Macos - Homebrew -
-brew tap krtirtho/apps
-brew install --cask spotube
-
-
Windows - Chocolatey -

choco install spotube

-
Windows - Scoop -

scoop bucket add extras

-

scoop install spotube

-
Windows - WinGet -

winget install --id KRTirtho.Spotube

-
+Download Spotube from the Official Website: https://spotube.org ### 🔄 Nightly Builds -Grab the latest nightly builds of Spotube [from the GitHub Releases](https://github.com/KRTirtho/spotube/releases/tag/nightly). +Grab the latest nightly builds of Spotube [from the Offical Website](https://spotube.org/downloads/nightly). ## 🕳️ Building from source -GitHub Workflow Status +GitHub Workflow Status -You can compile Spotube's source code by [following these instructions](CONTRIBUTION.md#your-first-code-contribution). +Spotube is a Kotlin Multiplatform + Compose Multiplatform app. The desktop app is built with the [Nucleus](https://nucleusframework.dev) Gradle plugin and released as a **GraalVM native image**: an ahead-of-time compiled binary that needs no JVM installed. + +### Prerequisites + +- **JDK 21** to run Gradle. The Gradle daemon JDK and the GraalVM toolchain are provisioned automatically. +- **Rust** (`rustup` + `cargo`). The `composeApp` Rust library is built through [Gobley](https://gobley.dev). +- **Android SDK**, only for Android builds. +- **Unpublished dependency.** One dependency isn't on Maven Central yet. Clone it and run `./gradlew publishToMavenLocal` in it: + - [kdroidFilter/ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview), at the commit pinned in [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) (`COMPOSE_WEBVIEW_COMMIT`) +- **Linux runtime libraries** (needed to run the app, including `:composeApp:run`). The `.deb`/`.rpm` install these as dependencies from your distro. For a source build, install them yourself, **including VLC**. Only the `.AppImage` is self-contained (it bundles VLC, GTK 3 and WebKitGTK, and uses just the host's glibc, graphics, X11 and sound libraries): + + | Distro | Command | + |---|---| + | Debian/Ubuntu | `sudo apt-get install libvlc5 vlc-plugin-base libwebkit2gtk-4.1-0 libgtk-3-0 libasound2 libgl1 libxtst6 libxi6 libxrender1` | + | Fedora | `sudo dnf install vlc-libs webkit2gtk4.1 gtk3 alsa-lib mesa-libGL libXtst libXi libXrender`. `vlc-libs` is only in [RPM Fusion](https://rpmfusion.org/Configuration), so enable it first. | + | Arch / CachyOS / Manjaro | `sudo pacman -S vlc webkit2gtk-4.1 gtk3 alsa-lib libglvnd libxtst libxi libxrender` | + | openSUSE | `sudo zypper install libvlc5 libwebkit2gtk-4_1-0 libgtk-3-0 libasound2 Mesa-libGL1 libXtst6 libXi6 libXrender1` (for wide codec support, switch to the [Packman](https://en.opensuse.org/Additional_package_repositories#Packman) repo's VLC) | + + Arch-based distros and openSUSE have no native package here. Use the `.AppImage`. The `.rpm` is built with Fedora package names, so it won't resolve on openSUSE. + + On Ubuntu 24.04+/Debian 13 `libgtk-3-0` and `libasound2` are named `libgtk-3-0t64` and `libasound2t64`; apt resolves this automatically. + +Always use the Gradle wrapper (`./gradlew`, or `gradlew.bat` on Windows). + +### Development + +```bash +./gradlew :composeApp:run # desktop (JVM), fastest edit/run loop +./gradlew :composeApp:assembleStableDebug # Android debug APK (or assembleNightlyDebug) +``` + +Development builds stay fast. The release-only optimizations (the Rust release profile with LTO and symbol stripping, the native image, native-library pruning) are only applied when you build something to ship. See [Production vs development builds](#production-vs-development-builds). + +### Building the desktop app (GraalVM native image) + +#### Native toolchain + +[Nucleus downloads and caches GraalVM CE](https://nucleusframework.dev/en/docs/performance/graalvm/) under `~/.gradle/nucleus/graalvm` on the first native build. Set `GRAALVM_HOME` to use your own GraalVM instead. Each OS also needs a native C toolchain: + +| OS | Requirements | +|---|---| +| Linux | `gcc`, `zlib` headers, `patchelf`; plus `rpm` for `.rpm` packages, `fuse` for `.AppImage`, and, for the AppImage's bundled libraries, `zstd` (plus `ar`/`tar` and `readelf`, from binutils). Debian/Ubuntu: `sudo apt-get install build-essential zlib1g-dev patchelf rpm fuse zstd`. Fedora: `sudo dnf install gcc zlib-devel patchelf rpm-build fuse zstd` (also install the [Linux runtime libraries](#prerequisites) to run what you build) | +| macOS | Xcode Command Line Tools (`xcode-select --install`) | +| Windows | Visual Studio Build Tools with the "Desktop development with C++" workload. Build from an "x64 Native Tools" prompt if `native-image` can't find MSVC. | + +A native build is a whole-program compile. It takes **about 10 minutes**, uses **every CPU core**, and peaks at roughly **15 GB of RAM** (it gets slower, not broken, with less). That only happens for the native tasks below, never for `run`. + +#### Build and run + +```bash +# App folder: binary + its native libs, in composeApp/build/compose/binaries/main/graalvm-app/ +./gradlew :composeApp:createGraalvmNativeDistributable + +# Run that folder's binary directly +./composeApp/build/compose/binaries/main/graalvm-app/org.spotube.spotube/spotube + +# Or build and launch in one step +./gradlew :composeApp:runGraalvmNativeDistributable +``` + +Keep the `spotube` binary next to the `.so`/`.dll`/`lib/` files it was built with; it loads them from its own folder. + +#### Installers + +Each OS can only build its own installers: + +| OS | Command | Output | +|---|---|---| +| Linux | `./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm`, then `./gradlew :composeApp:packageGraalvmAppImage -PappImage=true` | `composeApp/build/compose/binaries/main/graalvm-{deb,rpm,appimage}/` | +| Windows | `./gradlew :composeApp:packageGraalvmExe` (or `packageGraalvmMsi`) | `composeApp/build/compose/binaries/main/graalvm-exe/` | +| macOS | `./gradlew :composeApp:packageGraalvmDmg` | `composeApp/build/compose/binaries/main/graalvm-dmg/` | + +Add `-Pnightly=true` for the nightly channel (separate icons and name). + +The `.deb` and `.rpm` use your distro's shared libraries and declare them as dependencies, so the package manager installs them (on Fedora, `vlc-libs` needs RPM Fusion enabled). The `.AppImage` is the self-contained one: it bundles VLC, GTK 3 and WebKitGTK (see [What the AppImage bundles](#what-the-appimage-bundles)). + +#### What the AppImage bundles + +Build it with `./gradlew :composeApp:packageGraalvmAppImage -PappImage=true`. Without `-PappImage=true` nothing is bundled and the app uses the system libraries, which is what the `.deb`/`.rpm` and `:composeApp:run` do. + +The bundle follows the [AppImage exclude list](https://github.com/AppImageCommunity/pkg2appimage/blob/master/excludelist) and what Tauri apps ship. It carries VLC (audio-only, from the [vlcj-bundler](https://github.com/team-spotube/vlcj-bundler-gradle-plugin) plugin's `bundleVlc` option), GTK 3, WebKitGTK 4.1, GLib/GIO, pango/cairo and their dependencies, plus glib-networking for HTTPS in the webview. It leaves the host's glibc, `libstdc++`, `libz`, GL/EGL/DRM, X11/xcb, ALSA, fontconfig/freetype/harfbuzz and the Wayland libraries alone, so the host's graphics, display and sound stack is used. Everything is taken from the Ubuntu 24.04 archive, so the AppImage needs glibc 2.38 or newer. + +How it works, in short: +- [`DownloadLinuxGtkBundleTask`](buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt) downloads the packages, keeps the libraries reachable from GTK/WebKit and relocates them with `patchelf`. Needs `ar`, `tar`, `zstd`, `readelf` and `patchelf`. +- WebKitGTK has its helper-process path compiled in, so the library is patched to look relative to the working directory, and at startup the app moves there ([`AppImageEnvironment`](composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt)). It also points `GIO_EXTRA_MODULES` at the bundled TLS module and sets `WEBKIT_DISABLE_DMABUF_RENDERER=1`, because the bundled WebKitGTK 2.44's dmabuf renderer never paints (or aborts) against a newer host Mesa/compositor. The display backend is left alone: forcing X11 doesn't help, since the webview doesn't paint on XWayland either. +- Startup order matters: `main()` calls `GraalVmInitializer.initialize()` first, since the VLC loader and the environment setup read `compose.application.resources.dir`. + +#### Reflection metadata (when a feature breaks only in the native build) + +A native image is a closed world: anything the app reaches through reflection, JNI or JNA (vlcj, the Zipline/QuickJS plugin engine, NewPipe, ktor, datastore, …) must be declared at build time. Nucleus generates most of this automatically. The rest is in [`composeApp/graalvm/reachability-metadata.json`](composeApp/graalvm/reachability-metadata.json). + +If something works in `:composeApp:run` but throws `ClassNotFoundException`, `NoSuchMethodException`, `MissingReflectionRegistrationError` or similar in the native binary, record what's missing: + +```bash +./gradlew :composeApp:runWithNativeAgent +``` + +The app starts on the JVM with GraalVM's tracing agent attached. Use the broken feature (and anything near it), then **close the window normally**: the recorded entries are merged into `composeApp/graalvm/` only on a clean exit, not with Ctrl+C. Commit the updated JSON and rebuild the native image. + +`./gradlew :composeApp:runGraalvmNative` gives a faster debugging loop: a quick-build (`-Ob`) native image that reports exactly which registration is missing. + +Keep the hand-written `uk.co.caprica.vlcj.binding.lib.LibVlc` entry. JNA's direct mapping looks up its `native` methods through reflection, and without the entry, playback fails to initialize. + +### Production vs development builds + +Production mode is switched on automatically when you ask Gradle for a shippable artifact: any `package*`, `create*`, `notarize*` or `nativeImageCompile` task. You can force it either way with `-Pproduction=true` or `-Pproduction=false`. + +| | Development (`run`, debug builds) | Production (installers, native image) | +|---|---|---| +| Rust library (desktop) | `debug` profile, fast incremental compile | `release` profile with LTO, single codegen unit, stripped (31 MB → 1.8 MB) | +| Desktop app | JVM, hot-reloadable | GraalVM native image, `-Os` (size-optimized) | +| Native libraries | everything on the classpath | only the host OS/CPU's natives; other OSes' and CPUs' libraries are pruned from the uber JAR the image is compiled from | +| Skia (Skiko) | extracted from the JAR | loaded from `lib/` next to the binary, not embedded a second time | +| JDK runtime libs (Linux) | n/a | native-image's ~10 KB `libjvm.so`/`libjava.so` shims instead of the JDK's (28 MB `libjvm.so`) | + +Android picks the Rust profile from its build type (`debug` or `release`) on its own. + +### Releases + +Releases are built by [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml), run manually (`workflow_dispatch`) as `stable` or `nightly`. Each desktop platform (Linux x86_64 and aarch64, Windows, macOS) builds its native installers on its own runner. Android builds a signed APK. ## 👥 The Spotube team -- [Kingkor Roy Tirtho](https://github.com/KRTirtho) - The Founder, Maintainer and Lead Developer +- [Kingkor Roy Tirtho](https://github.com/team-spotube) - The Founder, Maintainer and Lead Developer - [Owen Connor](https://github.com/owencz1998) - The Cool Discord Moderator - [Piotr Rogowski](https://github.com/karniv00l) - The MacOS Developer - [Rusty Apple](https://github.com/RustyApple) - The Mysterious Unknown Guy ## 💼 License -Spotube is open source and licensed under the [BSD-4-Clause](/LICENSE) License. +Spotube is open source and licensed under the [AGPL-3.0-or-later](/LICENSE) License. -If you are curious, you can [read the reason of choosing this license](https://dev.to/krtirtho/choosing-open-source-license-wisely-1m3p). - -
- -

[Click to show] 🙏 Services/Package/Plugin Credits

-
- -### Services - -1. [Flutter](https://flutter.dev) - Flutter transforms the app development process. Build, test, and deploy beautiful mobile, web, desktop, and embedded apps from a single codebase -1. [MPV](https://mpv.io) - mpv is a free (as in freedom) media player for the command line. It supports a wide variety of media file formats, audio and video codecs, and subtitle types. -1. [Musicbrainz](https://musicbrainz.org) - MusicBrainz is a MetaBrainz project that aims to create a collaborative music database that is similar to the freedb project. -1. [Listenbrainz](https://listenbrainz.org) - ListenBrainz is a open-source project by the MetaBrainz Foundation that allows users to crowdsource and publicly store their digital music listening data. -1. [Piped](https://piped-docs.kavin.rocks/) - Piped is a privacy friendly alternative YouTube frontend, which is efficient and scalable by design. -1. [Invidious](https://invidious.io/) - Invidious is an open source alternative front-end to YouTube. -1. [yt-dlp](https://github.com/yt-dlp/yt-dlp) - A feature-rich command-line audio/video downloader. -1. [NewPipeExtractor](https://github.com/TeamNewPipe/NewPipeExtractor) - NewPipe's core library for extracting data from streaming sites. -1. [YouTubeExplodeDart](https://github.com/Hexer10/youtube_explode_dart) - A port in dart of the youtube explode library. Supports several API functions without the need of Youtube API Key. -1. [LRCLib](https://lrclib.net/) - A public synced lyric API. -1. [Linux](https://www.linux.org) - Linux is a family of open-source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991, by Linus Torvalds. Linux is typically packaged in a Linux distribution -1. [AUR](https://aur.archlinux.org) - AUR stands for Arch User Repository. It is a community-driven repository for Arch-based Linux distributions users -1. [Flatpak](https://flatpak.org) - Flatpak is a utility for software deployment and package management for Linux -1. [SponsorBlock](https://sponsor.ajay.app) - SponsorBlock is an open-source crowdsourced browser extension and open API for skipping sponsor segments in YouTube videos. -1. [Inno Setup](https://jrsoftware.org/isinfo.php) - Inno Setup is a free installer for Windows programs by Jordan Russell and Martijn Laan -1. [F-Droid](https://f-droid.org) - F-Droid is an installable catalogue of FOSS (Free and Open Source Software) applications for the Android platform. The client makes it easy to browse, install, and keep track of updates on your device -1. [LastFM](https://last.fm) - Last.fm is a music streaming and discovery platform that helps users discover and share new music. It tracks users' music listening habits across many devices and platforms. - -### Dependencies - -1. [app_links](https://github.com/llfbandit/app_links) - Android App Links, Deep Links, iOs Universal Links and Custom URL schemes handler for Flutter (desktop included). -1. [args](https://pub.dev/packages/args) - Library for defining parsers for parsing raw command-line arguments into a set of options and values using GNU and POSIX style options. -1. [audio_service](https://pub.dev/packages/audio_service) - Flutter plugin to play audio in the background while the screen is off. -1. [audio_service_mpris](https://github.com/bdrazhzhov/audio-service-mpris) - audio_service platform interface supporting Media Player Remote Interfacing Specification. -1. [audio_session](https://github.com/ryanheise/audio_session) - Sets the iOS audio session category and Android audio attributes for your app, and manages your app's audio focus, mixing and ducking behaviour. -1. [auto_route](https://github.com/Milad-Akarie/auto_route_library) - AutoRoute is a declarative routing solution, where everything needed for navigation is automatically generated for you. -1. [auto_size_text](https://github.com/leisim/auto_size_text) - Flutter widget that automatically resizes text to fit perfectly within its bounds. -1. [bonsoir](https://bonsoir.skyost.eu) - A Zeroconf library that allows you to discover network services and to broadcast your own. Based on Apple Bonjour and Android NSD. -1. [cached_network_image](https://github.com/Baseflow/flutter_cached_network_image) - Flutter library to load and cache network images. Can also be used with placeholder and error widgets. -1. [connectivity_plus](https://github.com/fluttercommunity/plus_plugins) - Flutter plugin for discovering the state of the network (WiFi & mobile/cellular) connectivity on Android and iOS. -1. [device_info_plus](https://github.com/fluttercommunity/plus_plugins) - Flutter plugin providing detailed information about the device (make, model, etc.), and Android or iOS version the app is running on. -1. [dio](https://github.com/cfug/dio) - A powerful HTTP networking package,supports Interceptors,Aborting and canceling a request,Custom adapters, Transformers, etc. -1. [drift](https://drift.simonbinder.eu/) - Drift is a reactive library to store relational data in Dart and Flutter applications. -1. [duration](https://github.com/desktop-dart/duration) - Utilities to make working with 'Duration's easier. Formats duration in human readable form and also parses duration in human readable form to Dart's Duration. -1. [encrypt](https://pub.dev/packages/encrypt) - A set of high-level APIs over PointyCastle for two-way cryptography. -1. [envied](https://github.com/petercinibulk/envied) - Explicitly reads environment variables into a dart file from a .env file for more security and faster start up times. -1. [file_picker](https://github.com/miguelpruivo/plugins_flutter_file_picker) - A package that allows you to use a native file explorer to pick single or multiple absolute file paths, with extension filtering support. -1. [file_selector](https://pub.dev/packages/file_selector) - Flutter plugin for opening and saving files, or selecting directories, using native file selection UI. -1. [fluentui_system_icons](https://github.com/microsoft/fluentui-system-icons/tree/main) - Fluent UI System Icons are a collection of familiar, friendly and modern icons from Microsoft. -1. [flutter_cache_manager](https://github.com/Baseflow/flutter_cache_manager/tree/develop/flutter_cache_manager) - Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite. -1. [flutter_discord_rpc](https://pub.dev/packages/flutter_discord_rpc) - Discord RPC support for Flutter desktop platforms -1. [flutter_displaymode](https://github.com/ajinasokan/flutter_displaymode) - A Flutter plugin to set display mode (resolution, refresh rate) on Android platform. Allows to enable high refresh rate on supported devices. -1. [flutter_feather_icons](https://github.com/muj-programmer/flutter_feather_icons) - Feather is a collection of simply beautiful open source icons. Each icon is designed on a 24x24 grid with an emphasis on simplicity, consistency and usability. -1. [flutter_form_builder](https://github.com/flutter-form-builder-ecosystem) - This package helps in creation of forms in Flutter by removing the boilerplate code, reusing validation, react to changes, and collect final user input. -1. [flutter_hooks](https://github.com/rrousselGit/flutter_hooks) - A flutter implementation of React hooks. It adds a new kind of widget with enhanced code reuse. -1. [flutter_inappwebview](https://inappwebview.dev/) - A Flutter plugin that allows you to add an inline webview, to use an headless webview, and to open an in-app browser window. -1. [flutter_native_splash](https://pub.dev/packages/flutter_native_splash) - Customize Flutter's default white native splash screen with background color and splash image. Supports dark mode, full screen, and more. -1. [flutter_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze. -1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app. -1. [flutter_undraw](https://github.com/KRTirtho/flutter_undraw) - Undraw.co Illustrations for Flutter with customization options -1. [form_builder_validators](https://github.com/flutter-form-builder-ecosystem) - Form Builder Validators set of validators for FlutterFormBuilder. Provides common validators and a way to make your own. -1. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too. -1. [fuzzywuzzy](https://github.com/sphericalkat/dart-fuzzywuzzy) - An implementation of the popular fuzzywuzzy package in Dart, to suit all your fuzzy string matching/searching needs! -1. [home_widget](https://pub.dev/packages/home_widget) - A plugin to provide a common interface for creating HomeScreen Widgets for Android and iOS. -1. [hooks_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze. -1. [html](https://pub.dev/packages/html) - APIs for parsing and manipulating HTML content outside the browser. -1. [html_unescape](https://github.com/filiph/html_unescape) - A small library for un-escaping HTML. Supports all Named Character References, Decimal Character References and Hexadecimal Character References. -1. [http](https://pub.dev/packages/http) - A composable, multi-platform, Future-based API for HTTP requests. -1. [image_picker](https://pub.dev/packages/image_picker) - Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. -1. [intl](https://pub.dev/packages/intl) - Contains code to deal with internationalized/localized messages, date and number formatting and parsing, bi-directional text, and other internationalization issues. -1. [local_notifier](https://github.com/leanflutter/local_notifier) - This plugin allows Flutter desktop apps to displaying local notifications. -1. [logger](https://pub.dev/packages/logger) - Small, easy to use and extensible logger which prints beautiful logs. -1. [logging](https://pub.dev/packages/logging) - Provides APIs for debugging and error logging, similar to loggers in other languages, such as the Closure JS Logger and java.util.logging.Logger. -1. [lrc](https://pub.dev/packages/lrc) - A Dart-only package that creates, parses, and handles LRC, which is a format that stores song lyrics. -1. [metadata_god](https://pub.dev/packages/metadata_god) - Plugin for retrieving and writing audio tags/metadata from audio files -1. [mime](https://pub.dev/packages/mime) - Utilities for handling media (MIME) types, including determining a type from a file extension and file contents. -1. [open_file](https://pub.dev/packages/open_file) - A plug-in that can call native APP to open files with string result in flutter, support iOS(UTI) / android(intent) / PC(ffi) / web(dart:html) -1. [package_info_plus](https://github.com/fluttercommunity/plus_plugins) - Flutter plugin for querying information about the application package, such as CFBundleVersion on iOS or versionCode on Android. -1. [palette_generator](https://pub.dev/packages/palette_generator) - Flutter package for generating palette colors from a source image. -1. [path](https://pub.dev/packages/path) - A string-based path manipulation library. All of the path operations you know and love, with solid support for Windows, POSIX (Linux and Mac OS X), and the web. -1. [path_provider](https://pub.dev/packages/path_provider) - Flutter plugin for getting commonly used locations on host platform file systems, such as the temp and app data directories. -1. [permission_handler](https://pub.dev/packages/permission_handler) - Permission plugin for Flutter. This plugin provides a cross-platform (iOS, Android) API to request and check permissions. -1. [riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze. -1. [scroll_to_index](https://github.com/quire-io/scroll-to-index) - Scroll to a specific child of any scrollable widget in Flutter -1. [shadcn_flutter](https://github.com/sunarya-thito/shadcn_flutter) - Beautifully designed components from Shadcn/UI is now available for Flutter -1. [shared_preferences](https://pub.dev/packages/shared_preferences) - Flutter plugin for reading and writing simple key-value pairs. Wraps NSUserDefaults on iOS and SharedPreferences on Android. -1. [shelf](https://pub.dev/packages/shelf) - A model for web server middleware that encourages composition and easy reuse. -1. [shelf_router](https://pub.dev/packages/shelf_router) - A convenient request router for the shelf web-framework, with support for URL-parameters, nested routers and routers generated from source annotations. -1. [shelf_web_socket](https://pub.dev/packages/shelf_web_socket) - A shelf handler that wires up a listener for every connection. -1. [simple_icons](https://teavelopment.com/) - The Simple Icon pack available as Flutter Icons. Provides over 1500 Free SVG icons for popular brands. -1. [skeletonizer](https://github.com/Milad-Akarie/skeletonizer) - Converts already built widgets into skeleton loaders with no extra effort. -1. [sliding_up_panel](https://github.com/akshathjain/sliding_up_panel) - A draggable Flutter widget that makes implementing a SlidingUpPanel much easier! -1. [sliver_tools](https://github.com/Kavantix) - A set of useful sliver tools that are missing from the flutter framework -1. [smtc_windows](https://pub.dev/packages/smtc_windows) - Windows `SystemMediaTransportControls` implementation for Flutter giving access to Windows OS Media Control applet. -1. [sqlite3](https://github.com/simolus3/sqlite3.dart/tree/main/sqlite3) - Provides lightweight yet convenient bindings to SQLite by using dart:ffi -1. [sqlite3_flutter_libs](https://github.com/simolus3/sqlite3.dart/tree/main/sqlite3_flutter_libs) - Flutter plugin to include native sqlite3 libraries with your app -1. [timezone](https://pub.dev/packages/timezone) - Time zone database and time zone aware DateTime. -1. [titlebar_buttons](https://github.com/gtk-flutter/titlebar_buttons) - A package which provides most of the titlebar buttons from windows, linux and macos. -1. [tray_manager](https://github.com/leanflutter/tray_manager) - This plugin allows Flutter desktop apps to defines system tray. -1. [url_launcher](https://pub.dev/packages/url_launcher) - Flutter plugin for launching a URL. Supports web, phone, SMS, and email schemes. -1. [uuid](https://pub.dev/packages/uuid) - RFC4122 (v1, v4, v5, v6, v7, v8) UUID Generator and Parser for Dart -1. [version](https://github.com/dartninja/version) - Provides a simple class for parsing and comparing semantic versions as defined by http://semver.org/ -1. [very_good_infinite_list](https://github.com/VeryGoodOpenSource/very_good_infinite_list) - A library for easily displaying paginated data, created by Very Good Ventures. Great for activity feeds, news feeds, and more. -1. [visibility_detector](https://pub.dev/packages/visibility_detector) - A widget that detects the visibility of its child and notifies a callback. -1. [web_socket_channel](https://pub.dev/packages/web_socket_channel) - StreamChannel wrappers for WebSockets. Provides a cross-platform WebSocketChannel API, a cross-platform implementation of that API that communicates over an underlying StreamChannel. -1. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter -1. [win32_registry](https://pub.dev/packages/win32_registry) - A package that provides a friendly Dart API for accessing the Windows Registry. -1. [window_manager](https://leanflutter.dev) - This plugin allows Flutter desktop apps to resizing and repositioning the window. -1. [youtube_explode_dart](https://github.com/Hexer10/youtube_explode_dart) - A port in dart of the youtube explode library. Supports several API functions without the need of Youtube API Key. -1. [http_parser](https://pub.dev/packages/http_parser) - A platform-independent package for parsing and serializing HTTP formats. -1. [collection](https://pub.dev/packages/collection) - Collections and utilities functions and classes related to collections. -1. [archive](https://pub.dev/packages/archive) - Provides encoders and decoders for various archive and compression formats such as zip, tar, bzip2, gzip, and zlib. -1. [hetu_script](https://github.com/hetu-script/hetu-script) - Hetu is a lightweight scripting language for embedding in Flutter apps. -1. [get_it](https://github.com/flutter-it/get_it) - Simple direct Service Locator that allows to decouple the interface from a concrete implementation and to access the concrete implementation from everywhere in your App" -1. [flutter_markdown_plus](https://pub.dev/packages/flutter_markdown_plus) - A Markdown renderer for Flutter. Create rich text output, including text styles, tables, links, and more, from plain text data formatted with simple Markdown tags. -1. [pub_semver](https://pub.dev/packages/pub_semver) - Versions and version constraints implementing pub's versioning policy. This is very similar to vanilla semver, with a few corner cases. -1. [change_case](https://github.com/mrgnhnt96/change_case) - An extension on String for the missing methods for camelCase, PascalCase, Capital Case, snake_case, param-case, CONSTANT_CASE and others. -1. [flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage) - Flutter Secure Storage provides API to store data in secure storage. Keychain is used in iOS, KeyStore based solution is used in Android. -1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation. -1. [envied_generator](https://github.com/petercinibulk/envied) - Generator for the Envied package. See https://pub.dev/packages/envied. -1. [flutter_gen_runner](https://github.com/FlutterGen/flutter_gen) - The Flutter code generator for your assets, fonts, colors, … — Get rid of all String-based APIs. -1. [flutter_launcher_icons](https://github.com/fluttercommunity/flutter_launcher_icons) - A package which simplifies the task of updating your Flutter app's launcher icon. -1. [flutter_lints](https://pub.dev/packages/flutter_lints) - Recommended lints for Flutter apps, packages, and plugins to encourage good coding practices. -1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes. -1. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features. -1. [process_run](https://github.com/tekartik/process_run.dart/blob/master/packages/process_run) - Process run helpers for Linux/Win/Mac and which like feature for finding executables. -1. [pubspec_parse](https://pub.dev/packages/pubspec_parse) - Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting. -1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information. -1. [io](https://pub.dev/packages/io) - Utilities for the Dart VM Runtime including support for ANSI colors, file copying, and standard exit code values. -1. [drift_dev](https://drift.simonbinder.eu/) - Dev-dependency for users of drift. Contains the generator and development tools. -1. [test](https://pub.dev/packages/test) - A full featured library for writing and running Dart tests across platforms. -1. [auto_route_generator](https://github.com/Milad-Akarie/auto_route_library) - AutoRoute is a declarative routing solution, where everything needed for navigation is automatically generated for you. -1. [desktop_webview_window](https://github.com/MixinNetwork/flutter-plugins/tree/main/packages/desktop_webview_window) - Show a webview window on your flutter desktop application. -1. [disable_battery_optimization](https://github.com/pvsvamsi/Disable-Battery-Optimizations) - Flutter plugin to check and disable battery optimizations. Also shows custom steps to disable the optimizations in devices like mi, xiaomi, samsung, oppo, huawei, oneplus etc -1. [draggable_scrollbar](https://github.com/fluttercommunity/flutter-draggable-scrollbar) - A scrollbar that can be dragged for quickly navigation through a vertical list. Additional option is showing label next to scrollthumb with information about current item. -1. [flutter_broadcasts](https://github.com/KRTirtho/flutter_broadcasts.git) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications. -1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark. -1. [yt_dlp_dart](https://github.com/KRTirtho/yt_dlp_dart.git) - A starting point for Dart libraries or applications. -1. [flutter_new_pipe_extractor](https://github.com/KRTirtho/flutter_new_pipe_extractor) - NewPipeExtractor binding for Flutter (Android only) -1. [hetu_std](https://github.com/hetu-community/hetu_std.git) - A sample command-line application. -1. [hetu_otp_util](https://github.com/hetu-community/hetu_otp_util.git) - A sample command-line application. -1. [hetu_spotube_plugin](https://github.com/KRTirtho/hetu_spotube_plugin) - A new Flutter package project. -1. [media_kit](https://github.com/media-kit/media-kit) - A cross-platform video player & audio player for Flutter & Dart. Performant, stable, feature-proof & modular. -1. [media_kit_libs_audio](https://github.com/media-kit/media-kit.git) - package:media_kit audio (only) playback native libraries for all platforms. - -
- -

© Copyright Spotube 2025

+

© Copyright Spotube 2026

diff --git a/analysis_options.yaml b/analysis_options.yaml deleted file mode 100644 index af222653..00000000 --- a/analysis_options.yaml +++ /dev/null @@ -1,40 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - file_names: false - avoid_renaming_method_parameters: false - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options -analyzer: - errors: - invalid_annotation_target: ignore - exclude: - - "**.freezed.dart" - - "**.g.dart" - - "**.gr.dart" - - "**/generated_plugin_registrant.dart" - - test/**/*.dart diff --git a/android/.gitignore b/android/.gitignore deleted file mode 100644 index 2391a77e..00000000 --- a/android/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java - -# Remember to never publicly share your keystore. -# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app -key.properties -**/*.keystore -**/*.jks -.kotlin \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle deleted file mode 100644 index 7319c6a8..00000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,140 +0,0 @@ -plugins { - id "com.android.application" - id "kotlin-android" - id "dev.flutter.flutter-gradle-plugin" - id "org.jetbrains.kotlin.plugin.compose" -} - -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -def keystoreProperties = new Properties() -def keystorePropertiesFile = rootProject.file('key.properties') -if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) -} - -def composeVersion = "1.4.8" - -android { - namespace "oss.krtirtho.spotube" - - compileSdkVersion 36 - - ndkVersion = "29.0.14206865" - - compileOptions { - coreLibraryDesugaringEnabled true - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - buildFeatures { - compose true - } - - composeOptions { - kotlinCompilerExtensionVersion "$composeVersion" // Correlates with org.jetbrains.kotlin.android plugin in settings.gradle - } - - defaultConfig { - applicationId "oss.krtirtho.spotube" - minSdkVersion 24 - targetSdkVersion 35 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - multiDexEnabled true - } - - signingConfigs { - release { - keyAlias keystoreProperties['keyAlias'] - keyPassword keystoreProperties['keyPassword'] - storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null - storePassword keystoreProperties['storePassword'] - } - } - - buildTypes { - release { - signingConfig signingConfigs.release - } - debug { - signingConfig signingConfigs.release - } - } - - flavorDimensions "default" - - productFlavors { - nightly { - dimension "default" - resValue "string", "app_name_en", "Spotube Nightly" - applicationIdSuffix ".nightly" - versionNameSuffix "-nightly" - signingConfig signingConfigs.release - } - dev { - dimension "default" - resValue "string", "app_name_en", "Spotube Dev" - applicationIdSuffix ".dev" - versionNameSuffix "-dev" - signingConfig signingConfigs.release - } - stable { - dimension "default" - resValue "string", "app_name_en", "Spotube" - signingConfig signingConfigs.release - } - } - - packagingOptions { - resources.excludes += "DebugProbesKt.bin" - } -} - -flutter { - source '../..' -} - -def glanceVersion = "1.1.1" -dependencies { - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' - - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' - // other deps so just ignore - implementation 'com.android.support:multidex:2.0.1' - - implementation "androidx.glance:glance-appwidget:$glanceVersion" - implementation "androidx.glance:glance-appwidget-preview:$glanceVersion" - implementation "androidx.glance:glance-preview:$glanceVersion" - implementation "androidx.glance:glance-material3:$glanceVersion" - implementation "androidx.glance:glance-material:$glanceVersion" - implementation "androidx.work:work-runtime-ktx:2.8.1" - - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3" - implementation 'com.google.code.gson:gson:2.11.0' -} \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro deleted file mode 100644 index bc4b8b8b..00000000 --- a/android/app/proguard-rules.pro +++ /dev/null @@ -1,60 +0,0 @@ -#Flutter Wrapper -# -keep class io.flutter.app.** { *; } --keep class io.flutter.plugin.** { *; } --keep class io.flutter.util.** { *; } --keep class io.flutter.view.** { *; } -# -keep class io.flutter.** { *; } --keep class io.flutter.plugins.** { *; } --keep class de.prosiebensat1digital.** { *; } - --keep class androidx.lifecycle.DefaultLifecycleObserver - --keepnames class kotlinx.serialization.** { *; } --keepnames class oss.krtirtho.spotube.glance.models.** { *; } --keep @kotlinx.serialization.Serializable class * --keepclassmembers class ** { - @kotlinx.serialization.* ; -} - -## We don't need beans --dontwarn java.beans.BeanDescriptor --dontwarn java.beans.BeanInfo --dontwarn java.beans.IntrospectionException --dontwarn java.beans.Introspector --dontwarn java.beans.PropertyDescriptor - -## Rules for NewPipeExtractor --keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; } --keep class org.mozilla.javascript.** { *; } --keep class org.mozilla.classfile.ClassFileWriter --dontwarn com.google.re2j.** --dontwarn org.mozilla.javascript.tools.** - --dontwarn javax.script.AbstractScriptEngine --dontwarn javax.script.Bindings --dontwarn javax.script.Compilable --dontwarn javax.script.CompiledScript --dontwarn javax.script.Invocable --dontwarn javax.script.ScriptContext --dontwarn javax.script.ScriptEngine --dontwarn javax.script.ScriptEngineFactory --dontwarn javax.script.ScriptException --dontwarn javax.script.SimpleBindings --dontwarn jdk.dynalink.CallSiteDescriptor --dontwarn jdk.dynalink.DynamicLinker --dontwarn jdk.dynalink.DynamicLinkerFactory --dontwarn jdk.dynalink.NamedOperation --dontwarn jdk.dynalink.Namespace --dontwarn jdk.dynalink.NamespaceOperation --dontwarn jdk.dynalink.Operation --dontwarn jdk.dynalink.RelinkableCallSite --dontwarn jdk.dynalink.StandardNamespace --dontwarn jdk.dynalink.StandardOperation --dontwarn jdk.dynalink.linker.GuardedInvocation --dontwarn jdk.dynalink.linker.GuardingDynamicLinker --dontwarn jdk.dynalink.linker.LinkRequest --dontwarn jdk.dynalink.linker.LinkerServices --dontwarn jdk.dynalink.linker.TypeBasedGuardingDynamicLinker --dontwarn jdk.dynalink.linker.support.CompositeTypeBasedGuardingDynamicLinker --dontwarn jdk.dynalink.linker.support.Guards --dontwarn jdk.dynalink.support.ChainedCallSite \ No newline at end of file diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 400c91e8..00000000 --- a/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index a005257e..00000000 --- a/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,135 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java b/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java deleted file mode 100644 index 752fc185..00000000 --- a/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java +++ /dev/null @@ -1,25 +0,0 @@ -// Generated file. -// -// If you wish to remove Flutter's multidex support, delete this entire file. -// -// Modifications to this file should be done in a copy under a different name -// as this file may be regenerated. - -package io.flutter.app; - -import android.app.Application; -import android.content.Context; -import androidx.annotation.CallSuper; -import androidx.multidex.MultiDex; - -/** - * Extension of {@link android.app.Application}, adding multidex support. - */ -public class FlutterMultiDexApplication extends Application { - @Override - @CallSuper - protected void attachBaseContext(Context base) { - super.attachBaseContext(base); - MultiDex.install(this); - } -} diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/MainActivity.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/MainActivity.kt deleted file mode 100644 index 90debb71..00000000 --- a/android/app/src/main/kotlin/oss/krtirtho/spotube/MainActivity.kt +++ /dev/null @@ -1,6 +0,0 @@ -package oss.krtirtho.spotube - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity: FlutterActivity() { -} diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt deleted file mode 100644 index a20af959..00000000 --- a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt +++ /dev/null @@ -1,207 +0,0 @@ -package oss.krtirtho.spotube.glance - -import HomeWidgetGlanceState -import HomeWidgetGlanceStateDefinition -import android.R -import android.content.Context -import android.graphics.drawable.Icon -import android.net.Uri -import android.util.Log -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import androidx.glance.GlanceId -import androidx.glance.GlanceModifier -import androidx.glance.GlanceTheme -import androidx.glance.Image -import androidx.glance.ImageProvider -import androidx.glance.LocalSize -import androidx.glance.action.ActionParameters -import androidx.glance.action.actionParametersOf -import androidx.glance.action.clickable -import androidx.glance.background -import androidx.glance.appwidget.GlanceAppWidget -import androidx.glance.appwidget.SizeMode -import androidx.glance.appwidget.action.ActionCallback -import androidx.glance.appwidget.action.actionRunCallback -import androidx.glance.appwidget.background -import androidx.glance.appwidget.components.CircleIconButton -import androidx.glance.appwidget.components.Scaffold -import androidx.glance.appwidget.cornerRadius -import androidx.glance.appwidget.provideContent -import androidx.glance.background -import androidx.glance.currentState -import androidx.glance.layout.Alignment -import androidx.glance.layout.Box -import androidx.glance.layout.Column -import androidx.glance.layout.ContentScale -import androidx.glance.layout.Row -import androidx.glance.layout.Spacer -import androidx.glance.layout.fillMaxSize -import androidx.glance.layout.fillMaxWidth -import androidx.glance.layout.padding -import androidx.glance.layout.size -import androidx.glance.preview.ExperimentalGlancePreviewApi -import androidx.glance.preview.Preview -import androidx.glance.state.GlanceStateDefinition -import com.google.gson.Gson -import es.antonborri.home_widget.HomeWidgetBackgroundIntent -import es.antonborri.home_widget.actionStartActivity -import oss.krtirtho.spotube.MainActivity -import oss.krtirtho.spotube.glance.models.Track -import oss.krtirtho.spotube.glance.widgets.FlutterAssetImageProvider -import oss.krtirtho.spotube.glance.widgets.TrackDetailsView -import oss.krtirtho.spotube.glance.widgets.TrackProgress - -val gson = Gson() -val serverAddressKey = ActionParameters.Key("serverAddress") - -class Breakpoints { - companion object { - val SMALL_SQUARE = DpSize(100.dp, 100.dp) - val HORIZONTAL_RECTANGLE = DpSize(250.dp, 100.dp) - val BIG_SQUARE = DpSize(250.dp, 250.dp) - } -} - -class HomePlayerWidget : GlanceAppWidget() { - - override val sizeMode = SizeMode.Responsive( - setOf( - Breakpoints.SMALL_SQUARE, - Breakpoints.HORIZONTAL_RECTANGLE, - Breakpoints.BIG_SQUARE - ) - ) - - override val stateDefinition: GlanceStateDefinition<*>? - get() = HomeWidgetGlanceStateDefinition() - - override suspend fun provideGlance(context: Context, id: GlanceId) { - provideContent { - GlanceContent(context, currentState()) - } - } - - - @OptIn(ExperimentalGlancePreviewApi::class) - @Preview(widthDp = 100, heightDp = 100) - @Composable - private fun GlanceContent(context: Context, currentState: HomeWidgetGlanceState) { - val prefs = currentState.preferences - val size = LocalSize.current - - val activeTrackStr = prefs.getString("activeTrack", null) - - val isPlaying = prefs.getBoolean("isPlaying", false) - val playbackServerAddress = prefs.getString("playbackServerAddress", null) ?: "" - - var activeTrack: Track? = null - if (activeTrackStr != null) { - activeTrack = gson.fromJson(activeTrackStr, Track::class.java) - } - - - val playIcon = Icon.createWithResource(context, R.drawable.ic_media_play); - val pauseIcon = Icon.createWithResource(context, R.drawable.ic_media_pause); - val previousIcon = Icon.createWithResource(context, R.drawable.ic_media_previous); - val nextIcon = Icon.createWithResource(context, R.drawable.ic_media_next); - - GlanceTheme { - Box( - modifier = GlanceModifier - .fillMaxSize() - .cornerRadius(8.dp) - .background( - color = GlanceTheme.colors.surface.getColor(context) - ) - .clickable { - actionStartActivity(context) - } - , - ) { - Box( - modifier = GlanceModifier - .background( - color = - GlanceTheme.colors.surface.getColor(context) - .copy(alpha = 0.5f), - ) - .fillMaxSize(), - ) {} - Column( - modifier = GlanceModifier.padding(top = 10.dp, start = 10.dp, end = 10.dp) - ) { - Row(verticalAlignment = Alignment.Vertical.CenterVertically) { - TrackDetailsView(activeTrack) - } - Spacer(modifier = GlanceModifier.size(6.dp)) - if (size != Breakpoints.SMALL_SQUARE) { - TrackProgress(prefs) - } - Spacer(modifier = GlanceModifier.size(6.dp)) - Row( - modifier = GlanceModifier.fillMaxWidth(), - horizontalAlignment = Alignment.Horizontal.CenterHorizontally - ) { - CircleIconButton( - imageProvider = ImageProvider(previousIcon), - contentDescription = "Previous", - onClick = actionRunCallback( - parameters = actionParametersOf(serverAddressKey to playbackServerAddress) - ) - ) - Spacer(modifier = GlanceModifier.size(6.dp)) - CircleIconButton( - imageProvider = - if (isPlaying) ImageProvider(pauseIcon) - else ImageProvider(playIcon), - contentDescription = "Play/Pause", - onClick = actionRunCallback( - parameters = actionParametersOf(serverAddressKey to playbackServerAddress) - ) - ) - Spacer(modifier = GlanceModifier.size(6.dp)) - CircleIconButton( - imageProvider = ImageProvider(nextIcon), - contentDescription = "Previous", - onClick = actionRunCallback( - parameters = actionParametersOf( - serverAddressKey to playbackServerAddress - ) - ) - ) - } - } - } - } - } -} - -class PlayPauseAction : InteractiveAction("toggle-playback") -class NextAction : InteractiveAction("next") -class PreviousAction : InteractiveAction("previous") - - -abstract class InteractiveAction(val command: String) : ActionCallback { - override suspend fun onAction( - context: Context, - glanceId: GlanceId, - parameters: ActionParameters - ) { - val serverAddress = parameters[serverAddressKey] ?: "" - - Log.d("HomePlayerWidget", "Sending command $command to $serverAddress") - - if (serverAddress == null || serverAddress.isEmpty()) { - return - } - - - val backgroundIntent = HomeWidgetBackgroundIntent.getBroadcast( - context, - Uri.parse("spotube://playback/$command?serverAddress=$serverAddress") - ) - backgroundIntent.send() - } -} diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidgetReceiver.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidgetReceiver.kt deleted file mode 100644 index 2d23c64f..00000000 --- a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidgetReceiver.kt +++ /dev/null @@ -1,7 +0,0 @@ -package oss.krtirtho.spotube.glance - -import HomeWidgetGlanceWidgetReceiver - -class HomePlayerWidgetReceiver : HomeWidgetGlanceWidgetReceiver() { - override val glanceAppWidget = HomePlayerWidget() -} diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/AlbumSimple.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/AlbumSimple.kt deleted file mode 100644 index 4edd69f6..00000000 --- a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/AlbumSimple.kt +++ /dev/null @@ -1,40 +0,0 @@ -package oss.krtirtho.spotube.glance.models - -import com.google.gson.annotations.SerializedName -import kotlinx.serialization.Serializable - -@Serializable -data class AlbumSimple( - @SerializedName("album_type") - val albumType: AlbumType?, - - @SerializedName("available_markets") - val availableMarkets: List?, - - val href: String?, - val id: String?, - val images: List?, - val name: String?, - - @SerializedName("release_date") - val releaseDate: String?, - - @SerializedName("release_date_precision") - val releaseDatePrecision: DatePrecision?, - - val type: String?, - val uri: String?, -) - -@Serializable -enum class AlbumType { - album, - single, - compilation -} - -enum class DatePrecision { - year, - month, - day -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Artist.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Artist.kt deleted file mode 100644 index ef43ecc8..00000000 --- a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Artist.kt +++ /dev/null @@ -1,25 +0,0 @@ -package oss.krtirtho.spotube.glance.models - -import com.google.gson.annotations.SerializedName -import kotlinx.serialization.Serializable - -@Serializable -data class Artist( - val href: String?, - val id: String?, - val name: String?, - val type: String?, - val uri: String?, - - val followers: Followers?, - val genres: List?, - val images: List?, - - @SerializedName("popularity") - val popularity: Int? -) - -@Serializable -data class Followers( - val total: Int? -) diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Image.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Image.kt deleted file mode 100644 index de7d5521..00000000 --- a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Image.kt +++ /dev/null @@ -1,10 +0,0 @@ -package oss.krtirtho.spotube.glance.models - -import kotlinx.serialization.Serializable - -@Serializable -data class Image( - val height: Int?, - val width: Int?, - val path: String, -) \ No newline at end of file diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Track.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Track.kt deleted file mode 100644 index 717b790f..00000000 --- a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Track.kt +++ /dev/null @@ -1,37 +0,0 @@ -package oss.krtirtho.spotube.glance.models - -import com.google.gson.annotations.SerializedName -import kotlinx.serialization.Serializable -import kotlin.time.Duration.Companion.milliseconds - -@Serializable -data class Track( - val album: AlbumSimple?, val artists: List?, - - @SerializedName("available_markets") val availableMarkets: List?, - - @SerializedName("disc_number") val discNumber: Int?, - - @SerializedName("duration_ms") val durationMs: Int, - - val explicit: Boolean?, val href: String?, val id: String?, - - @SerializedName("is_playable") val isPlayable: Boolean?, - - val name: String?, - - @SerializedName("popularity") val popularity: Int?, - - @SerializedName("preview_url") val previewUrl: String?, - - @SerializedName("track_number") val trackNumber: Int?, - - val type: String?, val uri: String? -) { - val duration: kotlin.time.Duration - get() = durationMs.toLong().milliseconds -} - -enum class Market { - AD, AE, AF, AG, AI, AL, AM, AO, AQ, AR, AS, AT, AU, AW, AX, AZ, BA, BB, BD, BE, BF, BG, BH, BI, BJ, BL, BM, BN, BO, BQ, BR, BS, BT, BV, BW, BY, BZ, CA, CC, CD, CF, CG, CH, CI, CK, CL, CM, CN, CO, CR, CU, CV, CW, CX, CY, CZ, DE, DJ, DK, DM, DO, DZ, EC, EE, EG, EH, ER, ES, ET, FI, FJ, FK, FM, FO, FR, GA, GB, GD, GE, GF, GG, GH, GI, GL, GM, GN, GP, GQ, GR, GS, GT, GU, GW, GY, HK, HM, HN, HR, HT, HU, ID, IE, IL, IM, IN, IO, IQ, IR, IS, IT, JE, JM, JO, JP, KE, KG, KH, KI, KM, KN, KP, KR, KW, KY, KZ, LA, LB, LC, LI, LK, LR, LS, LT, LU, LV, LY, MA, MC, MD, ME, MF, MG, MH, MK, ML, MM, MN, MO, MP, MQ, MR, MS, MT, MU, MV, MW, MX, MY, MZ, NA, NC, NE, NF, NG, NI, NL, NO, NP, NR, NU, NZ, OM, PA, PE, PF, PG, PH, PK, PL, PM, PN, PR, PS, PT, PW, PY, QA, RE, RO, RS, RU, RW, SA, SB, SC, SD, SE, SG, SH, SI, SJ, SK, SL, SM, SN, SO, SR, SS, ST, SV, SX, SY, SZ, TC, TD, TF, TG, TH, TJ, TK, TL, TM, TN, TO, TR, TT, TV, TW, TZ, UA, UG, UM, US, UY, UZ, VA, VC, VE, VG, VI, VN, VU, WF, WS, XK, YE, YT, ZA, ZM, ZW, -} diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/Base64ImageProvider.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/Base64ImageProvider.kt deleted file mode 100644 index 79339cea..00000000 --- a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/Base64ImageProvider.kt +++ /dev/null @@ -1,14 +0,0 @@ -package oss.krtirtho.spotube.glance.widgets - -import android.graphics.BitmapFactory -import android.util.Base64 -import androidx.glance.ImageProvider - -@Suppress("FunctionName") -fun Base64ImageProvider(base64: String): ImageProvider { - var bytes = Base64.decode(base64, Base64.DEFAULT); - - var bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size); - - return ImageProvider(bitmap) -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/FlutterAssetImageProvider.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/FlutterAssetImageProvider.kt deleted file mode 100644 index ad51ca3c..00000000 --- a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/FlutterAssetImageProvider.kt +++ /dev/null @@ -1,14 +0,0 @@ -package oss.krtirtho.spotube.glance.widgets - -import android.content.Context -import android.graphics.BitmapFactory -import androidx.glance.ImageProvider - -@Suppress("FunctionName") -fun FlutterAssetImageProvider(context: Context, path: String): ImageProvider { - var inputStream = context.assets.open("flutter_assets/$path") - - return ImageProvider( - BitmapFactory.decodeStream(inputStream) - ) -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackDetailsView.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackDetailsView.kt deleted file mode 100644 index fdfe8e4b..00000000 --- a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackDetailsView.kt +++ /dev/null @@ -1,78 +0,0 @@ -package oss.krtirtho.spotube.glance.widgets - -import android.graphics.BitmapFactory -import android.net.Uri -import android.util.Log -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.glance.GlanceModifier -import androidx.glance.GlanceTheme -import androidx.glance.Image -import androidx.glance.ImageProvider -import androidx.glance.LocalContext -import androidx.glance.LocalSize -import androidx.glance.appwidget.cornerRadius -import androidx.glance.layout.Alignment -import androidx.glance.layout.Row -import androidx.glance.layout.Column -import androidx.glance.layout.ContentScale -import androidx.glance.layout.Spacer -import androidx.glance.layout.size -import androidx.glance.text.FontWeight -import androidx.glance.text.Text -import androidx.glance.text.TextStyle -import oss.krtirtho.spotube.glance.Breakpoints -import oss.krtirtho.spotube.glance.models.Track - -@Composable -fun TrackDetailsView(activeTrack: Track?) { - val context = LocalContext.current - - val size = LocalSize.current - - val artistStr = activeTrack?.artists?.map { it.name }?.joinToString(", ") ?: "" - val imgLocalPath = activeTrack?.album?.images?.get(0)?.path; - val title = activeTrack?.name ?: "" - - - Image( - provider = - if (imgLocalPath == null) - ImageProvider( - BitmapFactory.decodeResource( - context.resources, - android.R.drawable.ic_delete - ) - ) - else ImageProvider(BitmapFactory.decodeFile(imgLocalPath)), - contentDescription = "Album Art", - modifier = GlanceModifier.cornerRadius(8.dp) - .size( - if (size.height < 200.dp) 50.dp - else 100.dp - ), - contentScale = ContentScale.Fit - ) - Spacer(modifier = GlanceModifier.size(6.dp)) - Column { - Text( - text = title, - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = GlanceTheme.colors.onBackground - ), - ) - if (size != Breakpoints.SMALL_SQUARE) { - Spacer(modifier = GlanceModifier.size(6.dp)) - Text( - text = artistStr, - style = TextStyle( - fontSize = 14.sp, - color = GlanceTheme.colors.onBackground - ), - ) - } - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackProgress.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackProgress.kt deleted file mode 100644 index b54059b1..00000000 --- a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackProgress.kt +++ /dev/null @@ -1,77 +0,0 @@ -package oss.krtirtho.spotube.glance.widgets - -import android.content.SharedPreferences -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.dp -import androidx.glance.GlanceModifier -import androidx.glance.GlanceTheme -import androidx.glance.LocalSize -import androidx.glance.appwidget.LinearProgressIndicator -import androidx.glance.layout.Column -import androidx.glance.layout.Row -import androidx.glance.layout.Spacer -import androidx.glance.layout.fillMaxWidth -import androidx.glance.layout.size -import androidx.glance.text.Text -import androidx.glance.text.TextStyle -import kotlin.math.max -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds -import oss.krtirtho.spotube.glance.Breakpoints - -fun Duration.format(): String { - return this.toComponents { hour, minutes, seconds, nanoseconds -> - var paddedSeconds = seconds.toString().padStart(2, '0') - var paddedMinutes = minutes.toString().padStart(2, '0') - var paddedHour = hour.toString().padStart(2, '0') - if (hour == 0L) { - "$paddedMinutes:$paddedSeconds" - } else { - "$paddedHour:$paddedMinutes:$paddedSeconds" - } - } -} - -@Composable -fun TrackProgress(prefs: SharedPreferences) { - val size = LocalSize.current - val position = prefs.getInt("position", 0).seconds - var duration = prefs.getInt("duration", 0).seconds - - var progress = position.inWholeSeconds.toFloat() / max(duration.inWholeSeconds.toFloat(), 1.0f) - - var textStyle = - TextStyle( - color = GlanceTheme.colors.onBackground, - ) - - if (size == Breakpoints.HORIZONTAL_RECTANGLE) { - Row(modifier = GlanceModifier.fillMaxWidth()) { - Text(text = position.format(), style = textStyle) - Spacer(modifier = GlanceModifier.size(6.dp)) - LinearProgressIndicator( - progress = progress, - modifier = GlanceModifier.defaultWeight(), - color = GlanceTheme.colors.primary, - backgroundColor = GlanceTheme.colors.primaryContainer, - ) - Spacer(modifier = GlanceModifier.size(6.dp)) - Text(text = duration.format(), style = textStyle) - } - } else { - Column(modifier = GlanceModifier.fillMaxWidth()) { - LinearProgressIndicator( - progress = progress, - modifier = GlanceModifier.fillMaxWidth(), - color = GlanceTheme.colors.primary, - backgroundColor = GlanceTheme.colors.primaryContainer, - ) - Spacer(modifier = GlanceModifier.size(6.dp)) - Row(modifier = GlanceModifier.fillMaxWidth()) { - Text(text = position.format(), style = textStyle) - Spacer(modifier = GlanceModifier.defaultWeight()) - Text(text = duration.format(), style = textStyle) - } - } - } -} diff --git a/android/app/src/main/res/drawable-hdpi-v31/android12branding.png b/android/app/src/main/res/drawable-hdpi-v31/android12branding.png deleted file mode 100644 index 22a9b8d3..00000000 Binary files a/android/app/src/main/res/drawable-hdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png deleted file mode 100644 index adeebcd1..00000000 Binary files a/android/app/src/main/res/drawable-hdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-hdpi/branding.png b/android/app/src/main/res/drawable-hdpi/branding.png deleted file mode 100644 index 22a9b8d3..00000000 Binary files a/android/app/src/main/res/drawable-hdpi/branding.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png deleted file mode 100644 index 696717ef..00000000 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 204ffe94..00000000 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png deleted file mode 100644 index 87eebe5c..00000000 Binary files a/android/app/src/main/res/drawable-hdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi-v31/android12branding.png b/android/app/src/main/res/drawable-mdpi-v31/android12branding.png deleted file mode 100644 index 3ff2a2da..00000000 Binary files a/android/app/src/main/res/drawable-mdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png deleted file mode 100644 index 72c22614..00000000 Binary files a/android/app/src/main/res/drawable-mdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/branding.png b/android/app/src/main/res/drawable-mdpi/branding.png deleted file mode 100644 index 3ff2a2da..00000000 Binary files a/android/app/src/main/res/drawable-mdpi/branding.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png deleted file mode 100644 index d2bb407f..00000000 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png deleted file mode 100644 index e7a75c9a..00000000 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png deleted file mode 100644 index 6e04efdc..00000000 Binary files a/android/app/src/main/res/drawable-mdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-hdpi-v31/android12branding.png b/android/app/src/main/res/drawable-night-hdpi-v31/android12branding.png deleted file mode 100644 index 22a9b8d3..00000000 Binary files a/android/app/src/main/res/drawable-night-hdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/android/app/src/main/res/drawable-night-hdpi/android12splash.png deleted file mode 100644 index adeebcd1..00000000 Binary files a/android/app/src/main/res/drawable-night-hdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-mdpi-v31/android12branding.png b/android/app/src/main/res/drawable-night-mdpi-v31/android12branding.png deleted file mode 100644 index 3ff2a2da..00000000 Binary files a/android/app/src/main/res/drawable-night-mdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/android/app/src/main/res/drawable-night-mdpi/android12splash.png deleted file mode 100644 index 72c22614..00000000 Binary files a/android/app/src/main/res/drawable-night-mdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-xhdpi-v31/android12branding.png b/android/app/src/main/res/drawable-night-xhdpi-v31/android12branding.png deleted file mode 100644 index 8e2bb197..00000000 Binary files a/android/app/src/main/res/drawable-night-xhdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png deleted file mode 100644 index 5adba278..00000000 Binary files a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-xxhdpi-v31/android12branding.png b/android/app/src/main/res/drawable-night-xxhdpi-v31/android12branding.png deleted file mode 100644 index d301093a..00000000 Binary files a/android/app/src/main/res/drawable-night-xxhdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png deleted file mode 100644 index 4e294a2d..00000000 Binary files a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-xxxhdpi-v31/android12branding.png b/android/app/src/main/res/drawable-night-xxxhdpi-v31/android12branding.png deleted file mode 100644 index 42b0bdc2..00000000 Binary files a/android/app/src/main/res/drawable-night-xxxhdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png deleted file mode 100644 index dcc70377..00000000 Binary files a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png deleted file mode 100644 index 4bebb9de..00000000 Binary files a/android/app/src/main/res/drawable-v21/background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index 5367a886..00000000 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/android/app/src/main/res/drawable-xhdpi-v31/android12branding.png b/android/app/src/main/res/drawable-xhdpi-v31/android12branding.png deleted file mode 100644 index 8e2bb197..00000000 Binary files a/android/app/src/main/res/drawable-xhdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png deleted file mode 100644 index 5adba278..00000000 Binary files a/android/app/src/main/res/drawable-xhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xhdpi/branding.png b/android/app/src/main/res/drawable-xhdpi/branding.png deleted file mode 100644 index 8e2bb197..00000000 Binary files a/android/app/src/main/res/drawable-xhdpi/branding.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png deleted file mode 100644 index 534e957f..00000000 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index b608dca7..00000000 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png deleted file mode 100644 index 51a669aa..00000000 Binary files a/android/app/src/main/res/drawable-xhdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi-v31/android12branding.png b/android/app/src/main/res/drawable-xxhdpi-v31/android12branding.png deleted file mode 100644 index d301093a..00000000 Binary files a/android/app/src/main/res/drawable-xxhdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png deleted file mode 100644 index 4e294a2d..00000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/branding.png b/android/app/src/main/res/drawable-xxhdpi/branding.png deleted file mode 100644 index d301093a..00000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/branding.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png deleted file mode 100644 index c1eaa966..00000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index b1e93458..00000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png deleted file mode 100644 index cc79cb85..00000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi-v31/android12branding.png b/android/app/src/main/res/drawable-xxxhdpi-v31/android12branding.png deleted file mode 100644 index 42b0bdc2..00000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png deleted file mode 100644 index dcc70377..00000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/branding.png b/android/app/src/main/res/drawable-xxxhdpi/branding.png deleted file mode 100644 index 42b0bdc2..00000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/branding.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png deleted file mode 100644 index 6c7c4636..00000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index d178ba12..00000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png deleted file mode 100644 index f526b26d..00000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png deleted file mode 100644 index 4bebb9de..00000000 Binary files a/android/app/src/main/res/drawable/background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml deleted file mode 100644 index 8aae0e6c..00000000 --- a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 5367a886..00000000 --- a/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index c79c58a3..00000000 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index c9e5cfad..00000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 159b302f..00000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 9c81fb60..00000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 3450fb00..00000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index f94fd407..00000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml deleted file mode 100644 index 96980835..00000000 --- a/android/app/src/main/res/values-night-v31/styles.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 5eb2eda1..00000000 --- a/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml deleted file mode 100644 index 981a07a9..00000000 --- a/android/app/src/main/res/values-v31/styles.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml deleted file mode 100644 index 88247a21..00000000 --- a/android/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #242832 - \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml deleted file mode 100644 index 0fdc7036..00000000 --- a/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/android/app/src/main/res/xml/automotive_app_desc.xml b/android/app/src/main/res/xml/automotive_app_desc.xml deleted file mode 100644 index 90e6f30e..00000000 --- a/android/app/src/main/res/xml/automotive_app_desc.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/android/app/src/main/res/xml/home_player_widget_config.xml b/android/app/src/main/res/xml/home_player_widget_config.xml deleted file mode 100644 index c8ec7048..00000000 --- a/android/app/src/main/res/xml/home_player_widget_config.xml +++ /dev/null @@ -1,7 +0,0 @@ - - diff --git a/android/app/src/nightly/res/drawable-hdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-hdpi-v31/android12branding.png deleted file mode 100644 index 22a9b8d3..00000000 Binary files a/android/app/src/nightly/res/drawable-hdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-hdpi/android12splash.png b/android/app/src/nightly/res/drawable-hdpi/android12splash.png deleted file mode 100644 index 1d1cb853..00000000 Binary files a/android/app/src/nightly/res/drawable-hdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-hdpi/branding.png b/android/app/src/nightly/res/drawable-hdpi/branding.png deleted file mode 100644 index 22a9b8d3..00000000 Binary files a/android/app/src/nightly/res/drawable-hdpi/branding.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/nightly/res/drawable-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 7373eec1..00000000 Binary files a/android/app/src/nightly/res/drawable-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-hdpi/splash.png b/android/app/src/nightly/res/drawable-hdpi/splash.png deleted file mode 100644 index e3869211..00000000 Binary files a/android/app/src/nightly/res/drawable-hdpi/splash.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-mdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-mdpi-v31/android12branding.png deleted file mode 100644 index 3ff2a2da..00000000 Binary files a/android/app/src/nightly/res/drawable-mdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-mdpi/android12splash.png b/android/app/src/nightly/res/drawable-mdpi/android12splash.png deleted file mode 100644 index 3f2a1a0a..00000000 Binary files a/android/app/src/nightly/res/drawable-mdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-mdpi/branding.png b/android/app/src/nightly/res/drawable-mdpi/branding.png deleted file mode 100644 index 3ff2a2da..00000000 Binary files a/android/app/src/nightly/res/drawable-mdpi/branding.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/nightly/res/drawable-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 8d5a9656..00000000 Binary files a/android/app/src/nightly/res/drawable-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-mdpi/splash.png b/android/app/src/nightly/res/drawable-mdpi/splash.png deleted file mode 100644 index d8f7cc0e..00000000 Binary files a/android/app/src/nightly/res/drawable-mdpi/splash.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-night-hdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-night-hdpi-v31/android12branding.png deleted file mode 100644 index 22a9b8d3..00000000 Binary files a/android/app/src/nightly/res/drawable-night-hdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-night-hdpi/android12splash.png b/android/app/src/nightly/res/drawable-night-hdpi/android12splash.png deleted file mode 100644 index 1d1cb853..00000000 Binary files a/android/app/src/nightly/res/drawable-night-hdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-night-mdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-night-mdpi-v31/android12branding.png deleted file mode 100644 index 3ff2a2da..00000000 Binary files a/android/app/src/nightly/res/drawable-night-mdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-night-mdpi/android12splash.png b/android/app/src/nightly/res/drawable-night-mdpi/android12splash.png deleted file mode 100644 index 3f2a1a0a..00000000 Binary files a/android/app/src/nightly/res/drawable-night-mdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-night-xhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-night-xhdpi-v31/android12branding.png deleted file mode 100644 index 8e2bb197..00000000 Binary files a/android/app/src/nightly/res/drawable-night-xhdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-night-xhdpi/android12splash.png b/android/app/src/nightly/res/drawable-night-xhdpi/android12splash.png deleted file mode 100644 index ba73b0e2..00000000 Binary files a/android/app/src/nightly/res/drawable-night-xhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-night-xxhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-night-xxhdpi-v31/android12branding.png deleted file mode 100644 index d301093a..00000000 Binary files a/android/app/src/nightly/res/drawable-night-xxhdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-night-xxhdpi/android12splash.png b/android/app/src/nightly/res/drawable-night-xxhdpi/android12splash.png deleted file mode 100644 index 5e5cdc45..00000000 Binary files a/android/app/src/nightly/res/drawable-night-xxhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-night-xxxhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-night-xxxhdpi-v31/android12branding.png deleted file mode 100644 index 42b0bdc2..00000000 Binary files a/android/app/src/nightly/res/drawable-night-xxxhdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-night-xxxhdpi/android12splash.png b/android/app/src/nightly/res/drawable-night-xxxhdpi/android12splash.png deleted file mode 100644 index adff9bec..00000000 Binary files a/android/app/src/nightly/res/drawable-night-xxxhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-v21/background.png b/android/app/src/nightly/res/drawable-v21/background.png deleted file mode 100644 index 4bebb9de..00000000 Binary files a/android/app/src/nightly/res/drawable-v21/background.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-v21/launch_background.xml b/android/app/src/nightly/res/drawable-v21/launch_background.xml deleted file mode 100644 index 5367a886..00000000 --- a/android/app/src/nightly/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/android/app/src/nightly/res/drawable-xhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-xhdpi-v31/android12branding.png deleted file mode 100644 index 8e2bb197..00000000 Binary files a/android/app/src/nightly/res/drawable-xhdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-xhdpi/android12splash.png b/android/app/src/nightly/res/drawable-xhdpi/android12splash.png deleted file mode 100644 index ba73b0e2..00000000 Binary files a/android/app/src/nightly/res/drawable-xhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-xhdpi/branding.png b/android/app/src/nightly/res/drawable-xhdpi/branding.png deleted file mode 100644 index 8e2bb197..00000000 Binary files a/android/app/src/nightly/res/drawable-xhdpi/branding.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/nightly/res/drawable-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index f4f416f7..00000000 Binary files a/android/app/src/nightly/res/drawable-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-xhdpi/splash.png b/android/app/src/nightly/res/drawable-xhdpi/splash.png deleted file mode 100644 index 17a2c373..00000000 Binary files a/android/app/src/nightly/res/drawable-xhdpi/splash.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-xxhdpi-v31/android12branding.png deleted file mode 100644 index d301093a..00000000 Binary files a/android/app/src/nightly/res/drawable-xxhdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi/android12splash.png b/android/app/src/nightly/res/drawable-xxhdpi/android12splash.png deleted file mode 100644 index 5e5cdc45..00000000 Binary files a/android/app/src/nightly/res/drawable-xxhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi/branding.png b/android/app/src/nightly/res/drawable-xxhdpi/branding.png deleted file mode 100644 index d301093a..00000000 Binary files a/android/app/src/nightly/res/drawable-xxhdpi/branding.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/nightly/res/drawable-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 9f88e976..00000000 Binary files a/android/app/src/nightly/res/drawable-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi/splash.png b/android/app/src/nightly/res/drawable-xxhdpi/splash.png deleted file mode 100644 index db53f016..00000000 Binary files a/android/app/src/nightly/res/drawable-xxhdpi/splash.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-xxxhdpi-v31/android12branding.png deleted file mode 100644 index 42b0bdc2..00000000 Binary files a/android/app/src/nightly/res/drawable-xxxhdpi-v31/android12branding.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi/android12splash.png b/android/app/src/nightly/res/drawable-xxxhdpi/android12splash.png deleted file mode 100644 index adff9bec..00000000 Binary files a/android/app/src/nightly/res/drawable-xxxhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi/branding.png b/android/app/src/nightly/res/drawable-xxxhdpi/branding.png deleted file mode 100644 index 42b0bdc2..00000000 Binary files a/android/app/src/nightly/res/drawable-xxxhdpi/branding.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/nightly/res/drawable-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 7e2bb4c3..00000000 Binary files a/android/app/src/nightly/res/drawable-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi/splash.png b/android/app/src/nightly/res/drawable-xxxhdpi/splash.png deleted file mode 100644 index a74c95a4..00000000 Binary files a/android/app/src/nightly/res/drawable-xxxhdpi/splash.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable/background.png b/android/app/src/nightly/res/drawable/background.png deleted file mode 100644 index 4bebb9de..00000000 Binary files a/android/app/src/nightly/res/drawable/background.png and /dev/null differ diff --git a/android/app/src/nightly/res/drawable/ic_launcher_monochrome.xml b/android/app/src/nightly/res/drawable/ic_launcher_monochrome.xml deleted file mode 100644 index 8aae0e6c..00000000 --- a/android/app/src/nightly/res/drawable/ic_launcher_monochrome.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - diff --git a/android/app/src/nightly/res/drawable/launch_background.xml b/android/app/src/nightly/res/drawable/launch_background.xml deleted file mode 100644 index 5367a886..00000000 --- a/android/app/src/nightly/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index c79c58a3..00000000 --- a/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - diff --git a/android/app/src/nightly/res/mipmap-hdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 8164cc67..00000000 Binary files a/android/app/src/nightly/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/nightly/res/mipmap-mdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index bff95e97..00000000 Binary files a/android/app/src/nightly/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/nightly/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index df515ed1..00000000 Binary files a/android/app/src/nightly/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/nightly/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index e58ef25f..00000000 Binary files a/android/app/src/nightly/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index d39a5e64..00000000 Binary files a/android/app/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/nightly/res/values-night-v31/styles.xml b/android/app/src/nightly/res/values-night-v31/styles.xml deleted file mode 100644 index 96980835..00000000 --- a/android/app/src/nightly/res/values-night-v31/styles.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - diff --git a/android/app/src/nightly/res/values-night/styles.xml b/android/app/src/nightly/res/values-night/styles.xml deleted file mode 100644 index dbc9ea9f..00000000 --- a/android/app/src/nightly/res/values-night/styles.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/android/app/src/nightly/res/values-v31/styles.xml b/android/app/src/nightly/res/values-v31/styles.xml deleted file mode 100644 index 981a07a9..00000000 --- a/android/app/src/nightly/res/values-v31/styles.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - diff --git a/android/app/src/nightly/res/values/colors.xml b/android/app/src/nightly/res/values/colors.xml deleted file mode 100644 index 88247a21..00000000 --- a/android/app/src/nightly/res/values/colors.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #242832 - \ No newline at end of file diff --git a/android/app/src/nightly/res/values/styles.xml b/android/app/src/nightly/res/values/styles.xml deleted file mode 100644 index 0d1fa8fc..00000000 --- a/android/app/src/nightly/res/values/styles.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index a32d12af..00000000 --- a/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle deleted file mode 100644 index 8f31e8ca..00000000 --- a/android/build.gradle +++ /dev/null @@ -1,18 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(':app') -} - -tasks.register("clean", Delete) { - delete rootProject.buildDir -} \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties deleted file mode 100644 index ed508580..00000000 --- a/android/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -org.gradle.jvmargs=-Xmx4608m -android.useAndroidX=true -android.enableJetifier=true diff --git a/android/settings.gradle b/android/settings.gradle deleted file mode 100644 index 53d34a77..00000000 --- a/android/settings.gradle +++ /dev/null @@ -1,26 +0,0 @@ -pluginManagement { - def flutterSdkPath = { - def properties = new Properties() - file("local.properties").withInputStream { properties.load(it) } - def flutterSdkPath = properties.getProperty("flutter.sdk") - assert flutterSdkPath != null, "flutter.sdk not set in local.properties" - return flutterSdkPath - }() - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version '8.7.0' apply false - id "org.jetbrains.kotlin.android" version "2.1.0" apply false - id "org.jetbrains.kotlin.plugin.compose" version "2.1.0" apply false -} - -include ':app' \ No newline at end of file diff --git a/appdmg.json b/appdmg.json deleted file mode 100644 index 6e365f23..00000000 --- a/appdmg.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "title": "Spotube", - "icon": "assets/branding/spotube-logo-macos.png", - "contents": [ - { - "x": 448, - "y": 344, - "type": "link", - "path": "/Applications" - }, - { - "x": 192, - "y": 344, - "type": "file", - "path": "build/macos/Build/Products/Release/Spotube.app" - } - ] -} diff --git a/assets/fonts/Ubuntu_Mono/UFL.txt b/assets/fonts/Ubuntu_Mono/UFL.txt deleted file mode 100644 index 6e722c88..00000000 --- a/assets/fonts/Ubuntu_Mono/UFL.txt +++ /dev/null @@ -1,96 +0,0 @@ -------------------------------- -UBUNTU FONT LICENCE Version 1.0 -------------------------------- - -PREAMBLE -This licence allows the licensed fonts to be used, studied, modified and -redistributed freely. The fonts, including any derivative works, can be -bundled, embedded, and redistributed provided the terms of this licence -are met. The fonts and derivatives, however, cannot be released under -any other licence. The requirement for fonts to remain under this -licence does not require any document created using the fonts or their -derivatives to be published under this licence, as long as the primary -purpose of the document is not to be a vehicle for the distribution of -the fonts. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this licence and clearly marked as such. This may -include source files, build scripts and documentation. - -"Original Version" refers to the collection of Font Software components -as received under this licence. - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to -a new environment. - -"Copyright Holder(s)" refers to all individuals and companies who have a -copyright ownership of the Font Software. - -"Substantially Changed" refers to Modified Versions which can be easily -identified as dissimilar to the Font Software by users of the Font -Software comparing the Original Version with the Modified Version. - -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 and with or without charging -a redistribution fee), making available to the public, and in some -countries other activities as well. - -PERMISSION & CONDITIONS -This licence does not grant any rights under trademark law and all such -rights are reserved. - -Permission is hereby granted, free of charge, to any person obtaining a -copy of the Font Software, to propagate the Font Software, subject to -the below conditions: - -1) Each copy of the Font Software must contain the above copyright -notice and this licence. These can be included either as stand-alone -text files, human-readable headers or in the appropriate machine- -readable metadata fields within text or binary files as long as those -fields can be easily viewed by the user. - -2) The font name complies with the following: -(a) The Original Version must retain its name, unmodified. -(b) Modified Versions which are Substantially Changed must be renamed to -avoid use of the name of the Original Version or similar names entirely. -(c) Modified Versions which are not Substantially Changed must be -renamed to both (i) retain the name of the Original Version and (ii) add -additional naming elements to distinguish the Modified Version from the -Original Version. The name of such Modified Versions must be the name of -the Original Version, with "derivative X" where X represents the name of -the new work, appended to that name. - -3) The name(s) of the Copyright Holder(s) and any contributor to the -Font Software shall not be used to promote, endorse or advertise any -Modified Version, except (i) as required by this licence, (ii) to -acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with -their explicit written permission. - -4) The Font Software, modified or unmodified, in part or in whole, must -be distributed entirely under this licence, and must not be distributed -under any other licence. The requirement for fonts to remain under this -licence does not affect any document created using the Font Software, -except any version of the Font Software extracted from a document -created using the Font Software may only be distributed under this -licence. - -TERMINATION -This licence becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF -COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER -DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Ubuntu_Mono/UbuntuMono-Bold.ttf b/assets/fonts/Ubuntu_Mono/UbuntuMono-Bold.ttf deleted file mode 100644 index 01ad81bf..00000000 Binary files a/assets/fonts/Ubuntu_Mono/UbuntuMono-Bold.ttf and /dev/null differ diff --git a/assets/fonts/Ubuntu_Mono/UbuntuMono-BoldItalic.ttf b/assets/fonts/Ubuntu_Mono/UbuntuMono-BoldItalic.ttf deleted file mode 100644 index 731884eb..00000000 Binary files a/assets/fonts/Ubuntu_Mono/UbuntuMono-BoldItalic.ttf and /dev/null differ diff --git a/assets/fonts/Ubuntu_Mono/UbuntuMono-Italic.ttf b/assets/fonts/Ubuntu_Mono/UbuntuMono-Italic.ttf deleted file mode 100644 index b89338d4..00000000 Binary files a/assets/fonts/Ubuntu_Mono/UbuntuMono-Italic.ttf and /dev/null differ diff --git a/assets/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf b/assets/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf deleted file mode 100644 index 4977028d..00000000 Binary files a/assets/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf and /dev/null differ diff --git a/assets/images/album-placeholder.png b/assets/images/album-placeholder.png deleted file mode 100644 index bcb25969..00000000 Binary files a/assets/images/album-placeholder.png and /dev/null differ diff --git a/assets/images/bengali-patterns-bg.jpg b/assets/images/bengali-patterns-bg.jpg deleted file mode 100644 index a4090a01..00000000 Binary files a/assets/images/bengali-patterns-bg.jpg and /dev/null differ diff --git a/assets/images/logos/dab-music.png b/assets/images/logos/dab-music.png deleted file mode 100644 index e09d3410..00000000 Binary files a/assets/images/logos/dab-music.png and /dev/null differ diff --git a/assets/images/logos/invidious.jpg b/assets/images/logos/invidious.jpg deleted file mode 100644 index 3a54ace1..00000000 Binary files a/assets/images/logos/invidious.jpg and /dev/null differ diff --git a/assets/images/logos/jiosaavn.png b/assets/images/logos/jiosaavn.png deleted file mode 100644 index 4d2d46e4..00000000 Binary files a/assets/images/logos/jiosaavn.png and /dev/null differ diff --git a/assets/images/placeholder.png b/assets/images/placeholder.png deleted file mode 100644 index 6f6d451f..00000000 Binary files a/assets/images/placeholder.png and /dev/null differ diff --git a/assets/images/user-placeholder.png b/assets/images/user-placeholder.png deleted file mode 100644 index cb2325ff..00000000 Binary files a/assets/images/user-placeholder.png and /dev/null differ diff --git a/assets/plugins/spotube-plugin-musicbrainz-listenbrainz/plugin.smplug b/assets/plugins/spotube-plugin-musicbrainz-listenbrainz/plugin.smplug deleted file mode 100644 index 41be05a4..00000000 Binary files a/assets/plugins/spotube-plugin-musicbrainz-listenbrainz/plugin.smplug and /dev/null differ diff --git a/assets/plugins/spotube-plugin-youtube-audio/plugin.smplug b/assets/plugins/spotube-plugin-youtube-audio/plugin.smplug deleted file mode 100644 index 55aa2895..00000000 Binary files a/assets/plugins/spotube-plugin-youtube-audio/plugin.smplug and /dev/null differ diff --git a/aur-struct/.SRCINFO b/aur-struct/.SRCINFO deleted file mode 100644 index 0460932b..00000000 --- a/aur-struct/.SRCINFO +++ /dev/null @@ -1,19 +0,0 @@ -pkgbase = spotube-bin -pkgdesc = Open source Music client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! -pkgver = 4.0.0 -pkgrel = 1 -url = https://spotube.krtirtho.dev -arch = x86_64 -license = BSD-4-Clause -depends = mpv -depends = libappindicator-gtk3 -depends = libsecret -depends = jsoncpp -depends = libnotify -depends = xdg-user-dirs -depends = webkit2gtk-4.1 -optdepends = yt-dlp-git -source = https://github.com/KRTirtho/spotube/releases/download/v3.7.1/spotube-linux-3.7.1-x86_64.tar.xz -md5sums = 475b1ae9b08f27743a4d4749391ae3db - -pkgname = spotube-bin diff --git a/aur-struct/PKGBUILD b/aur-struct/PKGBUILD deleted file mode 100644 index 4f884f0d..00000000 --- a/aur-struct/PKGBUILD +++ /dev/null @@ -1,40 +0,0 @@ -# Maintainer: Kingkor Roy Tirtho -pkgname=spotube-bin -pkgver=%{{SPOTUBE_VERSION}}% -pkgrel=%{{PKGREL}}% -epoch= -pkgdesc="Open source Music client that doesn't require Premium nor uses Electron! Available for both desktop & mobile!" -arch=(x86_64) -url="https://spotube.krtirtho.dev" -license=('BSD-4-Clause') -groups=() -depends=('mpv' 'libappindicator-gtk3' 'libsecret' 'jsoncpp' 'libnotify' 'xdg-user-dirs' 'webkit2gtk-4.1') -makedepends=() -checkdepends=() -optdepends=('yt-dlp-git') -provides=() -conflicts=() -replaces=() -backup=() -options=() -install= -changelog= -source=("https://github.com/KRTirtho/spotube/releases/download/v${pkgver}/spotube-linux-${pkgver}-x86_64.tar.xz") -noextract=() -md5sums=(%{{LINUX_MD5}}%) -validpgpkeys=() - -package() { - install -dm755 "${pkgdir}/usr/share/icons/spotube" - install -dm755 "${pkgdir}/usr/share/applications" - install -dm755 "${pkgdir}/usr/share/appdata" - install -dm755 "${pkgdir}/usr/share/${pkgname}" - install -dm755 "${pkgdir}/usr/bin" - - mv ./spotube.desktop "${pkgdir}/usr/share/applications" - mv ./spotube-logo.png "${pkgdir}/usr/share/icons/spotube/" - mv ./com.github.KRTirtho.Spotube.appdata.xml "${pkgdir}/usr/share/appdata/spotube.appdata.xml" - cp -ra ./data ./lib ./spotube "${pkgdir}/usr/share/${pkgname}" - sed -i 's|com.github.KRTirtho.Spotube|spotube|' "${pkgdir}/usr/share/appdata/spotube.appdata.xml" - ln -s "/usr/share/${pkgname}/spotube" "${pkgdir}/usr/bin/spotube" -} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..b4313c19 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +plugins { + // this is necessary to avoid the plugins to be loaded multiple times + // in each subproject's classloader + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.composeHotReload) apply false + alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.kotlinSerialization) apply false + alias(libs.plugins.jetbrainsKotlinJvm) apply false + alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false + alias(libs.plugins.zipline.gradle.plugin) apply false + alias(libs.plugins.spotubeGradle) apply false + alias(libs.plugins.vlcjBundler) apply false + alias(libs.plugins.uniffi) apply false + alias(libs.plugins.cargo) apply false + alias(libs.plugins.mokkery) apply false + alias(libs.plugins.neucleusFramework) apply false +} \ No newline at end of file diff --git a/build.yaml b/build.yaml deleted file mode 100644 index 76771f22..00000000 --- a/build.yaml +++ /dev/null @@ -1,29 +0,0 @@ -targets: - $default: - sources: - exclude: - - bin/*.dart - builders: - auto_route_generator:auto_route_generator: # this for @RoutePage - options: - enable_cached_builds: true - generate_for: - - lib/pages/**/*.dart - auto_route_generator:auto_router_generator: # this for @AutoRouterConfig - options: - enable_cached_builds: true - generate_for: - - lib/collections/routes.dart - json_serializable: - options: - any_map: true - explicit_to_json: true - drift_dev: - options: - databases: - app_db: lib/models/database/database.dart - sql: - dialect: sqlite - options: - modules: - - json1 diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..0479ace4 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,14 @@ +// Build logic: the Linux AppImage bundle (DownloadLinuxGtkBundleTask) and the package manager manifests +// (GitCliffContextTask, RenderPackagingTask). Lives in buildSrc so the IDE resolves it; an +// `apply(from = ...)` script plugin gets no Kotlin classpath in Android Studio. +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(libs.jmustache) +} diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 00000000..ac722dd3 --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,8 @@ +// Shares the root version catalog (gradle/libs.versions.toml) with the build logic +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt b/buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt new file mode 100644 index 00000000..b9331fb5 --- /dev/null +++ b/buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// Bundles GTK 3 and WebKitGTK 4.1 into the Linux AppImage, the way Tauri apps do, so it runs on a +// system that has neither. Used only with -PappImage=true; deb/rpm depend on the distro's packages. +// +// The libraries come from the Ubuntu archive. Like the AppImage exclude list prescribes, nothing that +// has to match the host is bundled: glibc, libstdc++, libz, GL/EGL/DRM, X11/xcb, ALSA, fontconfig, +// freetype, harfbuzz, expat, gmp and the wayland libraries. What is left is pruned to the libraries +// reachable from GTK/WebKit through their ELF NEEDED entries. +// +// Layout (copied into the AppDir root by Nucleus; AppRun puts $APPDIR/usr/lib on LD_LIBRARY_PATH): +// usr/lib/*.so.N shared libraries, RUNPATH=$ORIGIN +// usr/lib//webkit2gtk-4.1/WebKit*Process helper processes +// usr/lib//gio/modules/libgiognutls.so TLS backend for WebKit/libsoup +// libwebkit2gtk has the helper path compiled in as /usr/lib//webkit2gtk-4.1; "/usr" is +// patched to "././" (same length), so the helpers resolve relative to the working directory, which the +// app sets to $APPDIR/usr at startup (AppImageEnvironment). Needs `ar`, `tar` (+ `zstd`), `readelf` +// and `patchelf` on the build machine. + +package org.spotube.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault +import java.io.File +import java.net.URI +import java.util.zip.GZIPInputStream + +@DisableCachingByDefault(because = "Downloads from the network") +abstract class DownloadLinuxGtkBundleTask : DefaultTask() { + /** Root passed on to Nucleus' `appResourcesRootDir`; the files land in `/linux-/usr`. */ + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + /** Ubuntu release the binaries come from. Its glibc is the minimum the bundle runs on. */ + @get:Input + abstract val ubuntuRelease: Property + + init { + group = "dependencies" + description = "Downloads and relocates GTK 3 and WebKitGTK 4.1 for the Linux AppImage." + } + + private val packages = listOf( + "libgtk-3-0t64", "libwebkit2gtk-4.1-0", "libjavascriptcoregtk-4.1-0", "glib-networking", + "libatk-bridge2.0-0t64", "libatk1.0-0t64", "libatomic1", "libatspi2.0-0t64", "libbrotli1", + "libbz2-1.0", "libcairo-gobject2", "libcairo2", "libcap2", "libdatrie1", "libdbus-1-3", + "libdw1t64", "libelf1t64", "libenchant-2-2", "libepoxy0", "libevdev2", "libffi8", "libgcrypt20", + "libgdk-pixbuf-2.0-0", "libglib2.0-0t64", "libgnutls30t64", "libgssapi-krb5-2", + "libgstreamer-gl1.0-0", "libgstreamer-plugins-base1.0-0", "libgstreamer1.0-0", "libgudev-1.0-0", + "libharfbuzz-icu0", "libhogweed6t64", "libhyphen0", "libicu74", "libidn2-0", "libjpeg-turbo8", + "libjpeg8", "libk5crypto3", "libkeyutils1", "libkrb5-3", "libkrb5support0", "liblcms2-2", + "liblz4-1", "liblzma5", "libmanette-0.2-0", "libnettle8t64", "libnghttp2-14", "liborc-0.4-0t64", + "libp11-kit0", "libpango-1.0-0", "libpangocairo-1.0-0", "libpangoft2-1.0-0", "libpixman-1-0", + "libpng16-16t64", "libpsl5t64", "libseccomp2", "libsecret-1-0", "libsharpyuv0", "libsoup-3.0-0", + "libsqlite3-0", "libsystemd0", "libtasn1-6", "libthai0", "libudev1", "libunistring5", "libunwind8", + "libwebp7", "libwebpdemux2", "libwebpmux3", "libxcb-render0", "libxcb-shm0", "libxcomposite1", + "libxcursor1", "libxdamage1", "libxext6", "libxfixes3", "libxi6", "libxinerama1", "libxkbcommon0", + "libxml2", "libxrandr2", "libxrender1", "libxslt1.1", "libzstd1", + ) + + // Provided by the host, per the AppImage exclude list (which includes the wayland libraries). Never + // copied even if a bundled package carries them. + private val hostLibraries = setOf( + "libc.so.6", "libm.so.6", "libdl.so.2", "libpthread.so.0", "librt.so.1", "libresolv.so.2", + "libutil.so.1", "libnsl.so.1", "libstdc++.so.6", "libgcc_s.so.1", "libz.so.1", "libGL.so.1", + "libEGL.so.1", "libGLX.so.0", "libGLdispatch.so.0", "libOpenGL.so.0", "libdrm.so.2", + "libgbm.so.1", "libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libasound.so.2", + "libfontconfig.so.1", "libfreetype.so.6", "libharfbuzz.so.0", "libexpat.so.1", "libgmp.so.10", + "libwayland-client.so.0", "libwayland-server.so.0", "libwayland-cursor.so.0", + "libwayland-egl.so.1", "libuuid.so.1", "libgpg-error.so.0", "libfribidi.so.0", "libXau.so.6", + "libXdmcp.so.6", "libpcre2-8.so.0", "libselinux.so.1", "libmount.so.1", "libblkid.so.1", + ) + + private val rootLibraries = listOf( + "libgtk-3.so.0", "libgdk-3.so.0", "libwebkit2gtk-4.1.so.0", "libjavascriptcoregtk-4.1.so.0", + ) + + @TaskAction + fun run() { + val arch = System.getProperty("os.arch").lowercase() + val isArm = arch.contains("aarch64") || arch.contains("arm64") + val resourceDir = if (isArm) "linux-arm64" else "linux-x64" + val debArch = if (isArm) "arm64" else "amd64" + val triplet = if (isArm) "aarch64-linux-gnu" else "x86_64-linux-gnu" + val mirror = if (isArm) "http://ports.ubuntu.com/ubuntu-ports" else "http://archive.ubuntu.com/ubuntu" + + val target = outputDir.dir(resourceDir).get().asFile + val marker = File(target, ".complete") + if (marker.exists()) { + println("Linux GTK/WebKit bundle already present. Skipping download.") + return + } + for (tool in listOf("ar", "tar", "readelf", "patchelf")) { + val found = try { + ProcessBuilder(tool, "--version").redirectErrorStream(true).start() + .also { it.inputStream.readBytes() }.waitFor() == 0 + } catch (_: Exception) { + false + } + if (!found) throw GradleException("`$tool` is required to bundle GTK/WebKit on Linux but was not found on PATH.") + } + + target.deleteRecursively() + val work = File(temporaryDir, "work").apply { deleteRecursively(); mkdirs() } + val root = File(work, "root").apply { mkdirs() } + + val index = fetchPackageIndex(mirror, debArch) + for (pkg in packages) { + val filename = index[pkg] ?: throw GradleException("Package $pkg not found in the ${ubuntuRelease.get()} index") + val deb = File(work, "$pkg.deb") + println("Downloading $pkg") + URI("$mirror/$filename").toURL().openStream().use { input -> deb.outputStream().use { input.copyTo(it) } } + extractDeb(deb, root, File(work, "ar-$pkg")) + } + + val srcLib = File(root, "usr/lib/$triplet") + val libOut = File(target, "usr/lib").apply { mkdirs() } + + // Libraries by SONAME (libbz2.so.1.0 and friends don't end in a single number) + val available = HashMap() + srcLib.listFiles { f -> f.isFile && ".so" in f.name }.orEmpty().forEach { file -> + soname(file)?.let { available.putIfAbsent(it, file) } + } + + val helperSrc = File(srcLib, "webkit2gtk-4.1") + val helperOut = File(libOut, "$triplet/webkit2gtk-4.1") + val helpers = listOf("WebKitWebProcess", "WebKitNetworkProcess", "WebKitGPUProcess") + for (name in helpers) { + // copyTo drops the mode bits; WebKit spawns these, so they must stay executable + File(helperSrc, name).copyTo(File(helperOut, name), overwrite = true).setExecutable(true, false) + } + val injected = File(helperSrc, "injected-bundle/libwebkit2gtkinjectedbundle.so") + injected.copyTo(File(helperOut, "injected-bundle/${injected.name}"), overwrite = true) + val gioTls = File(srcLib, "gio/modules/libgiognutls.so") + gioTls.copyTo(File(libOut, "$triplet/gio/modules/${gioTls.name}"), overwrite = true) + + // Keep only what GTK, WebKit, their helpers and the TLS module pull in via NEEDED + val pending = ArrayDeque(rootLibraries) + listOf(*helpers.map { File(helperOut, it) }.toTypedArray(), File(helperOut, "injected-bundle/${injected.name}"), + File(libOut, "$triplet/gio/modules/${gioTls.name}")).forEach { pending += needed(it) } + val keep = LinkedHashSet() + while (pending.isNotEmpty()) { + val name = pending.removeFirst() + if (name in hostLibraries || name in keep) continue + val file = available[name] ?: continue + keep += name + pending += needed(file) + } + for (name in keep) available.getValue(name).copyTo(File(libOut, name), overwrite = true) + + patchWebKitPaths(File(libOut, "libwebkit2gtk-4.1.so.0"), triplet) + + // Libraries find each other next to themselves; the helpers and modules sit two/three levels down + libOut.listFiles { f -> f.isFile }.orEmpty().forEach { patchRpath(it, "\$ORIGIN") } + helpers.forEach { patchRpath(File(helperOut, it), "\$ORIGIN/../..") } + patchRpath(File(helperOut, "injected-bundle/${injected.name}"), "\$ORIGIN/../../..") + patchRpath(File(libOut, "$triplet/gio/modules/${gioTls.name}"), "\$ORIGIN/../../..") + + marker.writeText("") + work.deleteRecursively() + println("GTK/WebKit bundled into $target (${keep.size} libraries)") + } + + /** "/usr/lib//webkit2gtk-4.1[/injected-bundle]" -> "././lib//webkit2gtk-4.1[/...]". */ + private fun patchWebKitPaths(lib: File, triplet: String) { + val bytes = lib.readBytes() + val needle = "/usr/lib/$triplet/webkit2gtk-4.1".toByteArray() + val replacement = "././".toByteArray() + var count = 0 + var i = 0 + while (i <= bytes.size - needle.size) { + var match = true + for (j in needle.indices) { + if (bytes[i + j] != needle[j]) { match = false; break } + } + if (match) { + replacement.copyInto(bytes, i) + count++ + i += needle.size + } else { + i++ + } + } + if (count == 0) throw GradleException("WebKit helper path not found in ${lib.name}; the bundling layout needs updating") + lib.writeBytes(bytes) + } + + private fun soname(file: File): String? = + exec(file.parentFile, "readelf", "-d", file.absolutePath).lineSequence() + .firstOrNull { "(SONAME)" in it }?.substringAfter('[')?.substringBefore(']') + + private fun needed(file: File): List = + exec(file.parentFile, "readelf", "-d", file.absolutePath).lineSequence() + .filter { "(NEEDED)" in it }.map { it.substringAfter('[').substringBefore(']') }.toList() + + /** name -> pool filename; `-updates` is read first so security/bugfix builds win. */ + private fun fetchPackageIndex(mirror: String, debArch: String): Map { + val release = ubuntuRelease.get() + val index = HashMap() + for (suite in listOf("$release-updates", release)) { + for (component in listOf("main", "universe")) { + val url = URI("$mirror/dists/$suite/$component/binary-$debArch/Packages.gz").toURL() + GZIPInputStream(url.openStream()).bufferedReader().useLines { lines -> + var name: String? = null + for (line in lines) { + when { + line.startsWith("Package: ") -> name = line.removePrefix("Package: ") + line.startsWith("Filename: ") -> name?.let { index.putIfAbsent(it, line.removePrefix("Filename: ")) } + line.isEmpty() -> name = null + } + } + } + } + } + return index + } + + private fun extractDeb(deb: File, into: File, scratch: File) { + scratch.mkdirs() + exec(scratch, "ar", "x", deb.absolutePath) + val data = scratch.listFiles { f -> f.name.startsWith("data.tar") }?.singleOrNull() + ?: throw GradleException("No data archive in ${deb.name}") + // GNU tar picks the decompressor from the content (zstd needs the zstd binary installed) + exec(scratch, "tar", "-xf", data.absolutePath, "-C", into.absolutePath) + } + + private fun patchRpath(file: File, rpath: String) { + exec(file.parentFile, "patchelf", "--set-rpath", rpath, file.absolutePath) + } + + private fun exec(dir: File, vararg command: String): String { + val process = ProcessBuilder(*command).directory(dir).redirectErrorStream(true).start() + val output = process.inputStream.bufferedReader().readText() + if (process.waitFor() != 0) { + throw GradleException("`${command.joinToString(" ")}` failed:\n$output") + } + return output + } +} diff --git a/buildSrc/src/main/kotlin/org/spotube/gradle/GitCliffContextTask.kt b/buildSrc/src/main/kotlin/org/spotube/gradle/GitCliffContextTask.kt new file mode 100644 index 00000000..88d13ed0 --- /dev/null +++ b/buildSrc/src/main/kotlin/org/spotube/gradle/GitCliffContextTask.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// Writes git-cliff's changelog context (every release with its parsed, grouped commits) as JSON, using +// the repository's cliff.toml. RenderPackagingTask turns it into release notes for the package manifests. +// Needs `git-cliff` on the PATH and the full git history (CI: `fetch-depth: 0`). + +package org.spotube.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault +import javax.inject.Inject + +@DisableCachingByDefault(because = "Reads the git history, which is not a declared input") +abstract class GitCliffContextTask @Inject constructor(private val execOperations: ExecOperations) : DefaultTask() { + @get:Input + abstract val executable: Property + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val configFile: RegularFileProperty + + @get:Internal + abstract val repositoryDir: DirectoryProperty + + /** Tag for the commits since the last release (`--tag`); unset leaves them unreleased (nightlies). */ + @get:Input + @get:Optional + abstract val tag: Property + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + init { + group = "distribution" + description = "Writes the git-cliff changelog context (releases and their commits) as JSON." + executable.convention("git-cliff") + // New commits and tags change the result without touching any declared input + outputs.upToDateWhen { false } + } + + @TaskAction + fun run() { + val output = outputFile.get().asFile + output.parentFile.mkdirs() + val args = buildList { + add(executable.get()) + addAll(listOf("--config", configFile.get().asFile.absolutePath, "--context")) + tag.orNull?.let { addAll(listOf("--tag", it)) } + addAll(listOf("--output", output.absolutePath)) + } + try { + execOperations.exec { + workingDir = repositoryDir.get().asFile + commandLine(args) + } + } catch (e: Exception) { + throw GradleException( + "Running git-cliff failed (${args.joinToString(" ")}). Install it from https://git-cliff.org " + + "and make sure the full git history is available.", + e, + ) + } + } +} diff --git a/buildSrc/src/main/kotlin/org/spotube/gradle/RenderPackagingTask.kt b/buildSrc/src/main/kotlin/org/spotube/gradle/RenderPackagingTask.kt new file mode 100644 index 00000000..27b57fa3 --- /dev/null +++ b/buildSrc/src/main/kotlin/org/spotube/gradle/RenderPackagingTask.kt @@ -0,0 +1,264 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// Renders one package manager's directory (packaging/) into the build dir: `*.mustache` files are +// rendered with JMustache (suffix dropped), everything else is copied as is. +// +// Template data: +// version, tag, pkgrel, repositoryUrl +// artifacts..{fileName, url, sha256, sha256Upper} release assets found in artifactsDir +// release.{version, date, url, groups, notes} the release being packaged +// releases tagged releases, newest first (metainfo) +// groups[].{title, entries[].text} changelog grouped like cliff.toml +// #indent2 / #indent4 indent a multi-line block (YAML block scalars) +// +// Missing variables fail the render, so a manifest never ships with an empty checksum or version. +// HTML/XML escaping is on for .xml and .nuspec output only. + +package org.spotube.gradle + +import com.samskivert.mustache.Mustache +import com.samskivert.mustache.MustacheException +import groovy.json.JsonSlurper +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.security.MessageDigest +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset + +@CacheableTask +abstract class RenderPackagingTask : DefaultTask() { + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val templateDir: DirectoryProperty + + @get:Input + abstract val version: Property + + @get:Input + abstract val pkgrel: Property + + @get:Input + abstract val repositoryUrl: Property + + /** Release assets by template key, e.g. `linuxX86_64` -> `spotube-linux-6.0.0-x86_64.tar.xz`. */ + @get:Input + abstract val artifactNames: MapProperty + + /** Directory holding the downloaded release assets; only needed by templates that use `artifacts`. */ + @get:Internal + abstract val artifactsDir: DirectoryProperty + + /** The release assets that exist in [artifactsDir]; their checksums are part of the output. */ + @get:Input + protected val artifactChecksums: Map + get() = artifactFiles().mapValues { (_, file) -> sha256(file) } + + /** git-cliff `--context` output (GitCliffContextTask). */ + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + abstract val changelogContext: RegularFileProperty + + /** How many tagged releases `releases` lists. */ + @get:Input + abstract val maxReleases: Property + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + init { + group = "distribution" + pkgrel.convention("1") + maxReleases.convention(10) + } + + @TaskAction + fun render() { + val source = templateDir.get().asFile + val target = outputDir.get().asFile + target.deleteRecursively() + target.mkdirs() + + val data = templateData() + source.walkTopDown().filter { it.isFile }.forEach { file -> + val relative = file.relativeTo(source).path + if (!relative.endsWith(TEMPLATE_SUFFIX)) { + file.copyTo(target.resolve(relative), overwrite = true) + return@forEach + } + val output = target.resolve(relative.removeSuffix(TEMPLATE_SUFFIX)) + val compiler = Mustache.compiler() + .escapeHTML(output.extension in XML_EXTENSIONS) + .emptyStringIsFalse(true) + .zeroIsFalse(true) + val rendered = try { + compiler.compile(file.readText()).execute(data) + } catch (e: MustacheException) { + throw GradleException( + "Rendering $relative failed: ${e.message?.trimEnd('.')}. Templates that use `artifacts` need " + + "-PpackagingArtifactsDir=.", + e, + ) + } + output.parentFile.mkdirs() + output.writeText(rendered) + } + } + + private fun templateData(): Map { + val version = version.get() + val tag = "v$version" + val repositoryUrl = repositoryUrl.get() + val downloadUrl = "$repositoryUrl/releases/download/$tag" + + val artifacts = artifactFiles().mapValues { (_, file) -> + val sha256 = sha256(file) + mapOf( + "fileName" to file.name, + "url" to "$downloadUrl/${file.name}", + "sha256" to sha256, + "sha256Upper" to sha256.uppercase(), + ) + } + + val changelog = changelogContext.orNull?.asFile?.let(::readChangelog).orEmpty() + val tagged = changelog.filter { it.version != null }.sortedByDescending { it.timestamp } + // The release being packaged; nightlies have no tag, so they get the unreleased commits + val current = changelog.firstOrNull { it.version == tag } + ?: changelog.firstOrNull { it.version == null } + ?: ChangelogRelease(tag, Instant.now().epochSecond, emptyList()) + + return mapOf( + "version" to version, + "tag" to tag, + "pkgrel" to pkgrel.get(), + "repositoryUrl" to repositoryUrl, + "artifacts" to artifacts, + "release" to releaseData(current, version, repositoryUrl), + "releases" to tagged.take(maxReleases.get()).map { + releaseData(it, it.version!!.removePrefix("v"), repositoryUrl) + }, + "indent2" to indent(2), + "indent4" to indent(4), + ) + } + + private fun artifactFiles(): Map { + val dir = artifactsDir.orNull?.asFile ?: return emptyMap() + return artifactNames.get() + .mapValues { (_, name) -> dir.resolve(name) } + .filterValues { it.isFile } + } + + private fun releaseData(release: ChangelogRelease, version: String, repositoryUrl: String): Map { + val groups = release.commits + .groupBy { it.group } + .toSortedMap() + .map { (group, commits) -> + mapOf( + // cliff.toml orders groups with an HTML comment prefix: "Features" + "title" to group.replace(HTML_COMMENT, "").trim(), + "entries" to commits.map { mapOf("text" to it.text) }, + ) + } + val notes = groups.joinToString("\n\n") { group -> + @Suppress("UNCHECKED_CAST") + val entries = group["entries"] as List> + "${group["title"]}\n" + entries.joinToString("\n") { "- ${it["text"]}" } + } + return mapOf( + "version" to version, + "date" to LocalDate.ofInstant(Instant.ofEpochSecond(release.timestamp), ZoneOffset.UTC).toString(), + "url" to "$repositoryUrl/releases/tag/v$version", + "groups" to groups, + "notes" to notes, + ) + } + + @Suppress("UNCHECKED_CAST") + private fun readChangelog(file: File): List { + val releases = JsonSlurper().parse(file) as List> + return releases.map { release -> + val commits = (release["commits"] as List>).mapNotNull { commit -> + val group = commit["group"] as String? ?: return@mapNotNull null + val message = (commit["message"] as String).plainText().replaceFirstChar { it.uppercase() } + val scope = commit["scope"] as String? + val breaking = commit["breaking"] == true + val text = buildString { + if (breaking) append("Breaking: ") + if (scope != null) append("$scope: ") + append(message) + } + ChangelogCommit(group, text) + } + ChangelogRelease( + version = release["version"] as String?, + timestamp = (release["timestamp"] as Number?)?.toLong() ?: Instant.now().epochSecond, + commits = commits, + ) + } + } + + private data class ChangelogRelease(val version: String?, val timestamp: Long, val commits: List) + + private data class ChangelogCommit(val group: String, val text: String) + + private companion object { + const val TEMPLATE_SUFFIX = ".mustache" + val XML_EXTENSIONS = setOf("xml", "nuspec") + val HTML_COMMENT = Regex("") + val MARKDOWN_LINK = Regex("""\[([^\]]+)]\([^)]+\)""") + + // cliff.toml's commit preprocessors turn issue numbers into Markdown links + fun String.plainText() = replace(MARKDOWN_LINK, "$1").replace("**", "").trim() + + fun indent(spaces: Int) = Mustache.Lambda { fragment, out -> + val prefix = " ".repeat(spaces) + out.write(fragment.execute().lines().joinToString("\n") { if (it.isEmpty()) it else prefix + it }) + } + + fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(1 shl 16) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + } +} diff --git a/choco-struct/spotube.nuspec b/choco-struct/spotube.nuspec deleted file mode 100644 index 40941c08..00000000 --- a/choco-struct/spotube.nuspec +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - spotube - %{{SPOTUBE_VERSION}}% - https://github.com/KRTirtho/spotube/tree/master/choco-struct - Kingkor Roy Tirtho - - - - spotube (Install) - Kingkor Roy Tirtho - https://spotube.krtirtho.dev - - https://rawcdn.githack.com/KRTirtho/spotube/7edb0bb834eb18c05551e30a891720a6abf53dbe/assets/branding/spotube-logo.png - 2022 Spotube - - https://github.com/KRTirtho/spotube/blob/master/LICENSE - true - https://github.com/KRTirtho/spotube - https://spotube.krtirtho.dev - https://github.com/KRTirtho/spotube/issues/new - spotube music audio youtube flutter - 🎧 Open source music client that doesn't require Premium nor uses Electron! Available - for both desktop & mobile! - - Spotube is a Flutter based lightweight music client. It utilizes the power - of music metadata providers & Youtube's public API & creates a hazardless, performant - & resource - friendly User Experience - - # Features - - Open source/libre software - - Anonymous/guest login - - Cross platform support - - No telemetry, diagnostics or user data collection - - Lightweight & resource-friendly - - Native performance (Thanks to Flutter+Skia) - - Playback control is done locally instead of on the server - - Small size & less data usage - - No ads since it uses all public & free APIs (It is still recommended - to support the creators by watching/liking/subscribing to the artists' YouTube channels or - liking their tracks on different music platforms.) - - Time synced lyrics - - Downloadable tracks - - https://github.com/KRTirtho/spotube/releases/tag/v%{{SPOTUBE_VERSION}}% - - - - - \ No newline at end of file diff --git a/choco-struct/tools/LICENSE.txt b/choco-struct/tools/LICENSE.txt deleted file mode 100644 index 1a285107..00000000 --- a/choco-struct/tools/LICENSE.txt +++ /dev/null @@ -1,14 +0,0 @@ -From: https://github.com/KRTirtho/spotube/blob/master/LICENSE - -BSD 4-Clause License - -Copyright (c) 2025 Kingkor Roy Tirtho. All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. -3. All advertising materials mentioning features or use of this software must display the following acknowledgement: -This product includes software developed by Kingkor Roy Tirtho. -4. Neither the name of the Software nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. -THIS SOFTWARE IS PROVIDED BY KINGKOR ROY TIRTHO AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL KINGKOR ROY TIRTHO AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/choco-struct/tools/chocolateyinstall.ps1 b/choco-struct/tools/chocolateyinstall.ps1 deleted file mode 100644 index 8e84266e..00000000 --- a/choco-struct/tools/chocolateyinstall.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -$ErrorActionPreference = 'Stop'; # stop on all errors - -$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)" -$fileLocation = Join-Path $toolsDir 'Spotube-windows-x86_64-setup.exe' - -$packageArgs = @{ - packageName = $env:ChocolateyPackageName - unzipLocation = $toolsDir - fileType = 'exe' #only one of these: exe, msi, msu - file = $fileLocation - - softwareName = 'spotube*' #part or all of the Display Name as you see it in Programs and Features. It should be enough to be unique - silentArgs = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-' # Inno Setup - validExitCodes= @(0) -} - -Install-ChocolateyInstallPackage @packageArgs \ No newline at end of file diff --git a/cli/README.md b/cli/README.md deleted file mode 100644 index b2ba8ebd..00000000 --- a/cli/README.md +++ /dev/null @@ -1,4 +0,0 @@ -## Spotube Configuration CLI - -This is used for building the project for multiple platforms and having utilities specific for the project. -Written in Dart diff --git a/cli/cli.dart b/cli/cli.dart deleted file mode 100644 index 26190d4c..00000000 --- a/cli/cli.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:args/command_runner.dart'; - -import 'commands/build.dart'; -import 'commands/credits.dart'; -import 'commands/install-dependencies.dart'; -import 'commands/translated.dart'; -import 'commands/untranslated.dart'; - -void main(List args) { - final commandRunner = CommandRunner( - "cli", - "Configuration CLI for Spotube", - ); - - commandRunner.addCommand(InstallDependenciesCommand()); - commandRunner.addCommand(BuildCommand()); - commandRunner.addCommand(CreditsCommand()); - commandRunner.addCommand(TranslatedCommand()); - commandRunner.addCommand(UntranslatedCommand()); - - commandRunner.run(args); -} diff --git a/cli/commands/build.dart b/cli/commands/build.dart deleted file mode 100644 index e0c254ff..00000000 --- a/cli/commands/build.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:args/command_runner.dart'; - -import 'build/android.dart'; -import 'build/ios.dart'; -import 'build/linux.dart'; -import 'build/macos.dart'; -import 'build/windows.dart'; - -class BuildCommand extends Command { - @override - String get description => "Build for different platforms"; - - @override - String get name => "build"; - - BuildCommand() { - addSubcommand(AndroidBuildCommand()); - addSubcommand(IosBuildCommand()); - addSubcommand(LinuxBuildCommand()); - addSubcommand(MacosBuildCommand()); - addSubcommand(WindowsBuildCommand()); - argParser.addOption( - "arch", - abbr: "a", - defaultsTo: "x86", - allowed: ["x86", "arm64", "all"], - ); - } -} diff --git a/cli/commands/build/android.dart b/cli/commands/build/android.dart deleted file mode 100644 index b9edeb84..00000000 --- a/cli/commands/build/android.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:args/command_runner.dart'; -import 'package:path/path.dart'; - -import '../../core/env.dart'; -import 'common.dart'; - -class AndroidBuildCommand extends Command with BuildCommandCommonSteps { - @override - String get description => "Build for android"; - - @override - String get name => "android"; - - @override - FutureOr? run() async { - await bootstrap(); - - await shell.run( - "flutter build apk --flavor ${CliEnv.channel.name}", - ); - - final ogApkFile = File( - join( - "build", - "app", - "outputs", - "flutter-apk", - "app-${CliEnv.channel.name}-release.apk", - ), - ); - - await ogApkFile.copy( - join(cwd.path, "build", "Spotube-android-all-arch.apk"), - ); - - stdout.writeln("✅ Built Android Apk and Appbundle"); - } -} diff --git a/cli/commands/build/common.dart b/cli/commands/build/common.dart deleted file mode 100644 index c30197f5..00000000 --- a/cli/commands/build/common.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'dart:io'; - -import 'package:args/command_runner.dart'; -import 'package:path/path.dart'; -import 'package:process_run/shell_run.dart'; -import 'package:pubspec_parse/pubspec_parse.dart'; - -import '../../core/env.dart'; - -mixin BuildCommandCommonSteps on Command { - final shell = Shell(); - Directory get cwd => Directory.current; - - Pubspec? _pubspec; - - Pubspec get pubspec { - if (_pubspec != null) { - return _pubspec!; - } - - final pubspecFile = File(join(cwd.path, "pubspec.yaml")); - _pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); - - return _pubspec!; - } - - String get versionWithoutBuildNumber { - return "${pubspec.version!.major}.${pubspec.version!.minor}.${pubspec.version!.patch}"; - } - - RegExp get versionVarRegExp => - RegExp(r"\%\{\{SPOTUBE_VERSION\}\}\%", multiLine: true); - - File get dotEnvFile => File(join(cwd.path, ".env")); - - Future bootstrap() async { - await dotEnvFile.create(recursive: true); - - await dotEnvFile.writeAsString( - "${CliEnv.dotenv}\n" - "RELEASE_CHANNEL=${CliEnv.channel.name}\n", - ); - - if (CliEnv.channel == BuildChannel.nightly) { - final pubspecFile = File(join(cwd.path, "pubspec.yaml")); - - pubspecFile.writeAsStringSync( - pubspecFile.readAsStringSync().replaceAll( - "version: ${pubspec.version!.canonicalizedVersion}", - "version: $versionWithoutBuildNumber+${CliEnv.ghRunNumber}", - ), - ); - - _pubspec = null; - pubspec; - } - - await shell.run( - """ - flutter pub get - dart run build_runner build --delete-conflicting-outputs - dart pub global activate fastforge - """, - ); - } - - String get architecture => parent?.argResults?.option("arch") as String; -} diff --git a/cli/commands/build/ios.dart b/cli/commands/build/ios.dart deleted file mode 100644 index 6460f9ed..00000000 --- a/cli/commands/build/ios.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:async'; - -import 'package:args/command_runner.dart'; -import 'package:path/path.dart'; - -import '../../core/env.dart'; -import 'common.dart'; - -class IosBuildCommand extends Command with BuildCommandCommonSteps { - @override - String get description => "iOS build command"; - - @override - String get name => "ios"; - - @override - FutureOr? run() async { - await bootstrap(); - - final buildDirPath = join(cwd.path, "build", "ios", "iphoneos"); - await shell.run( - """ - flutter build ios --release --no-codesign --flavor ${CliEnv.channel.name} - ln -sf $buildDirPath Payload - zip -r9 Spotube-iOS.ipa ${join("Payload", "${CliEnv.channel.name}.app")} - """, - ); - } -} diff --git a/cli/commands/build/linux.dart b/cli/commands/build/linux.dart deleted file mode 100644 index 3ca792ea..00000000 --- a/cli/commands/build/linux.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:io/io.dart'; -import 'package:args/command_runner.dart'; -import 'package:intl/intl.dart'; -import 'package:path/path.dart'; - -import '../../core/env.dart'; -import 'common.dart'; - -class LinuxBuildCommand extends Command with BuildCommandCommonSteps { - @override - String get description => "Linux build command"; - - @override - String get name => "linux"; - - @override - FutureOr? run() async { - stdout.writeln("Replacing versions"); - - final appDataFile = File( - join(cwd.path, "linux", "com.github.KRTirtho.Spotube.appdata.xml"), - ); - - appDataFile.writeAsStringSync( - appDataFile.readAsStringSync().replaceAll( - versionVarRegExp, - '', - ), - ); - - await bootstrap(); - - await shell.run( - "fastforge package --platform=linux --targets=deb,appimage", - ); - if (architecture == "x86") { - await shell.run( - "fastforge package --platform=linux --targets=rpm", - ); - } - - final tempDir = join(Directory.systemTemp.path, "spotube-tar"); - final bundleArchName = architecture == "x86" ? "x86_64" : "aarch64"; - final bundleDirPath = join( - cwd.path, - "build", - "linux", - architecture == "x86" ? "x64" : architecture, - "release", - "bundle", - ); - - final tarFile = File(join( - cwd.path, - "dist", - "spotube-linux-" - "${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber}" - "-$bundleArchName.tar.xz", - )); - - await copyPath(bundleDirPath, tempDir); - await File(join(cwd.path, "linux", "spotube.desktop")).copy( - join(tempDir, "spotube.desktop"), - ); - await File( - join(cwd.path, "linux", "com.github.KRTirtho.Spotube.appdata.xml"), - ).copy( - join(tempDir, "com.github.KRTirtho.Spotube.appdata.xml"), - ); - await File(join(cwd.path, "assets", "branding", "spotube-logo.png")).copy( - join(tempDir, "spotube-logo.png"), - ); - - await shell.run( - "tar -cJf ${tarFile.path} -C $tempDir .", - ); - - final ogDeb = File( - join( - cwd.path, - "dist", - pubspec.version.toString(), - "spotube-${pubspec.version}-linux.deb", - ), - ); - await ogDeb.copy( - join( - cwd.path, - "dist", - "Spotube-linux-$bundleArchName.deb", - ), - ); - await ogDeb.delete(); - - if (architecture == "x86") { - final ogRpm = File( - join( - cwd.path, - "dist", - pubspec.version.toString(), - "spotube-${pubspec.version}-linux.rpm", - ), - ); - - await ogRpm.copy( - join(cwd.path, "dist", "Spotube-linux-$bundleArchName.rpm"), - ); - - await ogRpm.delete(); - } - - final ogAppImage = File( - join( - cwd.path, - "dist", - pubspec.version.toString(), - "spotube-${pubspec.version}-linux.AppImage", - ), - ); - await ogAppImage.copy( - join( - cwd.path, - "dist", - "Spotube-linux-$bundleArchName.AppImage", - ), - ); - await ogAppImage.delete(); - - stdout.writeln("✅ Linux building done"); - } -} diff --git a/cli/commands/build/macos.dart b/cli/commands/build/macos.dart deleted file mode 100644 index 936f1fc8..00000000 --- a/cli/commands/build/macos.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:args/command_runner.dart'; -import 'package:path/path.dart'; - -import 'common.dart'; - -class MacosBuildCommand extends Command with BuildCommandCommonSteps { - @override - String get description => "Macos Build command"; - - @override - String get name => "macos"; - - @override - FutureOr? run() async { - await bootstrap(); - - await shell.run( - """ - flutter build macos - appdmg appdmg.json ${join(cwd.path, "build", "Spotube-macos-universal.dmg")} - fastforge package --platform=macos --targets pkg --skip-clean - """, - ); - - final ogPkg = File( - join( - cwd.path, - "dist", - pubspec.version.toString(), - "spotube-${pubspec.version}-macos.pkg", - ), - ); - - await ogPkg.copy( - join(cwd.path, "build", "Spotube-macos-universal.pkg"), - ); - await ogPkg.delete(); - } -} diff --git a/cli/commands/build/windows.dart b/cli/commands/build/windows.dart deleted file mode 100644 index 1045c11c..00000000 --- a/cli/commands/build/windows.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'dart:io'; - -import 'package:args/command_runner.dart'; -import 'package:path/path.dart'; -import 'package:crypto/crypto.dart'; -import 'common.dart'; - -class WindowsBuildCommand extends Command with BuildCommandCommonSteps { - @override - String get description => "Build Windows exe"; - - @override - String get name => "windows"; - - Future innoDependInstall() async { - final innoDependencyPath = join(cwd.path, "build", "inno-depend"); - - await shell.run( - "git clone https://github.com/DomGries/InnoDependencyInstaller.git $innoDependencyPath", - ); - } - - @override - void run() async { - stdout.writeln("Replace versions"); - - final chocoFiles = [ - join(cwd.path, "choco-struct", "tools", "VERIFICATION.txt"), - join(cwd.path, "choco-struct", "spotube.nuspec"), - ]; - - for (final filePath in chocoFiles) { - final file = File(filePath); - final content = file.readAsStringSync(); - final newContent = - content.replaceAll(versionVarRegExp, versionWithoutBuildNumber); - - file.writeAsStringSync(newContent); - } - - await bootstrap(); - await innoDependInstall(); - - final runnerRCFile = File( - join(cwd.path, "windows", "runner", "Runner.rc"), - ); - - runnerRCFile.writeAsStringSync( - runnerRCFile - .readAsStringSync() - .replaceAll("%{{SPOTUBE_VERSION}}%", versionWithoutBuildNumber) - .replaceAll( - "%{{SPOTUBE_VERSION_AS_NUMBER}}%", - [ - pubspec.version!.major, - pubspec.version!.minor, - pubspec.version!.patch, - 0 - ].join(","), - ), - ); - - await shell.run( - "fastforge package --platform=windows --targets=exe --skip-clean", - ); - - final ogExe = File( - join( - cwd.path, - "dist", - pubspec.version.toString(), - "spotube-${pubspec.version}-windows-setup.exe", - ), - ); - - final exePath = join(cwd.path, "dist", "Spotube-windows-x86_64-setup.exe"); - - await ogExe.copy(exePath); - await ogExe.delete(); - - stdout.writeln("✅ Windows exe built at $exePath"); - - final exeFile = File(exePath); - - final hash = sha256.convert(await exeFile.readAsBytes()).toString(); - - final chocoVerificationFile = File(chocoFiles.first); - - chocoVerificationFile.writeAsStringSync( - chocoVerificationFile.readAsStringSync().replaceAll( - RegExp(r"\%\{\{WIN_SHA256\}\}\%"), - hash, - ), - ); - - await exeFile.copy( - join(cwd.path, "choco-struct", "tools", basename(exeFile.path)), - ); - - await shell.run( - "choco pack ${chocoFiles[1]} --outputdirectory ${join(cwd.path, "dist")}", - ); - - final chocoNupkg = File( - join(cwd.path, "dist", "spotube.$versionWithoutBuildNumber.nupkg"), - ); - - final distNupkgPath = join( - cwd.path, - "dist", - "Spotube-windows-x86_64.nupkg", - ); - - await chocoNupkg.copy(distNupkgPath); - await chocoNupkg.delete(); - - stdout.writeln("✅ Windows nupkg built at $distNupkgPath"); - } -} diff --git a/cli/commands/credits.dart b/cli/commands/credits.dart deleted file mode 100644 index 6bad7a44..00000000 --- a/cli/commands/credits.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'dart:io'; - -import 'package:args/command_runner.dart'; -import 'package:collection/collection.dart'; -import 'package:dio/dio.dart'; -import 'package:html/parser.dart'; -import 'package:path/path.dart'; -import 'package:pub_api_client/pub_api_client.dart'; -import 'package:pubspec_parse/pubspec_parse.dart'; - -class CreditsCommand extends Command { - final dio = Dio( - BaseOptions( - responseType: ResponseType.plain, - ), - ); - - @override - String get description => "Generate credits for used Library's authors"; - - @override - String get name => "credits"; - - @override - run() async { - final client = PubClient(); - final cwd = Directory.current; - - final pubspec = Pubspec.parse( - File(join(cwd.path, 'pubspec.yaml')).readAsStringSync(), - ); - - final allDeps = [ - ...pubspec.dependencies.entries, - ...pubspec.devDependencies.entries, - ]; - - final dependencies = allDeps - .where((d) => d.value is HostedDependency) - .map((d) => d.key) - .toSet(); - final packageInfo = await Future.wait(dependencies.map(client.packageInfo)); - - final gitDepsList = List.castFrom, - MapEntry>( - allDeps - .where((d) => d.value is GitDependency) - .map((d) => MapEntry(d.key, d.value as GitDependency)) - .toList(), - ); - - final gitDeps = gitDepsList.map( - (d) { - final uri = Uri.parse( - d.value.url.toString().replaceAll('.git', ''), - ); - return MapEntry( - d.key, - uri.replace( - pathSegments: [ - ...uri.pathSegments, - 'raw', - d.value.ref ?? 'main', - d.value.path ?? '', - 'pubspec.yaml', - ], - ).toString(), - ); - }, - ).toList(); - - final gitPubspecs = await Future.wait( - gitDeps.map( - (d) { - Pubspec parser(Response res) { - try { - return Pubspec.parse(res.data); - } catch (e) { - final document = parse(res.data); - final pre = document.querySelector('pre'); - if (pre == null) { - stdout.writeln(d.toString()); - rethrow; - } - return Pubspec.parse(pre.text); - } - } - - return dio.get(d.value).then(parser).catchError( - (_) => dio - .get(d.value.replaceFirst('/main', '/master')) - .then(parser), - ); - }, - ), - ); - - stdout.writeln( - packageInfo - .map( - (package) => - '1. [${package.name}](${package.latestPubspec.homepage ?? package.url}) - ${package.description.replaceAll('\n', '')}', - ) - .join('\n'), - ); - - stdout.writeln( - gitPubspecs.map( - (package) { - final packageUrl = package.homepage ?? - gitDepsList - .firstWhereOrNull((dep) => dep.key == package.name) - ?.value - .url - .toString(); - return '1. [${package.name}]($packageUrl) - ${package.description?.replaceAll('\n', '')}'; - }, - ).join('\n'), - ); - } -} diff --git a/cli/commands/install-dependencies.dart b/cli/commands/install-dependencies.dart deleted file mode 100644 index 56f679f1..00000000 --- a/cli/commands/install-dependencies.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'dart:async'; - -import 'package:args/command_runner.dart'; -import 'package:process_run/shell_run.dart'; - -class InstallDependenciesCommand extends Command { - @override - String get description => "Install platform dependencies"; - - @override - String get name => "install-dependencies"; - - InstallDependenciesCommand() { - argParser.addOption( - "platform", - abbr: "p", - allowed: [ - "windows", - "linux", - "linux_arm", - "macos", - "ios", - "android", - ], - mandatory: true, - ); - - argParser.addOption( - "arch", - abbr: "a", - allowed: ["x86", "arm64", "all"], - defaultsTo: "x86", - ); - } - - @override - FutureOr? run() async { - final shell = Shell(); - - final arch = argResults?.option("arch") == "x86" ? "x86_64" : "aarch64"; - - switch (argResults!.option("platform")) { - case "windows": - await shell.run( - """ - choco install innosetup -y - """, - ); - break; - case "linux": - await shell.run( - """ - sudo apt-get update -y - sudo apt-get install -y wget tar clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev libwebkit2gtk-4.1-0 libwebkit2gtk-4.1-dev libsoup-3.0-0 libsoup-3.0-dev - wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$arch.AppImage" - chmod +x appimagetool - sudo mv appimagetool /usr/local/bin/ - """, - ); - break; - case "macos": - await shell.run( - """ - brew install python-setuptools - npm install -g appdmg - """, - ); - break; - case "ios": - await shell.run( - """ - rustup target add aarch64-apple-ios - """, - ); - break; - case "android": - await shell.run( - """ - sudo apt-get update -y - sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse - """, - ); - break; - default: - break; - } - } -} diff --git a/cli/commands/translated.dart b/cli/commands/translated.dart deleted file mode 100644 index 43c4ea49..00000000 --- a/cli/commands/translated.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'dart:async'; - -import 'dart:convert'; -import 'dart:io'; -import 'package:args/command_runner.dart'; -import 'package:path/path.dart'; - -class TranslatedCommand extends Command { - @override - String get description => - "Update translation based on generated translated messages"; - - @override - String get name => "translated"; - - @override - FutureOr? run() async { - final cwd = Directory.current; - final translatedFile = jsonDecode( - await File(join(cwd.path, 'tm.json')).readAsString(), - ) as Map; - - for (final MapEntry(:key, :value) in translatedFile.entries) { - stdout.writeln('Updating locale: $key'); - final file = File(join(cwd.path, 'lib', 'l10n', 'app_$key.arb')); - - final fileContent = - jsonDecode(await file.readAsString()) as Map; - - final newContent = {...fileContent, ...value}; - - await file.writeAsString( - const JsonEncoder.withIndent(' ').convert(newContent), - ); - - stdout.writeln('✅ Updated locale: $key'); - } - } -} diff --git a/cli/commands/untranslated.dart b/cli/commands/untranslated.dart deleted file mode 100644 index dadcd8b5..00000000 --- a/cli/commands/untranslated.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:args/command_runner.dart'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:path/path.dart'; - -class UntranslatedCommand extends Command { - @override - get name => "untranslated"; - @override - get description => - "Generate Untranslated Messages for ChatGPT based Translation"; - - @override - run() async { - final cwd = Directory.current; - final file = jsonDecode( - File(join(cwd.path, 'untranslated_messages.json')).readAsStringSync(), - ) as Map; - - final englishMessages = jsonDecode( - File(join(cwd.path, 'lib', 'l10n', 'app_en.arb')).readAsStringSync(), - ) as Map; - - final messagesWithValues = {}; - - for (final MapEntry(key: locale, value: messages) in file.entries) { - messagesWithValues[locale] = Map.fromEntries( - messages - .map( - (message) => - MapEntry(message, englishMessages[message]), - ) - .toList() - .cast>(), - ); - } - - stdout.writeln( - "Prompt:\n" - "Translate following to their appropriate locale for flutter arb translations files." - " Put the respective new translations in a map of their corresponding locale.", - ); - stdout.writeln( - const JsonEncoder.withIndent(' ').convert(messagesWithValues), - ); - } -} diff --git a/cli/core/env.dart b/cli/core/env.dart deleted file mode 100644 index 33cc5df1..00000000 --- a/cli/core/env.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'dart:io'; - -enum BuildChannel { - stable, - nightly; - - factory BuildChannel.fromEnvironment(String name) { - final channel = Platform.environment[name]!; - if (channel == "stable") { - return BuildChannel.stable; - } else if (channel == "nightly") { - return BuildChannel.nightly; - } else { - throw Exception("Invalid channel: $channel"); - } - } -} - -class CliEnv { - static final channel = BuildChannel.fromEnvironment("CHANNEL"); - static final dotenv = Platform.environment["DOTENV"]!; - static final ghRunNumber = Platform.environment["GITHUB_RUN_NUMBER"]; - static final flutterVersion = Platform.environment["FLUTTER_VERSION"]!; -} diff --git a/cliff.toml b/cliff.toml index 311a7d7c..1c98d090 100644 --- a/cliff.toml +++ b/cliff.toml @@ -27,7 +27,7 @@ render_always = true # An array of regex based postprocessors to modify the changelog. postprocessors = [ # Replace the placeholder with a URL. - { pattern = '', replace = "https://github.com/KRTirtho/spotube" }, + { pattern = '', replace = "https://github.com/team-spotube/spotube" }, ] # render body even when there are no releases to process # render_always = true diff --git a/composeApp/Cargo.lock b/composeApp/Cargo.lock new file mode 100644 index 00000000..d8199d57 --- /dev/null +++ b/composeApp/Cargo.lock @@ -0,0 +1,897 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "askama" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.119", +] + +[[package]] +name = "askama_parser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow", +] + +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compose-app" +version = "0.1.0" +dependencies = [ + "discord-rich-presence", + "lofty", + "log", + "parking_lot", + "thiserror", + "tokio", + "uniffi", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "discord-rich-presence" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494" +dependencies = [ + "log", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "thiserror", + "uuid", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "lofty" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec4feeff6c7d75093278133a06e827d7af6d2bfe20b0f331f9d10338a5ec7ca" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "ogg_pager" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" +dependencies = [ + "byteorder", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uniffi" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3291800a6b06569f7d3e15bdb6dc235e0f0c8bd3eb07177f430057feb076415f" +dependencies = [ + "anyhow", + "cargo_metadata", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a04b99fa7796eaaa7b87976a0dbdd1178dc1ee702ea00aca2642003aef9b669e" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98" +dependencies = [ + "anyhow", + "async-compat", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09acd2ce09c777dd65ee97c251d33c8a972afc04873f1e3b21eb3492ade16933" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uniffi_macros" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5596f178c4f7aafa1a501c4e0b96236a96bc2ef92bdb453d83e609dad0040152" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beadc1f460eb2e209263c49c4f5b19e9a02e00a3b2b393f78ad10d766346ecff" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd76b3ac8a2d964ca9fce7df21c755afb4c77b054a85ad7a029ad179cc5abb8a" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4319cf905911d70d5b97ce0f46f101619a22e9a189c8c46d797a9955e9233716" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/composeApp/Cargo.toml b/composeApp/Cargo.toml new file mode 100644 index 00000000..85f1f4dd --- /dev/null +++ b/composeApp/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "compose-app" +version = "0.1.0" +edition = "2024" + +[dependencies] +uniffi = { version = "0.29.4", features = ["tokio"] } +lofty = "0.24.0" +discord-rich-presence = "1.1.0" +thiserror = "2.0" +parking_lot = "0.12" +log = "0.4" +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } + +[lib] +crate-type = ["cdylib", "staticlib"] +path = "src/commonMain/rust/lib.rs" + +[profile.release] +strip = true +lto = true +codegen-units = 1 diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts new file mode 100644 index 00000000..fb121190 --- /dev/null +++ b/composeApp/build.gradle.kts @@ -0,0 +1,691 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import gobley.gradle.GobleyHost +import gobley.gradle.Variant +import gobley.gradle.cargo.dsl.jvm +import gobley.gradle.rust.targets.RustAndroidTarget +import dev.nucleusframework.desktop.application.dsl.NativeImageOptimization +import dev.nucleusframework.desktop.application.dsl.TargetFormat +import org.jetbrains.compose.reload.gradle.ComposeHotRun +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.gradle.api.tasks.Copy +import org.spotube.gradle.DownloadLinuxGtkBundleTask +import org.spotube.gradle.GitCliffContextTask +import org.spotube.gradle.RenderPackagingTask +import java.io.FileInputStream +import java.util.Properties + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.composeHotReload) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.zipline.gradle.plugin) + alias(libs.plugins.vlcjBundler) + alias(libs.plugins.uniffi) + alias(libs.plugins.cargo) + alias(libs.plugins.mokkery) + alias(libs.plugins.neucleusFramework) + kotlin("plugin.atomicfu") version libs.versions.kotlin +} + +val bundleId = "org.spotube.spotube" +val version = "6.0.0" +val buildNumber = 46 +// Desktop icon channel; Android picks its icons via the stable/nightly product flavor. Use -Pnightly=true. +val isNightly = providers.gradleProperty("nightly").map { it.toBoolean() }.getOrElse(false) +val desktopIconDir = if (isNightly) "icons/nightly" else "icons/stable" +// Nightlies are numbered by the CI run (-PbuildNumber=, or GITHUB_RUN_NUMBER) so every build is traceable to its run. +val nightlyBuildNumber = providers.gradleProperty("buildNumber").orNull?.toIntOrNull() + ?: System.getenv("GITHUB_RUN_NUMBER")?.toIntOrNull() + ?: buildNumber +// Production = shipping artifacts (installers, distributables, native images). Size/speed +// optimizations that slow builds down (Rust release + LTO) only apply then, never to `run`/dev. +// Override with -Pproduction=true|false. +val isProductionBuild = providers.gradleProperty("production").map { it.toBoolean() }.getOrElse( + gradle.startParameter.taskNames.any { task -> + task.substringAfterLast(':').let { name -> + name.startsWith("package") || name.startsWith("create") || + name.startsWith("notarize") || name == "nativeImageCompile" + } + } +) +val targetName = "Spotube" +val homePageUrl = "https://spotube.org" +val repositoryUrl = "https://github.com/team-spotube/spotube" +val appVersionName = version +val appBuildNumber = if (isNightly) nightlyBuildNumber else buildNumber +val appRepositoryUrl = repositoryUrl +// CI reads the release version from here instead of scraping this file. +tasks.register("printVersion") { + val appVersion = appVersionName + doLast { println(appVersion) } +} + +// Package manager manifests: packaging/ rendered into build/packaging/ with the version, the +// release assets' checksums (-PpackagingArtifactsDir=) and the git-cliff changelog. See RenderPackagingTask. +val gitCliffContext = tasks.register("gitCliffContext") { + configFile.set(rootProject.layout.projectDirectory.file("cliff.toml")) + repositoryDir.set(rootProject.layout.projectDirectory) + // Nightlies are not a release: their commits stay unreleased + if (!isNightly) tag.set("v$appVersionName") + outputFile.set(layout.buildDirectory.file("packaging/changelog-context.json")) +} +val packagingRootDir = rootProject.layout.projectDirectory +val packagingArtifactsDir = packagingRootDir.dir(providers.gradleProperty("packagingArtifactsDir")) +listOf("linux", "aur", "flatpak", "winget", "chocolatey").forEach { target -> + tasks.register("renderPackaging${target.replaceFirstChar { it.uppercase() }}") { + description = "Renders packaging/$target into build/packaging/$target." + templateDir.set(packagingRootDir.dir("packaging/$target")) + version.set(appVersionName) + pkgrel.set(providers.gradleProperty("pkgrel").orElse("1")) + repositoryUrl.set(appRepositoryUrl) + artifactNames.set( + mapOf( + "linuxX86_64" to "spotube-linux-$appVersionName-x86_64.tar.xz", + "linuxAarch64" to "spotube-linux-$appVersionName-aarch64.tar.xz", + "windowsX64" to "Spotube-windows-x86_64-setup.exe", + ) + ) + artifactsDir.set(packagingArtifactsDir) + changelogContext.set(gitCliffContext.flatMap { it.outputFile }) + outputDir.set(layout.buildDirectory.dir("packaging/$target")) + } +} +val githubRepositoryPath = repositoryUrl.removePrefix("https://github.com/") +val latestReleaseApiUrl = "https://api.github.com/repos/$githubRepositoryPath/releases/latest" +val generatedAppInfoDir = layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin") +val generateAppBuildInfo by tasks.registering(Copy::class) { + description = "Generate AppBuildInfo.kt" + from(layout.projectDirectory.file("src/commonMain/templates/AppBuildInfo.kt.template")) + into(layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin/org/spotube/spotube")) + rename("AppBuildInfo.kt.template", "AppBuildInfo.kt") + expand( + mapOf( + "appVersion" to appVersionName, + "appBuildNumber" to appBuildNumber, + "repositoryUrl" to appRepositoryUrl, + "latestReleaseApiUrl" to latestReleaseApiUrl, + ) + ) + inputs.properties( + mapOf( + "appVersion" to appVersionName, + "appBuildNumber" to appBuildNumber, + "repositoryUrl" to appRepositoryUrl, + "latestReleaseApiUrl" to latestReleaseApiUrl, + ) + ) +} + +tasks.configureEach { + if (name.startsWith("compile") && name.contains("Kotlin")) { + dependsOn(generateAppBuildInfo) + } +} + +// Windows/macOS always bundle VLC. On Linux only the self-contained AppImage (-PappImage=true) bundles +// VLC, GTK and WebKitGTK; deb/rpm use the distro's packages. +val isLinuxHost = System.getProperty("os.name").lowercase().contains("linux") +val bundleForAppImage = isLinuxHost && providers.gradleProperty("appImage").map { it.toBoolean() }.getOrElse(false) + +vlcjBundler { + packageName = "$bundleId.core.generated" // choose a different package + objectName = "VLCBundleLoaderGenerated" // or rename the object + bundleVlc = !isLinuxHost || bundleForAppImage +} + +// GTK 3 + WebKitGTK for the AppImage (see DownloadLinuxGtkBundleTask in buildSrc) +val downloadLinuxGtkBundle = tasks.register("downloadLinuxGtkBundle") { + outputDir.set(layout.buildDirectory.dir("linux-gtk-bundle")) + ubuntuRelease.set("noble") +} +val linuxGtkBundleDir: Provider = downloadLinuxGtkBundle.flatMap { it.outputDir } + +// One resources root holding VLC and GTK/WebKit, laid out for the AppImage +val assembleAppImageResources = tasks.register("assembleAppImageResources") { + from(vlcjBundler.vlcNativesDirectory) + from(linuxGtkBundleDir) + exclude("**/.complete") + into(layout.buildDirectory.dir("appimage-resources")) +} + + +kotlin { + // Note: For Android application modules, androidTarget() is still required as of AGP 8.x. + // The deprecation warning is expected. For libraries, use the androidKotlinMultiplatformLibrary plugin instead. + // Full migration support for applications will be available in AGP 9.0.0+ + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } + + val iosArm64Target = iosArm64() + val iosSimulatorArm64Target = iosSimulatorArm64() + + listOf(iosArm64Target, iosSimulatorArm64Target).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "ComposeApp" + isStatic = true + } + } + + jvm() + jvmToolchain(21) + + sourceSets { + val commonMain by getting { + kotlin.srcDir(generatedAppInfoDir) + dependencies { + // Project dependencies + implementation(project(":plugin_interfaces")) + + // Jetpack Compose dependencies + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.coil.compose) + implementation(libs.coil.network.ktor3) + implementation(libs.compose.components.resources) + implementation(libs.compose.uiToolingPreview) + implementation(libs.androidx.lifecycle.viewmodelCompose) + implementation(libs.androidx.lifecycle.runtimeCompose) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) + implementation(libs.dns.sd.kt) + + // Millennium UI + implementation(libs.millennium) + + // Navigation + implementation(libs.jetbrains.navigation3.ui) + implementation(libs.jetbrains.lifecycle.viewmodelNavigation3) + + // koin + api(libs.koin.core) + implementation(libs.koin.compose) + implementation(libs.koin.compose.viewmodel) + implementation(libs.koin.compose.navigation3) + + // 3rd party libraries + // ktor + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.logging) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.serialization.kotlinx.json) + implementation(libs.ktor.client.websockets) + implementation(libs.ktor.server.core) + implementation(libs.ktor.server.cio) + implementation(libs.ktor.server.websockets) + // Zipline + api(libs.zipline.core) + implementation(libs.zipline.loader) + // file-kit + implementation(libs.filekit.core) + implementation(libs.filekit.dialogs) + implementation(libs.filekit.dialogs.compose) + + // icons + implementation(libs.feather.icons) + + // androidx-datastore + implementation(libs.androidx.datastore) + implementation(libs.androidx.datastore.preferences) + // kmp-zip + implementation(libs.kmp.zip) + implementation(libs.kmp.zip.okio) + implementation(libs.kmp.zip.kotlinx) + implementation(libs.murmurhash) + // logging + implementation(libs.kermit) + implementation(libs.kermit.koin) + + implementation(libs.semver) + + // crypto + implementation(libs.cryptography.core) + implementation(libs.cryptography.provider.optimal) + + // webview + implementation(libs.compose.webview) + + // blur + implementation(libs.haze) + implementation(libs.haze.blur) + implementation(libs.haze.materials) + + // caching + implementation(libs.cache4k) + + // reorderable list + implementation(libs.reorderable) + + // Shimmer effect + implementation(libs.compose.shimmer) + + // DLNA + implementation(libs.dns.sd.kt) + + // mqtt client for jam-session + implementation(libs.mqtt.client) + implementation(libs.mqtt.x.models) + implementation(libs.mqtt.buffer) + implementation(libs.mqtt.buffer.codec) + + // Markdown renderer + implementation(libs.multiplatform.markdown.renderer) + implementation(libs.multiplatform.markdown.renderer.m3) + } + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + // Shared code between mobile targets (Android + iOS) + val mobileMain by creating { + dependsOn(commonMain) + dependencies { +// implementation(libs.kmedia) + } + } + // Shared code between Java compatible targets (Android + Desktop) + val androidJvmMain by creating { + dependsOn(commonMain) + dependencies { + implementation(libs.newpipeextractor) + implementation(libs.ktor.client.okhttp) + } + } + androidMain { + dependsOn(mobileMain) + dependsOn(androidJvmMain) + dependencies { + implementation(libs.compose.uiToolingPreview) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.car.app) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.media3.common) + implementation(libs.media3.exoplayer) + implementation(libs.media3.session) + } + } + val iosMain by creating { + dependsOn(mobileMain) + dependencies { + // 3rd party libraries + implementation(libs.ktor.client.darwin) + implementation(libs.newpipe.extractor.kmp) + } + } + val iosArm64Main by getting { + dependsOn(iosMain) + } + val iosSimulatorArm64Main by getting { + dependsOn(iosMain) + } + jvmMain { + dependsOn(androidJvmMain) + dependencies { + // Jetpack Compose dependencies + implementation(compose.desktop.currentOs) + implementation(libs.kotlinx.coroutinesSwing) + + // vlcj dependencies + implementation(libs.vlcj) + implementation(libs.vlcj.natives) + + implementation(libs.appdirs) + implementation(libs.jna) + + implementation(libs.nucleus.core.runtime) + implementation(libs.nucleus.graalvm.runtime) + implementation(libs.nucleus.nucleus.application) + implementation(libs.nucleus.decorated.window.tao) + implementation(libs.compose.native.tray) + implementation(libs.nucleus.media.control) + } + } + } +} + +android { + namespace = "org.spotube.spotube" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + applicationId = bundleId + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = buildNumber + versionName = version + } + // stable = production channel, nightly = beta channel (own icon, name and installable side by side) + flavorDimensions += "channel" + productFlavors { + create("stable") { + dimension = "channel" + } + create("nightly") { + dimension = "channel" + applicationIdSuffix = ".nightly" + versionCode = nightlyBuildNumber + versionNameSuffix = "-nightly.$nightlyBuildNumber" + } + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + val secretProps = Properties().apply { + val propertiesFile = project.file("local.properties") + if (propertiesFile.exists()) { + load(FileInputStream(propertiesFile)) + } + } + + signingConfigs { + create("release") { + val storeFilePath = secretProps.getProperty("release.signing.storeFile") + if (!storeFilePath.isNullOrEmpty()) { + storeFile = file(storeFilePath) + storePassword = secretProps.getProperty("release.signing.storePassword") + keyAlias = secretProps.getProperty("release.signing.keyAlias") + keyPassword = secretProps.getProperty("release.signing.keyPassword") + } + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("release") + } + getByName("debug") { + // Debug and release share the application id, so sign both with the release key + // when it's configured; otherwise one can't be installed over the other. + // Without local.properties signing (contributors) keep the default debug keystore. + if (signingConfigs.getByName("release").storeFile != null) { + signingConfig = signingConfigs.getByName("release") + } + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true + } + +} + +// ditchoom's socket-quic-quiche ships libquiche_jni.so with 4 KB LOAD segment alignment +// (not 16 KB compatible), even in the latest release. It is only an optional QUIC transport +// pulled in transitively by mqtt-client, so drop it on Android until upstream fixes it. +configurations.matching { it.name.startsWith("debug") || it.name.startsWith("release") }.configureEach { + exclude(group = "com.ditchoom", module = "socket-quic-quiche") + exclude(group = "com.ditchoom", module = "socket-quic-quiche-android") +} + +dependencies { + coreLibraryDesugaring(libs.desugar.jdk.libs) + debugImplementation(libs.compose.uiTooling) +} + +nucleus.application { + mainClass = "org.spotube.spotube.MainKt" + + jvmArgs += listOf( + "--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work + // --- ADD THESE FOR SPEED & LOW RAM --- + "-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront) + "-Xmx384m", // Cap the max heap (VLC and WebView need a bit of room) + "-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup! + "-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB + ) + + nativeDistributions { + packageName = bundleId + // User-facing name (.app bundle, DMG productName, Launchpad/Dock); packageName above is the reverse-DNS id. + // The GraalVM DMG task reads only appName ?: packageName, not macOS.packageName + appName = if (isNightly) "Spotube Nightly" else "Spotube" + packageVersion = version + licenseFile = project.file("../LICENSE") + + appResourcesRootDir.set( + if (bundleForAppImage) assembleAppImageResources.map { layout.buildDirectory.dir("appimage-resources").get() } else vlcjBundler.vlcNativesDirectory + ) + + targetFormats( + TargetFormat.Dmg, + TargetFormat.Deb, + TargetFormat.Rpm, + TargetFormat.AppImage, + TargetFormat.Msi, + TargetFormat.Exe, + ) + + modules("jdk.unsupported") + + homepage = homePageUrl + + linux { + iconFile.set(project.file("$desktopIconDir/icon.png")) + modules("jdk.security.auth") + debMaintainer = "Team Spotube " + rpmLicenseType = "AGPL-3.0-or-later" + // deb/rpm use the distro's shared libraries (only the AppImage bundles VLC). VLC is not + // bundled on Linux otherwise, the webview needs WebKitGTK 4.1, and the window/tray/MPRIS + // natives link GTK 3, GLib/GIO and D-Bus. Setting these replaces electron-builder's + // Electron-specific defaults (libnss3, libnotify, ...). + debDepends = listOf( + "libvlc5", + "vlc-plugin-base", + "libwebkit2gtk-4.1-0", + "libgtk-3-0 | libgtk-3-0t64", + "libglib2.0-0 | libglib2.0-0t64", + "libdbus-1-3", + "libasound2 | libasound2t64", + "libgl1", + "libfontconfig1", + "libfreetype6", + "libx11-6", + "libxext6", + "libxi6", + "libxrender1", + "libxtst6", + ) + // vlc-libs comes from RPM Fusion (Fedora ships no VLC); the README tells users to enable it + rpmRequires = listOf( + "vlc-libs", + "webkit2gtk4.1", + "gtk3", + "glib2", + "dbus-libs", + "alsa-lib", + "mesa-libGL", + "fontconfig", + "freetype", + "libX11", + "libXext", + "libXi", + "libXrender", + "libXtst", + ) + } + windows { + iconFile.set(project.file("$desktopIconDir/icon.ico")) + shortcut = true + menu = true + dirChooser = true + perUserInstall = true + } + + macOS { + iconFile.set(project.file("$desktopIconDir/icon.icns")) + bundleID = if (isNightly) "$bundleId.nightly" else bundleId + dockName = appName + notarization { + + } + } + } + + buildTypes.release.proguard { + configurationFiles.from(project.file("proguard-rules.pro")) + } + + // AOT-compiled native binary (no bundled JVM). Build with `packageGraalvmNative`, try it with + // `runGraalvmNative`. Nucleus downloads GraalVM CE on first use (or uses $GRAALVM_HOME). + // Reflection/JNI metadata the automatic passes miss (vlcj, Zipline, NewPipe/Rhino, uniffi) + // lives in `graalvm/` and is (re)collected by exercising the app under `runWithNativeAgent`. + graalvm { + isEnabled = true + imageName = "spotube" + nativeImageConfigBaseDir.set(project.layout.projectDirectory.dir("graalvm")) + // ProGuard is not used for native images; -Os is the size equivalent + optimization = NativeImageOptimization.SIZE + // Same ceiling as the JVM distribution's -Xmx + maxHeapSize = "384m" + // Skiko is loaded from lib/ next to the binary (Nucleus copies it there); don't embed + // a second ~30 MB copy as a resource + buildArgs.add("-H:+UnlockExperimentalVMOptions") + buildArgs.add("-H:ExcludeResources=.*skiko-[^/]*\\.(so|dylib|dll)") + } +} + +// The uber JAR is what the GraalVM build compiles from, and native-image embeds every registered +// resource in the binary. Libraries ship natives for every OS/CPU, so keep only the host's +// (the uber JAR is per-OS/arch already; it isn't used by `run`). +tasks.withType().matching { it.name.endsWith("UberJarForCurrentOS") }.configureEach { + // Everything the exclude closure uses is local: the configuration cache can't serialize + // references to the build script object. + val hostOs = System.getProperty("os.name").lowercase() + val foreignExtensions = when { + hostOs.contains("win") -> setOf("so", "dylib", "jnilib", "a") + hostOs.contains("mac") -> setOf("so", "dll", "a") + else -> setOf("dll", "dylib", "jnilib", "a") + } + val nativeExtensions = setOf("so", "dll", "dylib", "jnilib", "a") + // CPU names as libraries spell them in their native folders (linux-x86-64, jni/amd64, darwin-aarch64, …) + val cpuDirToken = Regex( + "(?:^|[-_])(x86[-_]64|amd64|x64|aarch64|arm64|armv6|armv7|armel|arm|x86|i[36]86|ppc64le|ppc64|ppc" + + "|s390x|riscv64|loongarch64|mips64el|sparcv9|sparc)(?=$|[-_])" + ) + val hostCpuTokens = when (System.getProperty("os.arch").lowercase()) { + "aarch64", "arm64" -> setOf("aarch64", "arm64") + else -> setOf("x86-64", "x86_64", "amd64", "x64") + } + // OS flavours we never ship desktop builds for (sqlite-jdbc and JNA bundle these) + val foreignOsDirToken = Regex("android|musl|freebsd|openbsd|dragonflybsd|aix|sunos") + exclude { element -> + val segments = element.path.lowercase().split('/') + val extension = segments.last().substringAfterLast('.', "") + !element.isDirectory && extension in nativeExtensions && ( + extension in foreignExtensions || + segments.dropLast(1).any { dir -> + foreignOsDirToken.containsMatchIn(dir) || + cpuDirToken.findAll(dir).any { it.groupValues[1] !in hostCpuTokens } + } + ) + } + // The locally built compose-webview SNAPSHOT carries stale CEF binaries (~1.3 GB unstripped + // libcef.so) that no code loads (Linux uses WebKitGTK). + exclude( + "nucleus/native/*/libcef.*", + "nucleus/native/*/libcompose_cef_*", + "nucleus/native/*/cef_subprocess*", + "nucleus/native/*/*.pak", + "nucleus/native/*/icudtl.dat", + "nucleus/native/*/v8_context_snapshot.bin", + ) +} + +// Linux: native-image emits 13 KB libjvm.so/libjava.so shims (the image itself provides the JVM +// symbols), but Nucleus copies the JDK's real ones next to the binary — libjvm.so alone is 28 MB. +// Ship native-image's own outputs instead: skip the libjvm copy, and in the AWT copy prefer +// nativeCompile's version of any library native-image produced over the JDK's. +tasks.matching { it.name == "copyGraalvmJvmSo" }.configureEach { enabled = false } +tasks.withType().matching { it.name == "copyGraalvmAwtSoLibs" }.configureEach { + val nativeCompileDir = layout.buildDirectory.dir("compose/tmp/main/graalvm/nativeCompile").get().asFile + from(nativeCompileDir) { include("*.so") } + eachFile { + if (file.parentFile != nativeCompileDir && nativeCompileDir.resolve(name).exists()) exclude() + } +} + +// Issue with compose plugin +// https://github.com/JetBrains/compose-hot-reload/blob/master/docs/Known_limitations.md#property-composeapplicationresourcesdir-is-null-when-running-hot-reload-tasks +tasks.withType().configureEach { + // Populates the resources dir below with the VLC natives (appResourcesRootDir); hot run doesn't depend on it by default + dependsOn("prepareAppResources") + jvmArgs( + "--enable-native-access=ALL-UNNAMED" + ) + systemProperty( + "compose.application.resources.dir", + project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get() + ) +} + +cargo { + // Gobley defaults desktop to the debug profile, which ships an unstripped ~31 MB library + if (isProductionBuild) jvmVariant = Variant.Release + builds.jvm { + embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget) + } +} + +// Gobley adds build/intermediates/rust// as a jniLibs source dir, but AGP leaves source dirs +// that don't exist at configuration time out of the merge inputs. On a clean build (CI) libcompose_app.so would +// then silently miss the APK and crash at runtime with UnsatisfiedLinkError, so create the dirs up front. +RustAndroidTarget.entries.forEach { target -> + Variant.entries.forEach { variant -> + layout.buildDirectory.dir("intermediates/rust/${target.rustTriple}/$variant").get().asFile.mkdirs() + } +} + +// Google only ships x86_64 Linux NDK toolchains, so on a Linux arm64 host the Android Rust targets can never +// link (clang: "Exec format error"). Desktop packaging doesn't need them; Android is built on x86_64 runners. +run { + val isLinuxArm64Host = System.getProperty("os.name").lowercase().contains("linux") && + System.getProperty("os.arch").lowercase() in setOf("aarch64", "arm64") + if (isLinuxArm64Host) { + // Gobley generates the UniFFI bindings from the first Android build's library by default; use the host's. + uniffi { + generateFromLibrary { + build = GobleyHost.current.rustTarget + } + } + tasks.matching { it.name.startsWith("cargoBuildAndroid") || it.name.startsWith("rustUpTargetAddAndroid") } + .configureEach { enabled = false } + } +} + +// Nucleus' resource copies (prepareAppResources, copyGraalvmAppResources) drop the execute bit, but WebKitGTK +// spawns its helper processes from the AppImage bundle (DownloadLinuxGtkBundleTask in buildSrc) and fails with +// "Permission denied" otherwise. +tasks.matching { it.name == "prepareAppResources" || it.name == "copyGraalvmAppResources" }.configureEach { + (this as? org.gradle.api.tasks.AbstractCopyTask)?.filesMatching("**/webkit2gtk-4.1/WebKit*Process") { + permissions { unix("rwxr-xr-x") } + } +} diff --git a/composeApp/graalvm/reachability-metadata.json b/composeApp/graalvm/reachability-metadata.json new file mode 100644 index 00000000..6d8dfc7c --- /dev/null +++ b/composeApp/graalvm/reachability-metadata.json @@ -0,0 +1,1332 @@ +{ + "reflection": [ + { + "type": "uk.co.caprica.vlcj.binding.lib.LibVlc", + "jniAccessible": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "androidx.compose.ui.platform.GlobalSnapshotManager$Registration" + }, + { + "type": "androidx.datastore.preferences.PreferencesProto$PreferenceMap", + "fields": [ + { + "name": "preferences_" + } + ] + }, + { + "type": "androidx.datastore.preferences.PreferencesProto$Value", + "fields": [ + { + "name": "valueCase_" + }, + { + "name": "value_" + } + ] + }, + { + "type": "app.cash.zipline.InterruptHandler", + "jniAccessible": true, + "methods": [ + { + "name": "poll", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "app.cash.zipline.QuickJsException", + "jniAccessible": true, + "methods": [ + { + "name": "", + "parameterTypes": [ + "java.lang.String", + "java.lang.String" + ] + } + ] + }, + { + "type": "app.cash.zipline.internal.bridge.CallChannel", + "jniAccessible": true, + "methods": [ + { + "name": "call", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "disconnect", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, + { + "type": "com.sun.crypto.provider.HKDFKeyDerivation$HKDFSHA384", + "methods": [ + { + "name": "", + "parameterTypes": [ + "javax.crypto.KDFParameters" + ] + } + ] + }, + { + "type": "com.sun.crypto.provider.HmacCore$HmacSHA384", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "com.sun.jna.CallbackReference$AttachOptions", + "jniAccessible": true, + "fields": [ + { + "name": "daemon" + }, + { + "name": "detach" + }, + { + "name": "name" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "com.sun.jna.Union" + }, + { + "type": "dev.nucleusframework.window.tao.event.TaoSyntheticKeyKt$SyntheticAwtKeyEventSource$1" + }, + { + "type": "dev.nucleusframework.window.tao.event.TaoSyntheticMouseWheelEvent$source$1" + }, + { + "type": "io.ktor.client.plugins.DefaultRequest" + }, + { + "type": "io.ktor.client.plugins.HttpTimeoutConfig" + }, + { + "type": "io.ktor.client.plugins.websocket.WebSockets" + }, + { + "type": "io.ktor.server.application.PluginInstance" + }, + { + "type": "io.ktor.server.plugins.MutableOriginConnectionPoint" + }, + { + "type": "io.ktor.utils.io.ByteReadChannel" + }, + { + "type": "java.lang.Boolean", + "jniAccessible": true, + "fields": [ + { + "name": "TYPE" + }, + { + "name": "value" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + "boolean" + ] + }, + { + "name": "booleanValue", + "parameterTypes": [ + + ] + }, + { + "name": "getBoolean", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "valueOf", + "parameterTypes": [ + "boolean" + ] + } + ] + }, + { + "type": "java.lang.Double", + "jniAccessible": true, + "fields": [ + { + "name": "TYPE" + }, + { + "name": "value" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + "double" + ] + }, + { + "name": "valueOf", + "parameterTypes": [ + "double" + ] + } + ] + }, + { + "type": "java.lang.Integer", + "jniAccessible": true, + "fields": [ + { + "name": "TYPE" + }, + { + "name": "value" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + "int" + ] + }, + { + "name": "valueOf", + "parameterTypes": [ + "int" + ] + } + ] + }, + { + "type": "java.lang.Module", + "methods": [ + { + "name": "getDescriptor", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "java.lang.StackWalker", + "methods": [ + { + "name": "getInstance", + "parameterTypes": [ + "java.lang.StackWalker$Option" + ] + }, + { + "name": "walk", + "parameterTypes": [ + "java.util.function.Function" + ] + } + ] + }, + { + "type": "java.lang.StackWalker$StackFrame", + "methods": [ + { + "name": "getDeclaringClass", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "kotlin.Any" + }, + { + "type": "kotlin.Boolean" + }, + { + "type": "kotlin.Cloneable" + }, + { + "type": "kotlin.Function0" + }, + { + "type": "kotlin.Function2" + }, + { + "type": "kotlin.Function3" + }, + { + "type": "kotlin.Int" + }, + { + "type": "kotlin.Long" + }, + { + "type": "kotlin.String" + }, + { + "type": "kotlin.Throwable" + }, + { + "type": "kotlin.collections.List" + }, + { + "type": "kotlin.collections.Map" + }, + { + "type": "kotlinx.atomicfu.AtomicBoolean" + }, + { + "type": "kotlinx.io.Source" + }, + { + "type": "org.freedesktop.dbus.connections.impl.DBusConnection", + "methods": [ + { + "name": "addGenericSigHandler", + "parameterTypes": [ + "org.freedesktop.dbus.matchrules.DBusMatchRule", + "org.freedesktop.dbus.interfaces.DBusSigHandler" + ] + } + ] + }, + { + "type": "org.robolectric.Robolectric" + }, + { + "type": "org.spotube.spotube.core.server.StreamProxy", + "methods": [ + { + "name": "respondWithUpstream", + "parameterTypes": [ + "io.ktor.server.application.ApplicationCall", + "io.ktor.client.statement.HttpResponse", + "java.lang.String", + "io.ktor.http.HttpMethod", + "kotlin.coroutines.Continuation" + ] + } + ] + }, + { + "type": "org.spotube.spotube.core.server.StreamProxy$respondWithUpstream$5" + }, + { + "type": "org.spotube.spotube.core.zipline.ZiplinePluginService$use$2", + "fields": [ + { + "name": "label" + } + ] + }, + { + "type": "sun.net.www.protocol.http.Handler", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "sun.net.www.protocol.https.Handler", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "sun.security.rsa.RSASignature$SHA384withRSA", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "sun.text.resources.cldr.FormatData_en" + }, + { + "type": "sun.text.resources.cldr.FormatData_en_US" + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_callback_t", + "jniAccessible": true, + "methods": [ + { + "name": "callback", + "parameterTypes": [ + "uk.co.caprica.vlcj.binding.internal.libvlc_event_t", + "com.sun.jna.Pointer" + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_dialog_id", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_equalizer_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_event_manager_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_event_t", + "fields": [ + { + "name": "obj" + }, + { + "name": "type" + }, + { + "name": "u" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_event_u", + "fields": [ + { + "name": "media_duration_changed" + }, + { + "name": "media_freed" + }, + { + "name": "media_list_item_added" + }, + { + "name": "media_list_item_deleted" + }, + { + "name": "media_list_player_next_item_set" + }, + { + "name": "media_list_will_add_item" + }, + { + "name": "media_list_will_delete_item" + }, + { + "name": "media_meta_changed" + }, + { + "name": "media_parsed_changed" + }, + { + "name": "media_player_audio_device" + }, + { + "name": "media_player_audio_volume" + }, + { + "name": "media_player_buffering" + }, + { + "name": "media_player_chapter_changed" + }, + { + "name": "media_player_es_changed" + }, + { + "name": "media_player_length_changed" + }, + { + "name": "media_player_media_changed" + }, + { + "name": "media_player_pausable_changed" + }, + { + "name": "media_player_position_changed" + }, + { + "name": "media_player_scrambled_changed" + }, + { + "name": "media_player_seekable_changed" + }, + { + "name": "media_player_snapshot_taken" + }, + { + "name": "media_player_time_changed" + }, + { + "name": "media_player_title_changed" + }, + { + "name": "media_player_vout" + }, + { + "name": "media_state_changed" + }, + { + "name": "media_subitem_added" + }, + { + "name": "media_subitemtree_added" + }, + { + "name": "media_thumbnail_generated" + }, + { + "name": "renderer_discoverer_item_added" + }, + { + "name": "renderer_discoverer_item_deleted" + }, + { + "name": "vlm_media_event" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_instance_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_log_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_discoverer_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_list_player_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_list_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_player_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_stats_t", + "fields": [ + { + "name": "f_demux_bitrate" + }, + { + "name": "f_input_bitrate" + }, + { + "name": "f_send_bitrate" + }, + { + "name": "i_decoded_audio" + }, + { + "name": "i_decoded_video" + }, + { + "name": "i_demux_corrupted" + }, + { + "name": "i_demux_discontinuity" + }, + { + "name": "i_demux_read_bytes" + }, + { + "name": "i_displayed_pictures" + }, + { + "name": "i_lost_abuffers" + }, + { + "name": "i_lost_pictures" + }, + { + "name": "i_played_abuffers" + }, + { + "name": "i_read_bytes" + }, + { + "name": "i_sent_bytes" + }, + { + "name": "i_sent_packets" + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_picture_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_renderer_discoverer_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_renderer_item_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_duration_changed", + "fields": [ + { + "name": "new_duration" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_freed", + "fields": [ + { + "name": "md" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_list_item_added", + "fields": [ + { + "name": "index" + }, + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_list_item_deleted", + "fields": [ + { + "name": "index" + }, + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_list_player_next_item_set", + "fields": [ + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_list_will_add_item", + "fields": [ + { + "name": "index" + }, + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_list_will_delete_item", + "fields": [ + { + "name": "index" + }, + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_meta_changed", + "fields": [ + { + "name": "meta_type" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_parsed_changed", + "fields": [ + { + "name": "new_status" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_audio_device", + "fields": [ + { + "name": "device" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_audio_volume", + "fields": [ + { + "name": "volume" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_buffering", + "fields": [ + { + "name": "new_cache" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_chapter_changed", + "fields": [ + { + "name": "new_chapter" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_es_changed", + "fields": [ + { + "name": "i_id" + }, + { + "name": "i_type" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_length_changed", + "fields": [ + { + "name": "new_length" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_media_changed", + "fields": [ + { + "name": "md" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_pausable_changed", + "fields": [ + { + "name": "new_pausable" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_position_changed", + "fields": [ + { + "name": "new_position" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_scrambled_changed", + "fields": [ + { + "name": "new_scrambled" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_seekable_changed", + "fields": [ + { + "name": "new_seekable" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_snapshot_taken", + "fields": [ + { + "name": "filename" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_time_changed", + "fields": [ + { + "name": "new_time" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_title_changed", + "fields": [ + { + "name": "new_title" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_vout", + "fields": [ + { + "name": "new_count" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_state_changed", + "fields": [ + { + "name": "new_state" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_subitem_added", + "fields": [ + { + "name": "new_child" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_subitemtree_added", + "fields": [ + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_thumbnail_generated", + "fields": [ + { + "name": "p_thumbnail" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.renderer_discoverer_item_added", + "fields": [ + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.renderer_discoverer_item_deleted", + "fields": [ + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.vlm_media_event", + "fields": [ + { + "name": "psz_instance_name" + }, + { + "name": "psz_media_name" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.support.types.size_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.support.eventmanager.NativeEventManager$EventCallback" + }, + { + "type": "uniffi.compose_app.ForeignBytesStruct", + "fields": [ + { + "name": "data" + }, + { + "name": "len" + } + ] + }, + { + "type": "uniffi.compose_app.ForeignBytesStruct$ByValue", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uniffi.compose_app.IntegrityCheckingUniffiLib" + }, + { + "type": "uniffi.compose_app.RustBufferStruct", + "fields": [ + { + "name": "capacity" + }, + { + "name": "data" + }, + { + "name": "len" + } + ] + }, + { + "type": "uniffi.compose_app.RustBufferStruct$ByValue", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uniffi.compose_app.UniffiLib" + }, + { + "type": "uniffi.compose_app.UniffiRustCallStatusStruct", + "fields": [ + { + "name": "code" + }, + { + "name": "errorBuf" + } + ] + }, + { + "type": "uniffi.compose_app.UniffiRustCallStatusStruct$ByReference" + } + ], + "resources": [ + { + "glob": "okhttp3/internal/publicsuffix/PublicSuffixDatabase.list" + } + ] +} diff --git a/composeApp/icons/nightly/icon.icns b/composeApp/icons/nightly/icon.icns new file mode 100644 index 00000000..15e7c52a Binary files /dev/null and b/composeApp/icons/nightly/icon.icns differ diff --git a/composeApp/icons/nightly/icon.ico b/composeApp/icons/nightly/icon.ico new file mode 100644 index 00000000..aaee01a3 Binary files /dev/null and b/composeApp/icons/nightly/icon.ico differ diff --git a/composeApp/icons/nightly/icon.png b/composeApp/icons/nightly/icon.png new file mode 100644 index 00000000..33774b89 Binary files /dev/null and b/composeApp/icons/nightly/icon.png differ diff --git a/composeApp/icons/stable/icon.icns b/composeApp/icons/stable/icon.icns new file mode 100644 index 00000000..7110d55f Binary files /dev/null and b/composeApp/icons/stable/icon.icns differ diff --git a/composeApp/icons/stable/icon.ico b/composeApp/icons/stable/icon.ico new file mode 100644 index 00000000..0448c504 Binary files /dev/null and b/composeApp/icons/stable/icon.ico differ diff --git a/composeApp/icons/stable/icon.png b/composeApp/icons/stable/icon.png new file mode 100644 index 00000000..3a59c7aa Binary files /dev/null and b/composeApp/icons/stable/icon.png differ diff --git a/composeApp/proguard-rules.pro b/composeApp/proguard-rules.pro new file mode 100644 index 00000000..0a5f7168 --- /dev/null +++ b/composeApp/proguard-rules.pro @@ -0,0 +1,117 @@ +# Prevent ProGuard from deleting or obfuscating sun.misc.Unsafe +-keep class sun.misc.Unsafe { *; } +-dontwarn sun.misc.Unsafe + +## Rules for NewPipeExtractor +-keep class org.mozilla.javascript.** { *; } +-keep class org.mozilla.classfile.ClassFileWriter +-dontwarn org.mozilla.javascript.tools.** + +# 1. Keep all NewPipeExtractor classes, methods, and fields intact +-keep class org.schabi.newpipe.extractor.** { *; } + +# 2. CRITICAL: Prevent ProGuard from stripping the 'enum' flag from NewPipe enums +# This specifically fixes the "MediaCapability not an enum" ClassCastException +-keepclassmembers enum org.schabi.newpipe.extractor.** { + public static **[] values(); + public static ** valueOf(java.lang.String); + *; +} + +# 3. Keep ServiceLoader / SPI implementations used by NewPipe to discover services +-keep class * implements org.schabi.newpipe.extractor.Extractor { *; } +-keep class * implements org.schabi.newpipe.extractor.downloader.Downloader { *; } + +# JSR 305 annotations are for embedding nullability information. +-dontwarn javax.annotation.** + +# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java. +-dontwarn org.codehaus.mojo.animal_sniffer.* + +# OkHttp platform used only on JVM and when Conscrypt and other security providers are available. +# May be used with robolectric or deliberate use of Bouncy Castle on Android +-dontwarn okhttp3.internal.platform.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-keepattributes Signature +-keepattributes Annotation +-keep interface okhttp3.** { *; } +-dontwarn okhttp3.** +-dontwarn okio.** + +-dontwarn kotlin.Deprecated$Container +-dontwarn com.google.re2j.** + +-dontwarn java.awt.* +-keep class com.sun.jna.* { *; } +-keep class * extends com.sun.jna.* { *; } +-keepclassmembers class * extends com.sun.jna.* { public *; } + +# Most of volatile fields are updated with AtomicFU and should not be mangled/removed +-keepclassmembers class io.ktor.** { + volatile ; +} + +-keepclassmembernames class io.ktor.** { + volatile ; +} + +# client engines are loaded using ServiceLoader so we need to keep them +-keep class io.ktor.client.engine.** implements io.ktor.client.HttpClientEngineContainer + +-keep class uk.co.caprica.** { *; } + +# Prevent ProGuard from renaming, stripping, or optimizing Jetpack Navigation 3 UI structures +-keep class androidx.navigation3.** { *; } +-keep interface androidx.navigation3.** { *; } +-dontwarn androidx.navigation3.** + +# Keep related navigation event artifacts intact +-keep class androidx.navigationevent.** { *; } +-dontwarn androidx.navigationevent.** + +-keep class dev.whyoleg.cryptography.** { *; } +-keep interface dev.whyoleg.cryptography.** { *; } + +-keep class okio.** { *; } +-keep interface okio.** { *; } +-keepclassmembers class okio.** { *; } + +-keep class app.cash.zipline.** { *; } +-keep interface app.cash.zipline.** { *; } +-keepclassmembers class app.cash.zipline.** { *; } +-dontwarn app.cash.zipline.** + +# Keep all Ktor serialization provider metadata files intact +-keep class io.ktor.serialization.** { *; } +-keep interface io.ktor.serialization.** { *; } + +# Tell ProGuard to explicitly keep the underlying service registration descriptors +-keepclassmembers class * implements io.ktor.serialization.kotlinx.KotlinxSerializationExtensionProvider { *; } + +# Keep all core Coil packages intact +#-keep class coil3.** { *; } +#-keep interface coil3.** { *; } +#-dontwarn coil3.** +# +## Keep platform-specific image decoders (Skia/Skiko rendering targets for desktop) +#-keep class coil3.decode.** { *; } +#-keep class coil3.request.** { *; } +# +## Coil uses Ktor or OkHttp internally for fetching images over the network +## Ensure its network fetcher factory singletons aren't stripped +#-keep class * implements coil3.fetch.Fetcher$Factory { *; } +#-keep class * implements coil3.decode.Decoder$Factory { *; } +# +## Keep SVG or extra dynamic graphic components if you use them +#-keep class coil3.svg.** { *; } +-keep class coil3.util.DecoderServiceLoaderTarget { *; } +-keep class coil3.util.FetcherServiceLoaderTarget { *; } +-keep class coil3.util.ServiceLoaderComponentRegistry { *; } +-keep class * implements coil3.util.DecoderServiceLoaderTarget { *; } +-keep class * implements coil3.util.FetcherServiceLoaderTarget { *; } + +# AndroidX Car App Library +-keep class androidx.car.app.** { *; } +-keep interface androidx.car.app.** { *; } +-dontwarn androidx.car.app.** \ No newline at end of file diff --git a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt new file mode 100644 index 00000000..fc0fa47e --- /dev/null +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.newpipe + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import org.spotube.spotube.core.paths.Paths +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import okio.Path.Companion.toPath +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.downloader.Downloader +import org.schabi.newpipe.extractor.downloader.Request +import org.schabi.newpipe.extractor.downloader.Response +import java.util.concurrent.TimeUnit + +class NewPipeDownloader(private val cookieJar: PersistentCookieJar) : Downloader() { + private val client: OkHttpClient = OkHttpClient.Builder() + .readTimeout(30, TimeUnit.SECONDS) + .connectTimeout(30, TimeUnit.SECONDS) + .cookieJar(cookieJar) + .build() + + override fun execute(request: Request): Response { + val httpMethod = request.httpMethod() + val url = request.url() + val headers = request.headers() + val dataToSend = request.dataToSend() + + val requestBuilder = okhttp3.Request.Builder() + .method(httpMethod, dataToSend?.toRequestBody()) + .url(url) + + headers.forEach { (key, values) -> + values.forEach { value -> requestBuilder.addHeader(key, value) } + } + + if (!headers.containsKey("User-Agent") && !headers.containsKey("user-agent")) { + requestBuilder.addHeader( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + ) + } + + val response = client.newCall(requestBuilder.build()).execute() + val responseBody = response.body.string() + val latestUrl = response.request.url.toString() + + return Response( + response.code, + response.message, + response.headers.toMultimap(), + responseBody, + latestUrl + ) + } + + companion object { + private var instance: NewPipeDownloader? = null + + fun init(paths: Paths) { + val cookieJar = PersistentCookieJar(paths) + instance = NewPipeDownloader(cookieJar) + NewPipe.init(instance) + } + } +} + +class PersistentCookieJar(private val paths: Paths) : CookieJar { + private val cookieStore = mutableMapOf>() + private val dataStore: DataStore = PreferenceDataStoreFactory.createWithPath( + produceFile = { "${paths.getApplicationDataDirPath()}/spotube_cookies.preferences_pb".toPath() } + ) + + init { + loadCookies() + } + + private fun loadCookies() { + runBlocking { + val prefs = dataStore.data.first() + val json = prefs[stringPreferencesKey("newpipe_cookies")] ?: return@runBlocking + try { + val serialized: Map> = Json.decodeFromString(json) + serialized.forEach { (host, cookieStrings) -> + cookieStore[host] = cookieStrings.mapNotNull { s -> + runCatching { + Cookie.parse( + HttpUrl.Builder().scheme("https").host(host).build(), s + ) + }.getOrNull() + }.toMutableList() + } + } catch (e: Exception) { + cookieStore.clear() + } + } + } + + private fun saveCookies() { + runBlocking { + dataStore.edit { prefs -> + val serialized = cookieStore.mapValues { (_, cookies) -> + cookies.map { cookie -> + "${cookie.name}=${cookie.value}; domain=${cookie.domain}; path=${cookie.path}; ${if (cookie.secure) "Secure" else ""}; ${if (cookie.httpOnly) "HttpOnly" else ""}" + } + } + prefs[stringPreferencesKey("newpipe_cookies")] = Json.encodeToString(serialized) + } + } + } + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val host = url.host + val hostCookies = cookieStore.getOrPut(host) { mutableListOf() } + + for (newCookie in cookies) { + val existingIndex = + hostCookies.indexOfFirst { it.name == newCookie.name && it.path == newCookie.path } + if (existingIndex >= 0) { + hostCookies[existingIndex] = newCookie + } else { + hostCookies.add(newCookie) + } + } + + hostCookies.removeAll { it.expiresAt < System.currentTimeMillis() } + + saveCookies() + } + + override fun loadForRequest(url: HttpUrl): List { + val host = url.host + val hostCookies = cookieStore[host] ?: return emptyList() + + val validCookies = hostCookies.filter { cookie -> + cookie.matches(url) && cookie.expiresAt >= System.currentTimeMillis() + } + + return validCookies + } +} diff --git a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt new file mode 100644 index 00000000..1500c6f9 --- /dev/null +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.newpipe + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import co.touchlab.kermit.Logger +import org.spotube.spotube.core.server.describeForLog +import io.ktor.http.Url +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.schabi.newpipe.extractor.ServiceList.YouTube +import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory +import org.schabi.newpipe.extractor.stream.DeliveryMethod +import org.schabi.newpipe.extractor.stream.StreamInfoItem + +/** NewPipe reports durations in seconds (-1 when unknown). */ +private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0 + +actual class NewPipeService { + private val logger = Logger.withTag("NewPipeService") + + actual suspend fun searchVideos(query: String, kind: SearchKind): List { + return withContext(Dispatchers.IO) { + val extractor = YouTube.getSearchExtractor( + query, + mutableListOf( + when (kind) { + SearchKind.Songs -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS + SearchKind.Videos -> YoutubeSearchQueryHandlerFactory.VIDEOS + }), + "" + ) + extractor.fetchPage() + val results = extractor.initialPage + + results.items.mapNotNull { item -> + if (item is StreamInfoItem) { + VideoSearchResult( + id = Url(item.url).parameters["v"] + ?: throw IllegalArgumentException("Invalid YouTube URL: ${item.url}"), + title = item.name, + url = item.url, + uploader = item.uploaderName, + durationMs = secondsToMs(item.duration), + thumbnailUrl = item.thumbnails.first().url) + } else { + null + } + } + } + } + + actual suspend fun getVideoInfo(id: String): VideoInfo { + return withContext(Dispatchers.IO) { + val extractor = YouTube.getStreamExtractor("https://www.youtube.com/watch?v=$id") + extractor.fetchPage() + + // Raw extractor output, before any mapping/filtering, so stream-selection issues can be + // traced back to what YouTube actually offered for this video. + logger.i { + "[Streams $id] '${extractor.name}' raw audioStreams=${extractor.audioStreams.size}:\n" + + extractor.audioStreams.joinToString("\n") { stream -> + " itag=${stream.itag} format=${stream.format?.name} mime=${stream.format?.mimeType} " + + "suffix=${stream.format?.suffix} codec=${stream.codec} bitrate=${stream.bitrate} " + + "avgBitrate=${stream.averageBitrate} delivery=${stream.deliveryMethod} isUrl=${stream.isUrl} " + + "trackType=${stream.audioTrackType}" + } + } + logger.i { + "[Streams $id] dashMpdUrl=${extractor.dashMpdUrl.isNotBlank()} hlsUrl=${extractor.hlsUrl.isNotBlank()} " + + "raw videoStreams=${extractor.videoStreams.size} videoOnlyStreams=${extractor.videoOnlyStreams.size}" + } + + val audioStreams = extractor.audioStreams + .mapNotNull { stream -> + val format = stream.format ?: return@mapNotNull null + // Non-URL content is an inline manifest, not something a player or downloader can fetch. + if (!stream.isUrl) return@mapNotNull null + AudioStream.Lossy( + url = stream.content, + codec = normalizeCodec(stream.codec), + container = normalizeContainer(format.suffix), + bitrate = stream.bitrate, + protocol = stream.deliveryMethod.toProtocol()) + } + buildList { + if (extractor.dashMpdUrl.isNotBlank()) add( + AudioStream.Lossy( + url = extractor.dashMpdUrl, + codec = "aac", + container = "mp4", + bitrate = 128_000, + protocol = StreamProtocol.DASH + ) + ) + if (extractor.hlsUrl.isNotBlank()) add( + AudioStream.Lossy( + url = extractor.hlsUrl, + codec = "aac", + container = "mp4", + bitrate = 128_000, + protocol = StreamProtocol.HLS + ) + ) + } + + val videoStreams = extractor.videoStreams + .filter { stream -> stream.isUrl && stream.format != null } + .map { stream -> + AudioStream.Lossy( + url = stream.content, + codec = stream.codec, + bitrate = stream.bitrate, + container = stream.format!!.suffix) + } + logger.i { + "[Streams $id] mapped audio candidates (${audioStreams.size}):\n" + + audioStreams.joinToString("\n") { " ${it.describeForLog()}" } + } + VideoInfo( + id = id, + title = extractor.name, + url = extractor.url, + uploader = extractor.uploaderName, + durationMs = secondsToMs(extractor.length), + thumbnailUrl = extractor.thumbnails.first().url, + // fallback to video streams if no audio streams are available + audioStreams = audioStreams.ifEmpty { + logger.w { + "[Streams $id] no audio streams; FALLING BACK TO ${videoStreams.size} VIDEO streams " + + "(muxed video+audio, downloads will be video files):\n" + + extractor.videoStreams.joinToString("\n") { + " itag=${it.itag} format=${it.format?.name} res=${it.resolution} " + + "codec=${it.codec} videoOnly=${it.isVideoOnly} isUrl=${it.isUrl}" + } + } + videoStreams + }, + videoStreams = videoStreams + ) + } + } +} + +/** + * NewPipe names AAC "mp4a.40.x" in an "m4a" container, but the plugin advertises (and users pick) + * "aac"/"mp4". Without this a real AAC stream never matched the preference, and the hard-coded + * aac/mp4 DASH/HLS manifests always won instead. + */ +private fun DeliveryMethod.toProtocol(): StreamProtocol = when (this) { + DeliveryMethod.DASH -> StreamProtocol.DASH + DeliveryMethod.HLS -> StreamProtocol.HLS + else -> StreamProtocol.PROGRESSIVE +} diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml new file mode 100644 index 00000000..9e832592 --- /dev/null +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/ic_launcher-playstore.png b/composeApp/src/androidMain/ic_launcher-playstore.png new file mode 100644 index 00000000..5fd7f2f3 Binary files /dev/null and b/composeApp/src/androidMain/ic_launcher-playstore.png differ diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt new file mode 100644 index 00000000..e8f0437b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import org.spotube.spotube.core.deeplink.ExternalUriHandler +import org.spotube.spotube.core.newpipe.NewPipeDownloader +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.media.PlaybackService +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.dialogs.init + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + FileKit.init(this) + NewPipeDownloader.init(Paths()) + + // Plain start from the foreground; Media3 promotes PlaybackService itself once playback starts + startService(Intent(this, PlaybackService::class.java)) + + intent?.dataString?.let(ExternalUriHandler::onNewUri) + setContent { + App() + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + intent.dataString?.let(ExternalUriHandler::onNewUri) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/MyApplication.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/MyApplication.kt new file mode 100644 index 00000000..fff70175 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/MyApplication.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube + +import android.app.Application +import org.spotube.spotube.core.di.initKoin +import org.spotube.spotube.core.paths.Paths +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.koin.android.ext.koin.androidContext +import org.koin.core.component.KoinComponent + +class MyApplication : Application(), KoinComponent { + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + override fun onCreate() { + super.onCreate() + Paths.init(this) + initKoin { + androidContext(this@MyApplication) + } + } + + override fun onTerminate() { + appScope.cancel() + super.onTerminate() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/Platform.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/Platform.android.kt new file mode 100644 index 00000000..762ec487 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/Platform.android.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import org.koin.core.context.GlobalContext + +class AndroidPlatform : Platform { + override val name: String = "Android ${Build.VERSION.SDK_INT}" + override val type: PlatformType = PlatformType.Android +} + +actual fun getPlatform(): Platform = AndroidPlatform() + +actual fun openUrlInBrowser(url: String) { + val context = GlobalContext.get().get() + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt new file mode 100644 index 00000000..2bda1426 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt @@ -0,0 +1,447 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.audioplayer + +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import org.spotube.spotube.media.PlaybackService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface { + + actual val context: Any = context + + private val appContext: Context = (context as Context).applicationContext + + private fun ensureServiceStarted() { + // A plain start, never startForegroundService: that obliges the service to call + // startForeground() within seconds of *every* call, and once Media3 has demoted it + // on pause nothing does, so the app is killed (ForegroundServiceDidNotStartInTime). + // Media3's MediaSessionService promotes itself to the foreground when playback starts. + // Starting from the background (jam sync, remote control) throws; playback still runs + // in-process without the service, so never let that crash the app. + try { + appContext.startService(Intent(appContext, PlaybackService::class.java)) + } catch (e: Exception) { + Log.w("AudioPlayer", "Failed to start playback service", e) + } + } + + private val exoPlayer: ExoPlayer = ExoPlayer.Builder(appContext) + .setAudioAttributes( + AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .setUsage(C.USAGE_MEDIA) + .build(), + true + ) + .setHandleAudioBecomingNoisy(true) + .build() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private var disposed = false + + private val currentPlaylist = mutableListOf() + private val originalPlaylist = mutableListOf() + private var shuffleEnabled = false + private val urlIndexMap = mutableMapOf() + + private val _playerState = MutableStateFlow(PlayerState.IDLE) + private val _currentMediaItem = MutableStateFlow(null) + private val _playlist = MutableStateFlow>(emptyList()) + private val _duration = MutableStateFlow(Duration.ZERO) + private val _position = MutableStateFlow(Duration.ZERO) + private val _bufferingPosition = MutableStateFlow(Duration.ZERO) + private val _loopState = MutableStateFlow(LoopState.NONE) + private val _shuffleMode = MutableStateFlow(false) + private val _playbackSpeed = MutableStateFlow(1.0f) + private val _volume = MutableStateFlow(1.0f) + private val _completion = MutableSharedFlow(extraBufferCapacity = 1) + private val _error = MutableSharedFlow(extraBufferCapacity = 1) + + actual override val playerStateFlow: StateFlow = _playerState.asStateFlow() + actual override val currentMediaItemFlow: StateFlow = _currentMediaItem.asStateFlow() + actual override val playlistFlow: StateFlow> = _playlist.asStateFlow() + actual override val durationFlow: StateFlow = _duration.asStateFlow() + actual override val positionFlow: StateFlow = _position.asStateFlow() + actual override val bufferingPositionFlow: StateFlow = _bufferingPosition.asStateFlow() + actual override val loopStateFlow: StateFlow = _loopState.asStateFlow() + actual override val shuffleModeFlow: StateFlow = _shuffleMode.asStateFlow() + actual override val playbackSpeedFlow: StateFlow = _playbackSpeed.asStateFlow() + actual override val volumeFlow: StateFlow = _volume.asStateFlow() + actual override val completionFlow: Flow = _completion.asSharedFlow() + actual override val errorFlow: Flow = _error.asSharedFlow() + + private var lastPlaybackState = Player.STATE_IDLE + + init { + exoPlayer.addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + syncPlaybackState(playbackState) + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + syncPlayWhenReady(playWhenReady) + } + + override fun onMediaItemTransition(mediaItem: androidx.media3.common.MediaItem?, reason: Int) { + syncCurrentItem() + } + + override fun onPlayerError(error: PlaybackException) { + _error.tryEmit(error) + } + + override fun onRepeatModeChanged(repeatMode: Int) { + _loopState.tryEmit( + when (repeatMode) { + Player.REPEAT_MODE_OFF -> LoopState.NONE + Player.REPEAT_MODE_ONE -> LoopState.ONE + Player.REPEAT_MODE_ALL -> LoopState.ALL + else -> LoopState.NONE + } + ) + } + + override fun onVolumeChanged(volume: Float) { + _volume.tryEmit(volume) + } + }) + + scope.launch { + while (isActive && !disposed) { + _position.tryEmit(exoPlayer.currentPosition.milliseconds) + _duration.tryEmit( + if (exoPlayer.duration > 0) exoPlayer.duration.milliseconds else Duration.ZERO + ) + _bufferingPosition.tryEmit(exoPlayer.bufferedPosition.milliseconds) + delay(250) + } + } + } + + private fun syncPlaybackState(playbackState: Int) { + if (playbackState == Player.STATE_ENDED && lastPlaybackState != Player.STATE_ENDED) { + _completion.tryEmit(Unit) + _playerState.tryEmit(PlayerState.COMPLETED) + } + lastPlaybackState = playbackState + if (playbackState != Player.STATE_ENDED) { + syncPlayWhenReady(exoPlayer.playWhenReady) + } + } + + private fun syncPlayWhenReady(playWhenReady: Boolean) { + val state = when { + exoPlayer.playbackState == Player.STATE_IDLE -> PlayerState.IDLE + exoPlayer.playbackState == Player.STATE_BUFFERING -> PlayerState.BUFFERING + playWhenReady -> PlayerState.PLAYING + else -> PlayerState.PAUSED + } + _playerState.tryEmit(state) + } + + private fun syncCurrentItem() { + val index = exoPlayer.currentMediaItemIndex + _currentMediaItem.tryEmit( + if (index in currentPlaylist.indices) currentPlaylist[index] else null + ) + } + + internal val player: ExoPlayer get() = exoPlayer + + private fun MediaItem.toExoMediaItem(): androidx.media3.common.MediaItem { + return androidx.media3.common.MediaItem.Builder() + .setMediaId(url) + .setUri(url) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setArtist(artist) + .setAlbumTitle(album) + .apply { + if (coverBytes != null) { + setArtworkData(coverBytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER) + } else if (coverURL.isNotBlank()) { + setArtworkUri(android.net.Uri.parse(coverURL)) + } + } + .build() + ) + .build() + } + + actual override suspend fun play() { + withContext(Dispatchers.Main) { + ensureServiceStarted() + exoPlayer.play() + } + } + + actual override suspend fun pause() { + withContext(Dispatchers.Main) { + exoPlayer.pause() + } + } + + actual override suspend fun stop() { + withContext(Dispatchers.Main) { + exoPlayer.stop() + _playerState.tryEmit(PlayerState.IDLE) + } + } + + actual override suspend fun seekTo(position: Duration) { + withContext(Dispatchers.Main) { + val duration = exoPlayer.duration + val targetMs = if (duration > 0) { + position.inWholeMilliseconds.coerceIn(0, duration) + } else { + position.inWholeMilliseconds.coerceAtLeast(0) + } + exoPlayer.seekTo(targetMs) + _position.tryEmit(exoPlayer.currentPosition.milliseconds) + } + } + + actual override suspend fun loop(state: LoopState) { + withContext(Dispatchers.Main) { + val repeatMode = when (state) { + LoopState.NONE -> Player.REPEAT_MODE_OFF + LoopState.ONE -> Player.REPEAT_MODE_ONE + LoopState.ALL -> Player.REPEAT_MODE_ALL + } + exoPlayer.repeatMode = repeatMode + _loopState.tryEmit(state) + } + } + + actual override suspend fun shuffle(enabled: Boolean) { + withContext(Dispatchers.Main) { + if (disposed || currentPlaylist.isEmpty() || shuffleEnabled == enabled) return@withContext + + val currentIndex = exoPlayer.currentMediaItemIndex + val current = currentPlaylist.getOrNull(currentIndex) ?: return@withContext + + val rest = if (enabled) { + originalPlaylist.filterNot { it.url == current.url }.shuffled() + } else { + originalPlaylist.filterNot { it.url == current.url } + } + // Disabling restores original order around the current item. + val before = if (enabled) emptyList() else originalPlaylist.takeWhile { it.url != current.url } + val after = if (enabled) rest else originalPlaylist.dropWhile { it.url != current.url }.drop(1) + + // Reorder in place around the playing item so playback is not interrupted. + if (currentIndex < exoPlayer.mediaItemCount - 1) { + exoPlayer.removeMediaItems(currentIndex + 1, exoPlayer.mediaItemCount) + } + if (currentIndex > 0) exoPlayer.removeMediaItems(0, currentIndex) + exoPlayer.addMediaItems(0, before.map { it.toExoMediaItem() }) + exoPlayer.addMediaItems(after.map { it.toExoMediaItem() }) + + currentPlaylist.clear() + currentPlaylist.addAll(before + current + after) + urlIndexMap.clear() + currentPlaylist.forEachIndexed { i, item -> urlIndexMap[item.url] = i } + + shuffleEnabled = enabled + _shuffleMode.tryEmit(enabled) + _playlist.tryEmit(currentPlaylist.toList()) + syncCurrentItem() + } + } + + actual override suspend fun load( + playlist: List, + autoPlay: Boolean, + startPosition: Int + ) { + withContext(Dispatchers.Main) { + currentPlaylist.clear() + currentPlaylist.addAll(playlist) + originalPlaylist.clear() + originalPlaylist.addAll(playlist) + shuffleEnabled = false + _shuffleMode.tryEmit(false) + urlIndexMap.clear() + playlist.forEachIndexed { index, item -> + urlIndexMap[item.url] = index + } + _playlist.tryEmit(currentPlaylist.toList()) + + if (playlist.isEmpty()) { + _playerState.tryEmit(PlayerState.IDLE) + _currentMediaItem.tryEmit(null) + _position.tryEmit(Duration.ZERO) + _duration.tryEmit(Duration.ZERO) + return@withContext + } + + val exoItems = playlist.map { it.toExoMediaItem() } + val safeIndex = startPosition.coerceIn(0, exoItems.lastIndex) + + exoPlayer.setMediaItems(exoItems, safeIndex, 0L) + exoPlayer.prepare() + + if (autoPlay) { + ensureServiceStarted() + exoPlayer.playWhenReady = true + } + + _currentMediaItem.tryEmit(playlist[safeIndex]) + } + } + + actual override suspend fun addMediaItem(mediaItem: MediaItem) { + withContext(Dispatchers.Main) { + currentPlaylist.add(mediaItem) + originalPlaylist.add(mediaItem) + urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex + _playlist.tryEmit(currentPlaylist.toList()) + exoPlayer.addMediaItem(mediaItem.toExoMediaItem()) + } + } + + actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) { + withContext(Dispatchers.Main) { + val currentIndex = exoPlayer.currentMediaItemIndex + val insertIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.size) + + currentPlaylist.add(insertIndex, mediaItem) + val origCurrent = currentPlaylist.getOrNull(currentIndex)?.let { c -> originalPlaylist.indexOfFirst { it.url == c.url } } ?: -1 + originalPlaylist.add((origCurrent + 1).coerceIn(0, originalPlaylist.size), mediaItem) + urlIndexMap.clear() + currentPlaylist.forEachIndexed { index, item -> + urlIndexMap[item.url] = index + } + _playlist.tryEmit(currentPlaylist.toList()) + + exoPlayer.addMediaItem(insertIndex, mediaItem.toExoMediaItem()) + } + } + + actual override suspend fun removeMediaItem(mediaItem: MediaItem) { + withContext(Dispatchers.Main) { + val index = urlIndexMap[mediaItem.url] ?: return@withContext + currentPlaylist.removeAt(index) + originalPlaylist.removeAll { it.url == mediaItem.url } + urlIndexMap.clear() + currentPlaylist.forEachIndexed { i, item -> + urlIndexMap[item.url] = i + } + _playlist.tryEmit(currentPlaylist.toList()) + exoPlayer.removeMediaItem(index) + } + } + + actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) { + withContext(Dispatchers.Main) { + if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return@withContext + + val item = currentPlaylist.removeAt(fromIndex) + currentPlaylist.add(toIndex, item) + if (!shuffleEnabled && fromIndex in originalPlaylist.indices && toIndex in originalPlaylist.indices) { + originalPlaylist.add(toIndex, originalPlaylist.removeAt(fromIndex)) + } + urlIndexMap.clear() + currentPlaylist.forEachIndexed { i, it -> + urlIndexMap[it.url] = i + } + _playlist.tryEmit(currentPlaylist.toList()) + + exoPlayer.moveMediaItem(fromIndex, toIndex) + } + } + + actual override suspend fun skipToNext() { + withContext(Dispatchers.Main) { + exoPlayer.seekToNextMediaItem() + } + } + + actual override suspend fun skipToPrevious() { + withContext(Dispatchers.Main) { + exoPlayer.seekToPreviousMediaItem() + } + } + + actual override suspend fun jumpTo(index: Int) { + withContext(Dispatchers.Main) { + if (index in currentPlaylist.indices) { + exoPlayer.seekToDefaultPosition(index) + } + } + } + + actual override suspend fun setVolume(volume: Float) { + withContext(Dispatchers.Main) { + val clamped = volume.coerceIn(0f, 1f) + exoPlayer.setVolume(clamped) + _volume.tryEmit(clamped) + } + } + + actual override suspend fun setPlaybackSpeed(speed: Float) { + withContext(Dispatchers.Main) { + val clamped = speed.coerceIn(0.25f, 4f) + exoPlayer.setPlaybackSpeed(clamped) + _playbackSpeed.tryEmit(clamped) + } + } + + actual override fun isDisposed(): Boolean = disposed + + actual override fun dispose() { + disposed = true + exoPlayer.release() + currentPlaylist.clear() + originalPlaylist.clear() + urlIndexMap.clear() + + _playerState.tryEmit(PlayerState.IDLE) + _currentMediaItem.tryEmit(null) + _playlist.tryEmit(emptyList()) + _duration.tryEmit(Duration.ZERO) + _position.tryEmit(Duration.ZERO) + _bufferingPosition.tryEmit(Duration.ZERO) + } +} diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/di/Modules.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/di/Modules.android.kt new file mode 100644 index 00000000..a6e66693 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/di/Modules.android.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.di + +import android.content.Context +import org.spotube.spotube.core.audioplayer.AudioPlayer +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.share.AndroidShareService +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.media.MediaBrowseHelper +import org.spotube.spotube.modules.library.local_tracks.media.AndroidLocalMediaDiscoveryService +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService +import org.koin.dsl.module + +actual val platformModules = module { + single { Paths() } + single { AudioPlayer(get()) } + single { AndroidLocalMediaDiscoveryService(get()) } + single { AndroidShareService(get()) } + single { + MediaBrowseHelper( + pluginManager = get(), + homeScreenRepository = get(), + libraryRepository = get(), + playlistRepository = get(), + albumRepository = get(), + savedTracksRepository = get(), + searchRepository = get(), + collectionPlaybackHelper = get(), + audioPlayerQueue = get(), + settingsRepository = get(), + blacklistRepository = get()) + } +} diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt new file mode 100644 index 00000000..2f73a824 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.discovery + +// The OS's own service-discovery stack advertises on the right network. +internal actual fun preferredAdvertiseAddress(): String? = null diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.android.kt new file mode 100644 index 00000000..ba5f18c9 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.android.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.discovery + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat + +/** + * On Android 13+ (and especially 16+ where it became a runtime permission), + * mDNS/NSD discovery requires `NEARBY_WIFI_DEVICES`. Requests it when the + * returned lambda is invoked; the caller decides when (e.g. first visit to the + * Devices screen). + */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission()) { /* result is picked up by discovery/advertising retry loops */ } + + return { + val granted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED + if (!granted) { + launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES) + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.android.kt new file mode 100644 index 00000000..3676717e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.android.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.i18n + +import android.content.res.Configuration +import android.content.res.Resources +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidedValue +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import java.util.Locale + +@Composable +internal actual fun provideAppLocale(languageTag: String?): ProvidedValue<*> { + // System resources are never touched by the override below, so they always report the + // device language (also after the user changes it while the app is running). + val locale = languageTag?.let(Locale::forLanguageTag) + ?: Resources.getSystem().configuration.locales[0] + // Compose resources read Locale.getDefault(); also used by non-UI code (notifications). + Locale.setDefault(locale) + + val configuration = Configuration(LocalConfiguration.current).apply { setLocale(locale) } + val resources = LocalContext.current.resources + @Suppress("DEPRECATION") + resources.updateConfiguration(configuration, resources.displayMetrics) + return LocalConfiguration provides configuration +} diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/paths/Paths.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/paths/Paths.android.kt new file mode 100644 index 00000000..dfee7da5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/paths/Paths.android.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.paths + +import android.content.Context +import android.os.Environment + +actual class Paths { + private val context: Context + get() = requireNotNull(appContext) { "Paths.init(context) must be called before use" } + + actual fun getApplicationCacheDirPath(): String { + return context.cacheDir.absolutePath + } + + actual fun getApplicationDataDirPath(): String { + return context.filesDir.absolutePath + } + + actual fun getUserDownloadsDirPath(): String { + return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath + java.io.File.separator + "Spotube" + } + + actual fun getMusicCacheDirPath(): String { + return context.cacheDir.absolutePath + "/music_cache" + } + + companion object { + @Volatile + private var appContext: Context? = null + + fun init(context: Context) { + appContext = context.applicationContext + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/share/AndroidShareService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/share/AndroidShareService.kt new file mode 100644 index 00000000..f75039e5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/share/AndroidShareService.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.share + +import android.content.Context +import android.content.Intent +import androidx.core.app.ShareCompat +import org.spotube.spotube.core.share.ShareService + +class AndroidShareService(private val context: Context) : ShareService { + override fun share(url: String, title: String) { + // context is the application context (Koin), which can only start activities in a new task + val chooser = ShareCompat.IntentBuilder(context) + .setType("text/plain") + .setSubject(title) + .setText(url) + .createChooserIntent() + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(chooser) + } +} diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.android.kt new file mode 100644 index 00000000..2c05aec3 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.android.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.webview + +import android.webkit.CookieManager +import android.webkit.WebStorage +import android.webkit.WebView +import dev.nucleusframework.webview.web.WebViewState + +actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) { + val nativeWebView = webView.webView?.nativeWebView as? WebView ?: return + nativeWebView.settings.domStorageEnabled = true +} + +actual suspend fun platformClearWebviewData(pluginId: String?) { + if (pluginId == null) return + + CookieManager.getInstance().removeAllCookies(null) + CookieManager.getInstance().flush() + WebStorage.getInstance().deleteAllData() +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.android.kt new file mode 100644 index 00000000..3591c937 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.android.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline + +import kotlinx.coroutines.asCoroutineDispatcher +import java.util.concurrent.Executors + +actual fun createZiplineDispatcher(): ZiplineDispatcher { + val executor = Executors.newSingleThreadExecutor { runnable -> + Thread(null, runnable, "Zipline", 8L * 1024 * 1024) // 8 MiB stack for QuickJS compile() + } + val dispatcher = executor.asCoroutineDispatcher() + return ZiplineDispatcher(dispatcher) { + dispatcher.close() + executor.shutdown() + } +} + diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt new file mode 100644 index 00000000..c3ba32df --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt @@ -0,0 +1,670 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.media + +import android.net.Uri +import android.os.Bundle +import android.util.Log +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.modules.album.AlbumRepository +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.home.HomeScreenRepository +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.playlist.PlaylistRepository +import org.spotube.spotube.modules.plugin.PluginManager +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.spotube.modules.search.SearchRepository +import org.spotube.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.flow.first +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_tab_albums +import spotube.composeapp.generated.resources.library_tab_artists +import spotube.composeapp.generated.resources.library_tab_playlists +import spotube.composeapp.generated.resources.library_toolbar_compact_title +import spotube.composeapp.generated.resources.media_browser_browse +import spotube.composeapp.generated.resources.media_browser_featured +import spotube.composeapp.generated.resources.media_browser_now_playing +import spotube.composeapp.generated.resources.media_browser_saved_tracks +import spotube.composeapp.generated.resources.media_browser_top_tracks + +class MediaBrowseHelper( + private val pluginManager: PluginManager, + private val homeScreenRepository: HomeScreenRepository, + private val libraryRepository: LibraryRepository, + private val playlistRepository: PlaylistRepository, + private val albumRepository: AlbumRepository, + private val savedTracksRepository: SavedTracksRepository, + private val searchRepository: SearchRepository, + private val collectionPlaybackHelper: CollectionPlaybackHelper, + private val audioPlayerQueue: AudioPlayerQueue, + private val settingsRepository: SettingsRepository, + private val blacklistRepository: BlacklistRepository) { + + companion object { + const val MEDIA_ID_ROOT = "root" + const val MEDIA_ID_BROWSE = "browse" + const val MEDIA_ID_BROWSE_GENRE = "browse:genre" + const val MEDIA_ID_BROWSE_FEATURED = "browse:featured" + const val MEDIA_ID_LIBRARY = "library" + const val MEDIA_ID_SAVED_PLAYLISTS = "library:playlists" + const val MEDIA_ID_SAVED_ALBUMS = "library:albums" + const val MEDIA_ID_SAVED_ARTISTS = "library:artists" + const val MEDIA_ID_SAVED_TRACKS = "library:tracks" + const val MEDIA_ID_PLAYLIST = "playlist" + const val MEDIA_ID_ALBUM = "album" + const val MEDIA_ID_ARTIST = "artist" + const val MEDIA_ID_ARTIST_TRACKS = "artist:tracks" + const val MEDIA_ID_ARTIST_ALBUMS = "artist:albums" + const val MEDIA_ID_TRACK = "track" + const val MEDIA_ID_QUEUE = "queue" + + private const val CONTENT_STYLE_BROWSABLE = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT" + private const val CONTENT_STYLE_GRID = 2 + private const val CONTENT_STYLE_GROUP_TITLE = "android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT" + } + + fun buildRootItem(): MediaItem { + return MediaItem.Builder() + .setMediaId(MEDIA_ID_ROOT) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle("Spotube") + .setIsBrowsable(true) + .setIsPlayable(false) + .build() + ) + .build() + } + + suspend fun getChildren(parentId: String): List { + Log.d("MediaBrowseHelper", "Getting children for parentId: $parentId") + return when { + parentId == MEDIA_ID_ROOT -> buildRootChildren() + parentId == MEDIA_ID_BROWSE -> buildBrowseItems() + parentId == MEDIA_ID_BROWSE_FEATURED -> buildFeaturedItems() + parentId.startsWith("$MEDIA_ID_BROWSE_GENRE:") -> { + val genreId = parentId.removePrefix("$MEDIA_ID_BROWSE_GENRE:") + buildBrowseGenreItems(genreId) + } + parentId == MEDIA_ID_LIBRARY -> buildLibraryRootChildren() + parentId == MEDIA_ID_SAVED_PLAYLISTS -> buildSavedPlaylistsItems() + parentId == MEDIA_ID_SAVED_ALBUMS -> buildSavedAlbumsItems() + parentId == MEDIA_ID_SAVED_ARTISTS -> buildSavedArtistsItems() + parentId == MEDIA_ID_SAVED_TRACKS -> buildSavedTracksItems() + parentId.startsWith("$MEDIA_ID_PLAYLIST:") -> { + val playlistId = parentId.removePrefix("$MEDIA_ID_PLAYLIST:") + buildPlaylistTracksItems(playlistId) + } + parentId.startsWith("$MEDIA_ID_ALBUM:") -> { + val albumId = parentId.removePrefix("$MEDIA_ID_ALBUM:") + buildAlbumTracksItems(albumId) + } + parentId.startsWith("$MEDIA_ID_ARTIST:") -> { + val artistId = parentId.removePrefix("$MEDIA_ID_ARTIST:") + buildArtistOverviewItems(artistId) + } + parentId.startsWith("$MEDIA_ID_ARTIST_TRACKS:") -> { + val artistId = parentId.removePrefix("$MEDIA_ID_ARTIST_TRACKS:") + buildArtistTracksItems(artistId) + } + parentId.startsWith("$MEDIA_ID_ARTIST_ALBUMS:") -> { + val artistId = parentId.removePrefix("$MEDIA_ID_ARTIST_ALBUMS:") + buildArtistAlbumsItems(artistId) + } + parentId == MEDIA_ID_QUEUE -> buildQueueItems() + else -> emptyList() + } + } + + suspend fun search(query: String): List { + val results = try { + searchRepository.searchAll(query) + } catch (_: Exception) { + return emptyList() + } + val items = mutableListOf() + for (result in results) { + val item = when (result) { + is MetadataSearchResult.Track -> + buildTrackMediaItem(result.data) + + is MetadataSearchResult.Album -> + buildAlbumMediaItem(result.data) + + is MetadataSearchResult.Artist -> + buildArtistMediaItem(result.data) + + is MetadataSearchResult.Playlist -> + buildPlaylistMediaItem(result.data) + + is MetadataSearchResult.User -> null + } + if (item != null) { + items.add(item) + } + } + return items + } + + suspend fun resolveTrackById(trackId: String): MetadataTrack? { + return getTrackById(trackId) + } + + suspend fun resolveTrackToMediaItem(trackId: String): MediaItem? { + val track = getTrackById(trackId) ?: return null + val url = buildStreamingUrl(track.id) ?: return null + return MediaItem.Builder() + .setMediaId("$MEDIA_ID_TRACK:${track.id}") + .setUri(url) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(track.title) + .setArtist(track.artists.joinToString(", ") { it.name }) + .setAlbumTitle(track.album?.title) + .setArtworkUri(track.album?.thumbnails?.firstOrNull()?.url?.let { Uri.parse(it) }) + .setTrackNumber(track.trackNumber) + .setIsPlayable(true) + .setIsBrowsable(false) + .build() + ) + .build() + } + + suspend fun resolveAndPlayTrack(track: MetadataTrack) { + val entry = QueueEntry.StreamingTrack(track = track, url = "") + audioPlayerQueue.load(listOf(entry), autoPlay = true, startPosition = 0) + } + + suspend fun resolveAndPlayFromMediaId(mediaId: String): Boolean { + return when { + mediaId.startsWith("$MEDIA_ID_TRACK:") -> { + val trackId = mediaId.removePrefix("$MEDIA_ID_TRACK:") + val track = getTrackById(trackId) ?: return false + resolveAndPlayTrack(track) + true + } + mediaId.startsWith("$MEDIA_ID_PLAYLIST:") -> { + val playlistId = mediaId.removePrefix("$MEDIA_ID_PLAYLIST:") + collectionPlaybackHelper.playPlaylist(playlistId) + true + } + mediaId.startsWith("$MEDIA_ID_ALBUM:") -> { + val albumId = mediaId.removePrefix("$MEDIA_ID_ALBUM:") + collectionPlaybackHelper.playAlbum(albumId) + true + } + mediaId == MEDIA_ID_SAVED_TRACKS -> { + collectionPlaybackHelper.playSavedTracks() + true + } + mediaId.startsWith("$MEDIA_ID_ARTIST_TRACKS:") -> { + val artistId = mediaId.removePrefix("$MEDIA_ID_ARTIST_TRACKS:") + playArtistTracks(artistId) + true + } + else -> false + } + } + + private suspend fun buildRootChildren(): List { + return listOf( + MediaItem.Builder() + .setMediaId(MEDIA_ID_BROWSE) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(getString(Res.string.media_browser_browse)) + .setIsBrowsable(true) + .setIsPlayable(false) + .build() + ) + .build(), + MediaItem.Builder() + .setMediaId(MEDIA_ID_LIBRARY) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(getString(Res.string.library_toolbar_compact_title)) + .setIsBrowsable(true) + .setIsPlayable(false) + .build() + ) + .build(), + MediaItem.Builder() + .setMediaId(MEDIA_ID_QUEUE) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(getString(Res.string.media_browser_now_playing)) + .setIsBrowsable(true) + .setIsPlayable(false) + .build() + ) + .build()) + } + + private suspend fun buildBrowseItems(): List { + val gridExtras = Bundle().apply { putInt(CONTENT_STYLE_BROWSABLE, CONTENT_STYLE_GRID) } + val items = mutableListOf() + + items.add( + MediaItem.Builder() + .setMediaId(MEDIA_ID_BROWSE_FEATURED) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(getString(Res.string.media_browser_featured)) + .setIsBrowsable(true) + .setIsPlayable(false) + .setExtras(gridExtras) + .build() + ) + .build() + ) + + val genres = try { + homeScreenRepository.genres() ?: emptyList() + } catch (_: Exception) { + emptyList() + } + + for (genre in genres) { + items.add( + MediaItem.Builder() + .setMediaId("$MEDIA_ID_BROWSE_GENRE:${genre.id}") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(genre.name) + .setIsBrowsable(true) + .setIsPlayable(false) + .setExtras(gridExtras) + .build() + ) + .build() + ) + } + + return items + } + + private suspend fun buildFeaturedItems(): List { + val featured = try { + homeScreenRepository.featuredItems() ?: emptyList() + } catch (_: Exception) { + emptyList() + } + + return featured.mapNotNull { item -> browseItemToMediaItem(item) } + } + + private suspend fun buildBrowseGenreItems(genreId: String): List { + val sections = try { + homeScreenRepository.list(genreId)?.items ?: emptyList() + } catch (_: Exception) { + emptyList() + } + + val items = mutableListOf() + + for (section in sections) { + for (item in section.items) { + val mediaItem = browseItemToMediaItem( + when (item) { + is MetadataBrowseItem.Track -> item + is MetadataBrowseItem.Album -> item + is MetadataBrowseItem.Artist -> item + is MetadataBrowseItem.Playlist -> item + is MetadataBrowseItem.User -> null + } ?: continue + ) ?: continue + items.add(mediaItem.withGroupTitle(section.title)) + } + } + + return items + } + + private fun MediaItem.withGroupTitle(groupTitle: String): MediaItem { + val existingExtras = mediaMetadata.extras ?: Bundle() + existingExtras.putString(CONTENT_STYLE_GROUP_TITLE, groupTitle) + return buildUpon() + .setMediaMetadata( + mediaMetadata.buildUpon() + .setExtras(existingExtras) + .build() + ) + .build() + } + + private suspend fun buildLibraryRootChildren(): List { + return listOf( + MediaItem.Builder() + .setMediaId(MEDIA_ID_SAVED_PLAYLISTS) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(getString(Res.string.library_tab_playlists)) + .setIsBrowsable(true) + .setIsPlayable(false) + .build() + ) + .build(), + MediaItem.Builder() + .setMediaId(MEDIA_ID_SAVED_ALBUMS) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(getString(Res.string.library_tab_albums)) + .setIsBrowsable(true) + .setIsPlayable(false) + .build() + ) + .build(), + MediaItem.Builder() + .setMediaId(MEDIA_ID_SAVED_ARTISTS) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(getString(Res.string.library_tab_artists)) + .setIsBrowsable(true) + .setIsPlayable(false) + .build() + ) + .build(), + MediaItem.Builder() + .setMediaId(MEDIA_ID_SAVED_TRACKS) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(getString(Res.string.media_browser_saved_tracks)) + .setIsPlayable(false) + .setIsBrowsable(true) + .build() + ) + .build()) + } + + private suspend fun buildSavedPlaylistsItems(): List { + val result = try { + libraryRepository.savedPlaylists()?.items ?: emptyList() + } catch (_: Exception) { + emptyList() + } + return result.map { buildPlaylistMediaItem(it) } + } + + private suspend fun buildSavedAlbumsItems(): List { + val result = try { + libraryRepository.savedAlbums()?.items ?: emptyList() + } catch (_: Exception) { + emptyList() + } + return result.map { buildAlbumMediaItem(it) } + } + + private suspend fun buildSavedArtistsItems(): List { + val result = try { + libraryRepository.savedArtists()?.items ?: emptyList() + } catch (_: Exception) { + emptyList() + } + return result.map { buildArtistMediaItem(it) } + } + + private suspend fun buildSavedTracksItems(): List { + val allTracks = mutableListOf() + var pagination = savedTracksRepository.getSavedTracks() + pagination?.items?.let { allTracks.addAll(it) } + while (pagination?.nextPagination != null) { + pagination = savedTracksRepository.getSavedTracks(pagination.nextPagination) + pagination?.items?.let { allTracks.addAll(it) } + } + return allTracks.map { buildTrackMediaItem(it) } + } + + private suspend fun buildPlaylistTracksItems(playlistId: String): List { + val allTracks = mutableListOf() + var pagination = playlistRepository.getPlaylistTracks(playlistId) + pagination?.items?.let { allTracks.addAll(it) } + while (pagination?.nextPagination != null) { + pagination = playlistRepository.getPlaylistTracks(playlistId, pagination.nextPagination) + pagination?.items?.let { allTracks.addAll(it) } + } + return allTracks.map { buildTrackMediaItem(it) } + } + + private suspend fun buildAlbumTracksItems(albumId: String): List { + val allTracks = mutableListOf() + var pagination = albumRepository.getAlbumTracks(albumId) + pagination?.items?.let { allTracks.addAll(it) } + while (pagination?.nextPagination != null) { + pagination = albumRepository.getAlbumTracks(albumId, pagination.nextPagination) + pagination?.items?.let { allTracks.addAll(it) } + } + return allTracks.map { buildTrackMediaItem(it) } + } + + private suspend fun buildArtistOverviewItems(artistId: String): List { + return listOf( + MediaItem.Builder() + .setMediaId("$MEDIA_ID_ARTIST_TRACKS:$artistId") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(getString(Res.string.media_browser_top_tracks)) + .setIsPlayable(false) + .setIsBrowsable(true) + .build() + ) + .build(), + MediaItem.Builder() + .setMediaId("$MEDIA_ID_ARTIST_ALBUMS:$artistId") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(getString(Res.string.library_tab_albums)) + .setIsBrowsable(true) + .setIsPlayable(false) + .build() + ) + .build()) + } + + private suspend fun buildArtistTracksItems(artistId: String): List { + val plugin = pluginManager.selectedMetadataPlugin.value ?: return emptyList() + val tracks = try { + pluginManager.withScope { + plugin.use { metadataArtistAPI.getArtistTop10Tracks(artistId) } + } + } catch (_: Exception) { + emptyList() + } + val items = mutableListOf() + for (track in tracks) { + val item = buildTrackMediaItem(track) + if (item != null) { + items.add(item) + } + } + return items + } + + private suspend fun buildArtistAlbumsItems(artistId: String): List { + val plugin = pluginManager.selectedMetadataPlugin.value ?: return emptyList() + val result = try { + pluginManager.withScope { + plugin.use { metadataArtistAPI.getArtistAlbums(artistId) } + } + } catch (_: Exception) { + null + } + return result?.items?.map { buildAlbumMediaItem(it) } ?: emptyList() + } + + private suspend fun buildQueueItems(): List { + val queue = audioPlayerQueue.queueFlow.value + val items = mutableListOf() + for (entry in queue) { + val item = when (entry) { + is QueueEntry.StreamingTrack -> buildTrackMediaItem(entry.track) + is QueueEntry.LocalTrack -> buildLocalTrackMediaItem(entry) + } + if (item != null) { + items.add(item) + } + } + return items + } + + private suspend fun playArtistTracks(artistId: String) { + val plugin = pluginManager.selectedMetadataPlugin.value ?: return + val tracks = try { + pluginManager.withScope { + plugin.use { metadataArtistAPI.getArtistTop10Tracks(artistId) } + } + } catch (_: Exception) { + return + } + + val blacklistedTracks = blacklistRepository.getTracksSnapshot() + val blacklistedArtists = blacklistRepository.getArtistsSnapshot() + val blacklistedTrackIds = blacklistedTracks.map { it.id }.toSet() + val blacklistedArtistIds = blacklistedArtists.map { it.id }.toSet() + + val filteredTracks = tracks.filter { track -> + track.id !in blacklistedTrackIds && + track.artists.none { it.id in blacklistedArtistIds } + } + + val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } + if (entries.isNotEmpty()) { + audioPlayerQueue.load(entries, autoPlay = true, startPosition = 0) + } + } + + private suspend fun getTrackById(trackId: String): MetadataTrack? { + val plugin = pluginManager.selectedMetadataPlugin.value ?: return null + return try { + pluginManager.withScope { + plugin.use { metadataTrackAPI.getTrack(trackId) } + } + } catch (_: Exception) { + null + } + } + + private fun buildAlbumMediaItem(album: MetadataAlbum): MediaItem { + return MediaItem.Builder() + .setMediaId("$MEDIA_ID_ALBUM:${album.id}") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(album.title) + .setArtist(album.artists.joinToString(", ") { it.name }) + .setArtworkUri(album.thumbnails.firstOrNull()?.url?.let { Uri.parse(it) }) + .setDescription(album.description) + .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) + .setIsBrowsable(true) + .setIsPlayable(false) + .build() + ) + .build() + } + + private fun buildPlaylistMediaItem(playlist: MetadataPlaylist): MediaItem { + return MediaItem.Builder() + .setMediaId("$MEDIA_ID_PLAYLIST:${playlist.id}") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(playlist.title) + .setArtist(playlist.owner?.displayName ?: playlist.owner?.username) + .setArtworkUri(playlist.thumbnails.firstOrNull()?.url?.let { Uri.parse(it) }) + .setDescription(playlist.description) + .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) + .setIsBrowsable(true) + .setIsPlayable(false) + .build() + ) + .build() + } + + private fun buildTrackMediaItem(track: MetadataTrack): MediaItem { + return MediaItem.Builder() + .setMediaId("$MEDIA_ID_TRACK:${track.id}") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(track.title) + .setArtist(track.artists.joinToString(", ") { it.name }) + .setAlbumTitle(track.album?.title) + .setArtworkUri(track.album?.thumbnails?.firstOrNull()?.url?.let { Uri.parse(it) }) + .setTrackNumber(track.trackNumber) + .setIsPlayable(true) + .setIsBrowsable(false) + .build() + ) + .build() + } + + private fun buildArtistMediaItem(artist: MetadataArtist): MediaItem { + return MediaItem.Builder() + .setMediaId("$MEDIA_ID_ARTIST:${artist.id}") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(artist.name) + .setArtworkUri(artist.thumbnails.firstOrNull()?.url?.let { Uri.parse(it) }) + .setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST) + .setIsBrowsable(true) + .setIsPlayable(false) + .build() + ) + .build() + } + + private fun buildLocalTrackMediaItem(localTrack: QueueEntry.LocalTrack): MediaItem { + return MediaItem.Builder() + .setMediaId(localTrack.url) + .setUri(localTrack.url) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(localTrack.name) + .setArtist(localTrack.artists.joinToString(", ")) + .setAlbumTitle(localTrack.album) + .setIsPlayable(true) + .setIsBrowsable(false) + .build() + ) + .build() + } + + private suspend fun browseItemToMediaItem(item: MetadataBrowseItem): MediaItem? { + return when (item) { + is MetadataBrowseItem.Track -> buildTrackMediaItem(item.data) + is MetadataBrowseItem.Album -> buildAlbumMediaItem(item.data) + is MetadataBrowseItem.Artist -> buildArtistMediaItem(item.data) + is MetadataBrowseItem.Playlist -> buildPlaylistMediaItem(item.data) + is MetadataBrowseItem.User -> null + } + } + + private suspend fun buildStreamingUrl(trackId: String): String? { + val settings = settingsRepository.userSettings.first() + val port = settings.playbackProxyServerPort + if (port == 0) return null + return "http://127.0.0.1:$port/stream/$trackId" + } +} diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt new file mode 100644 index 00000000..bc3fdaf1 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt @@ -0,0 +1,451 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.media + +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.annotation.OptIn +import androidx.core.app.NotificationCompat +import androidx.media3.common.MediaItem as Media3MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaLibraryService.LibraryParams +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionCommands +import androidx.media3.session.SessionError +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.SettableFuture +import org.spotube.spotube.MainActivity +import org.spotube.spotube.R +import org.spotube.spotube.core.audioplayer.AudioPlayer +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.notification_playback_channel_description +import spotube.composeapp.generated.resources.notification_playback_channel_name + +@OptIn(UnstableApi::class) +class PlaybackService : MediaLibraryService(), KoinComponent { + + private val audioPlayer = inject().value as AudioPlayer + private val audioPlayerQueue: AudioPlayerQueue by inject() + private val mediaBrowseHelper: MediaBrowseHelper by inject() + private lateinit var librarySession: MediaLibrarySession + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onCreate() { + super.onCreate() + Log.i(TAG, "onCreate") + + createNotificationChannel() + + // Media3 posts the real playback notification; point it at our channel, + // ID and monochrome icon so it replaces the placeholder below in place. + setMediaNotificationProvider( + DefaultMediaNotificationProvider.Builder(this) + .setChannelId(CHANNEL_ID) + .setNotificationId(NOTIFICATION_ID) + .build() + .apply { setSmallIcon(R.drawable.ic_stat_spotube) } + ) + + val sessionActivity = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + // Short-lived placeholder until Media3 posts the playback notification. No localized text: + // loading it here would block the main thread (see createNotificationChannel). + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Spotube") + .setSmallIcon(R.drawable.ic_stat_spotube) + .setContentIntent(sessionActivity) + .setOngoing(true) + .build() + + // On Android 15+ a service created from the background (e.g. by a jam + // sync or queue restoration) hits this in startForeground instead of at + // the startForegroundService call site. Don't crash: playback keeps + // running in-process, just without the notification until the app is + // foregrounded and the service can start properly. + try { + startForeground(NOTIFICATION_ID, notification) + } catch (e: Exception) { + Log.w(TAG, "startForeground not allowed; continuing without notification", e) + } + + librarySession = + MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback()) + .setSessionActivity(sessionActivity) + .build() + + addSession(librarySession) + + Log.i(TAG, "Library session created") + } + + /** + * The channel must exist before startForeground, so it's created right away under the app name + * and renamed once the localized strings load. + * + * Never `runBlocking { getString(...) }` here: onCreate runs on the main thread, and Compose + * resources share one in-flight load per strings file. If App()'s stringResource started that + * load on the main dispatcher first, blocking main to await it deadlocks the app on its + * splash screen. + */ + @SuppressLint("ObsoleteSdkInt") + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NotificationManager::class.java) + fun channel(name: String, description: String? = null) = + NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_LOW).apply { + this.description = description + setShowBadge(false) + } + + manager.createNotificationChannel(channel("Spotube")) + serviceScope.launch { + // Re-creating a channel with the same ID updates its name and description. + manager.createNotificationChannel( + channel( + name = getString(Res.string.notification_playback_channel_name), + description = getString(Res.string.notification_playback_channel_description))) + } + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = + librarySession + + override fun onDestroy() { + Log.i(TAG, "onDestroy") + removeSession(librarySession) + librarySession.release() + super.onDestroy() + } + + override fun onTaskRemoved(rootIntent: Intent?) { + pauseAllPlayersAndStopSelf() + } + + private inner class LibrarySessionCallback : MediaLibrarySession.Callback { + + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): MediaSession.ConnectionResult { + val sessionCommands = SessionCommands.Builder() + .add(SessionCommand.COMMAND_CODE_LIBRARY_GET_CHILDREN) + .add(SessionCommand.COMMAND_CODE_LIBRARY_GET_ITEM) + .add(SessionCommand.COMMAND_CODE_LIBRARY_SEARCH) + .add(SessionCommand.COMMAND_CODE_LIBRARY_GET_SEARCH_RESULT) + .add(SessionCommand.COMMAND_CODE_LIBRARY_GET_LIBRARY_ROOT) + .add(SessionCommand.COMMAND_CODE_LIBRARY_SUBSCRIBE) + .add(SessionCommand.COMMAND_CODE_LIBRARY_UNSUBSCRIBE) + .build() + + val playerCommands = Player.Commands.Builder() + .addAllCommands() + .build() + + return MediaSession.ConnectionResult.accept( + sessionCommands, + playerCommands + ) + } + + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: LibraryParams? + ): ListenableFuture> { + return Futures.immediateFuture( + LibraryResult.ofItem(mediaBrowseHelper.buildRootItem(), params) + ) + } + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String + ): ListenableFuture> { + val future = SettableFuture.create>() + serviceScope.launch { + try { + val items = mediaBrowseHelper.getChildren(mediaId) + if (items.isEmpty()) { + future.set(LibraryResult.ofError(SessionError.ERROR_NOT_SUPPORTED)) + } else { + future.set(LibraryResult.ofItem(items.first(), null)) + } + } catch (e: Exception) { + Log.e(TAG, "onGetItem failed for $mediaId", e) + future.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) + } + } + return future + } + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ): ListenableFuture>> { + val future = SettableFuture.create>>() + serviceScope.launch { + try { + Log.d(TAG, "onGetChildren: parentId=$parentId, page=$page, pageSize=$pageSize") + val children = mediaBrowseHelper.getChildren(parentId) + val paged = if (pageSize in 1 until children.size) { + val start = page * pageSize + val end = minOf(start + pageSize, children.size) + if (start < children.size) children.subList(start, end) else emptyList() + } else { + children + } + Log.d(TAG, "onGetChildren: returning ${paged.size} items for $parentId") + future.set(LibraryResult.ofItemList(paged, params)) + } catch (e: Exception) { + Log.e(TAG, "onGetChildren failed for $parentId", e) + future.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) + } + } + return future + } + + override fun onSubscribe( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + params: LibraryParams? + ): ListenableFuture> { + return Futures.immediateFuture(LibraryResult.ofVoid(params)) + } + + override fun onUnsubscribe( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String + ): ListenableFuture> { + return Futures.immediateFuture(LibraryResult.ofVoid(null)) + } + + override fun onSearch( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + params: LibraryParams? + ): ListenableFuture> { + val future = SettableFuture.create>() + serviceScope.launch { + try { + val results = mediaBrowseHelper.search(query) + Log.d(TAG, "onSearch: query=$query, got ${results.size} results") + session.notifySearchResultChanged(browser, query, results.size, params) + future.set(LibraryResult.ofVoid(params)) + } catch (e: Exception) { + Log.e(TAG, "onSearch failed for $query", e) + future.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) + } + } + return future + } + + override fun onGetSearchResult( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ): ListenableFuture>> { + val future = SettableFuture.create>>() + serviceScope.launch { + try { + val results = mediaBrowseHelper.search(query) + val paged = if (pageSize in 1 until results.size) { + val start = page * pageSize + val end = minOf(start + pageSize, results.size) + if (start < results.size) results.subList(start, end) else emptyList() + } else { + results + } + Log.d( + TAG, + "onGetSearchResult: query=$query, page=$page, returning ${paged.size} items" + ) + future.set(LibraryResult.ofItemList(paged, params)) + } catch (e: Exception) { + Log.e(TAG, "onGetSearchResult failed for $query", e) + future.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) + } + } + return future + } + + override fun onSetMediaItems( + session: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: List, + startIndex: Int, + startPositionMs: Long + ): ListenableFuture { + val future = SettableFuture.create() + serviceScope.launch { + try { + handlePlaybackRequest(mediaItems, startIndex, startPositionMs) + val currentItems = buildCurrentMediaItemList() + future.set( + MediaSession.MediaItemsWithStartPosition( + currentItems, + startIndex, + startPositionMs + ) + ) + } catch (e: Exception) { + Log.e(TAG, "onSetMediaItems failed", e) + future.setException(e) + } + } + return future + } + + override fun onAddMediaItems( + session: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: List + ): ListenableFuture> { + val future = SettableFuture.create>() + serviceScope.launch { + try { + val entries = mutableListOf() + for (item in mediaItems) { + val track = mediaBrowseHelper.resolveTrackById( + item.mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_TRACK}:") + ) + if (track != null) { + entries.add(QueueEntry.StreamingTrack(track = track, url = "")) + } + } + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllToQueue(entries) + } + future.set(buildCurrentMediaItemList()) + } catch (e: Exception) { + Log.e(TAG, "onAddMediaItems failed", e) + future.setException(e) + } + } + return future + } + } + + private suspend fun handlePlaybackRequest( + mediaItems: List, + startIndex: Int, + startPositionMs: Long + ) { + val firstItem = mediaItems.firstOrNull() + if (firstItem != null) { + val mediaId = firstItem.mediaId + when { + mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_PLAYLIST}:") -> { + val playlistId = mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_PLAYLIST}:") + val helper: org.spotube.spotube.core.playback.CollectionPlaybackHelper by inject() + helper.playPlaylist(playlistId) + return + } + + mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") -> { + val albumId = mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") + val helper: org.spotube.spotube.core.playback.CollectionPlaybackHelper by inject() + helper.playAlbum(albumId) + return + } + + mediaId == MediaBrowseHelper.MEDIA_ID_SAVED_TRACKS -> { + val helper: org.spotube.spotube.core.playback.CollectionPlaybackHelper by inject() + helper.playSavedTracks() + return + } + + mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ARTIST_TRACKS}:") -> { + mediaBrowseHelper.resolveAndPlayFromMediaId(mediaId) + return + } + } + } + + val entries = mutableListOf() + for (item in mediaItems) { + val track = mediaBrowseHelper.resolveTrackById( + item.mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_TRACK}:") + ) + if (track != null) { + entries.add(QueueEntry.StreamingTrack(track = track, url = "")) + } + } + if (entries.isNotEmpty()) { + audioPlayerQueue.load(entries, autoPlay = true, startPosition = startIndex) + } + } + + private suspend fun buildCurrentMediaItemList(): List = + withContext(Dispatchers.Main) { + val count = audioPlayer.player.mediaItemCount + val items = mutableListOf() + for (i in 0 until count) { + val item = audioPlayer.player.getMediaItemAt(i) + items.add(item) + } + items + } + + companion object { + private const val TAG = "PlaybackService" + private const val CHANNEL_ID = "spotube_playback" + private const val NOTIFICATION_ID = 1 + } +} diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.android.kt new file mode 100644 index 00000000..672d70f4 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.android.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.downloads + +import android.content.Context +import android.media.MediaScannerConnection +import org.koin.core.context.GlobalContext + +internal actual fun addToMediaLibrary(path: String) { + val context = GlobalContext.get().get() + MediaScannerConnection.scanFile(context, arrayOf(path), null, null) +} diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt new file mode 100644 index 00000000..f5d1d118 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt @@ -0,0 +1,413 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.local_tracks.media + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.database.ContentObserver +import android.os.Build +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.graphics.BitmapFactory +import android.graphics.Bitmap +import android.provider.MediaStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import java.io.ByteArrayOutputStream +import java.util.concurrent.ConcurrentHashMap +import org.spotube.spotube.core.di.injectLogger +import org.koin.core.component.KoinComponent +import java.io.File +import java.util.Locale + +class AndroidLocalMediaDiscoveryService( + private val context: Context) : LocalMediaDiscoveryService, KoinComponent { + val logger by injectLogger() + + private class TagInfo( + val title: String?, + val artists: List, + val album: String?, + val durationMs: Long, + val cover: ByteArray?) + + private class PendingTrack( + val uri: String, + val filePath: String?, + val modifiedAt: Long, + val folderPath: String, + val fallbackName: String, + val mediaStoreArtists: List, + val mediaStoreAlbum: String?, + val mediaStoreDurationMs: Long) + + // Parsing tags + cover with lofty is the expensive part, so keep results across rescans. + private val tagCache = ConcurrentHashMap() + + override suspend fun discoverFolders(roots: List): List = withContext(Dispatchers.IO) { + discoverFoldersBlocking(roots) + } + + private suspend fun discoverFoldersBlocking(roots: List): List { + logger.i { "discoverFolders: start roots=${roots.size}" } + if (!hasReadPermission()) { + logger.w { "discoverFolders: missing media permission, returning empty result" } + return emptyList() + } + + val pending = mutableListOf() + val knownFilePaths = hashSetOf() + val resolver = context.contentResolver + val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + var totalRows = 0 + var acceptedRows = 0 + var skippedNonAudioRows = 0 + var unknownFolderRows = 0 + var nullCursor = false + + val projection = arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.DISPLAY_NAME, + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.ARTIST, + MediaStore.Audio.Media.ALBUM, + MediaStore.Audio.Media.DURATION, + MediaStore.Audio.Media.MIME_TYPE, + MediaStore.Audio.Media.DATA, + MediaStore.Audio.Media.DATE_MODIFIED) + if (Build.VERSION.SDK_INT >= 29) { + arrayOf(MediaStore.Audio.Media.RELATIVE_PATH) + } else { + emptyArray() + } + + val sortOrder = "${MediaStore.Audio.Media.DATE_ADDED} DESC" + + logger.d { "discoverFolders: querying MediaStore with collection=${collection} projection=${projection.joinToString()} sortOrder=$sortOrder" } + + resolver.query(collection, projection, null, null, sortOrder)?.use { cursor -> + val idCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) + val displayNameCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) + val titleCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) + val artistCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) + val albumCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM) + val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) + val mimeTypeCol = cursor.getColumnIndex(MediaStore.Audio.Media.MIME_TYPE) + val relativePathCol = cursor.getColumnIndex(MediaStore.Audio.Media.RELATIVE_PATH) + val dataCol = cursor.getColumnIndex(MediaStore.Audio.Media.DATA) + val modifiedCol = cursor.getColumnIndex(MediaStore.Audio.Media.DATE_MODIFIED) + + logger.d { + "discoverFolders: query ok columns(mimeType=$mimeTypeCol, relativePath=$relativePathCol)" + } + + while (cursor.moveToNext()) { + totalRows += 1 + val id = cursor.getLong(idCol) + val uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon() + .appendPath(id.toString()) + .build() + .toString() + + val rawTitle = cursor.getString(titleCol).orEmpty().trim() + val displayName = cursor.getString(displayNameCol).orEmpty().trim() + val mimeType = if (mimeTypeCol >= 0) { + cursor.getString(mimeTypeCol).orEmpty().trim() + } else { + "" + } + if (!isAudioCandidate(displayName, mimeType)) { + skippedNonAudioRows += 1 + if (skippedNonAudioRows <= 5) { + logger.d { + "discoverFolders: skip row id=$id displayName=$displayName mimeType=$mimeType" + } + } + continue + } + + val relativePath = if (relativePathCol >= 0) { + cursor.getString(relativePathCol).orEmpty().trim() + } else { + "Unknown" + } + val folderPath = relativePath.ifBlank { "Unknown" } + if (folderPath == "Unknown") unknownFolderRows += 1 + val artist = cursor.getString(artistCol).orEmpty() + .takeUnless { it.equals("", true) } + val album = cursor.getString(albumCol).orEmpty().takeUnless { it.isBlank() } + val durationMs = cursor.getLong(durationCol).coerceAtLeast(0L) + val filePath = if (dataCol >= 0) cursor.getString(dataCol)?.takeIf { it.isNotBlank() } else null + val modifiedAt = if (modifiedCol >= 0) cursor.getLong(modifiedCol) else 0L + filePath?.let(knownFilePaths::add) + + val name = rawTitle.ifBlank { + displayName.substringBeforeLast('.').takeIf { it.isNotBlank() } ?: displayName + } + + pending += PendingTrack( + uri = uri, + filePath = filePath, + modifiedAt = modifiedAt, + folderPath = folderPath, + fallbackName = name.ifBlank { "Unknown track" }, + mediaStoreArtists = listOfNotNull(artist), + mediaStoreAlbum = album, + mediaStoreDurationMs = durationMs) + acceptedRows += 1 + } + } ?: run { + nullCursor = true + } + + if (nullCursor) { + logger.w { "discoverFolders: MediaStore query returned null cursor" } + } + + // Files MediaStore already indexed must not be listed a second time by the filesystem scan. + discoverFileSystemFallback(roots, knownFilePaths, pending) + + val merged = buildFolders(pending) + + logger.i { + "discoverFolders: done folders=${merged.size} mediaStoreTracks=$acceptedRows tracks=${pending.size} totalRows=$totalRows skippedNonAudio=$skippedNonAudioRows unknownFolderRows=$unknownFolderRows" + } + + if (merged.isNotEmpty()) { + val preview = merged.take(3).joinToString { "${it.name}:${it.trackCount}" } + logger.d { "discoverFolders: folder preview=$preview" } + } + + return merged + } + + override fun observeChanges( + roots: List, + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { + logger.i { "observeChanges: start roots=${roots.size}" } + if (!hasReadPermission()) { + logger.w { "observeChanges: missing media permission, observer not registered" } + return null + } + + val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + logger.d { "observeChanges: MediaStore changed selfChange=$selfChange" } + onChanged() + } + } + + context.contentResolver.registerContentObserver( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + true, + observer) + + logger.i { "observeChanges: observer registered" } + + return LocalMediaObservation { + context.contentResolver.unregisterContentObserver(observer) + logger.i { "observeChanges: observer unregistered" } + } + } + + private fun hasReadPermission(): Boolean { + val permission = if (Build.VERSION.SDK_INT >= 33) { + Manifest.permission.READ_MEDIA_AUDIO + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + + val granted = + context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED + logger.d { "hasReadPermission: permission=$permission granted=$granted" } + return granted + } + + private fun normalizeFolderName(path: String): String { + return path.trimEnd('/', '\\').substringAfterLast('/').ifBlank { + path.trimEnd('/', '\\').substringAfterLast('\\').ifBlank { "Unknown" } + } + } + + private fun isAudioCandidate(displayName: String, mimeType: String): Boolean { + if (mimeType.startsWith("audio/", ignoreCase = true)) return true + val extension = displayName.substringAfterLast('.', missingDelimiterValue = "") + .lowercase(Locale.getDefault()) + return extension in SUPPORTED_EXTENSIONS + } + + private fun discoverFileSystemFallback( + roots: List, + knownFilePaths: Set, + out: MutableList) { + val candidateRoots = resolveCandidateRoots(roots) + if (candidateRoots.isEmpty()) { + logger.d { "discoverFileSystemFallback: no candidate roots" } + return + } + val storageRoot = Environment.getExternalStorageDirectory().absolutePath.trimEnd('/') + "/" + val seen = hashSetOf() + var added = 0 + + candidateRoots.forEach { root -> + scanDirectory(root) { file -> + if (file.extension.lowercase(Locale.getDefault()) !in SUPPORTED_EXTENSIONS) return@scanDirectory + val absolute = file.absolutePath + if (absolute in knownFilePaths || !seen.add(absolute)) return@scanDirectory + + // Same key shape as MediaStore's RELATIVE_PATH ("Download/") so folders merge. + val parent = file.parentFile?.absolutePath.orEmpty().trimEnd('/') + "/" + val folderPath = if (parent.startsWith(storageRoot)) parent.removePrefix(storageRoot) else parent + out += PendingTrack( + uri = absolute, + filePath = absolute, + modifiedAt = file.lastModified() / 1000, + folderPath = folderPath, + fallbackName = file.nameWithoutExtension.ifBlank { file.name }, + mediaStoreArtists = emptyList(), + mediaStoreAlbum = null, + mediaStoreDurationMs = 0L) + added += 1 + } + } + logger.i { "discoverFileSystemFallback: extra files not in MediaStore=$added" } + } + + private suspend fun buildFolders(pending: List): List { + val permits = Semaphore(4) + val tracks = coroutineScope { + pending.map { item -> + async(Dispatchers.IO) { + val tag = permits.withPermit { readTags(item) } + item.folderPath to LocalMediaTrack( + path = item.uri, + name = tag?.title?.takeIf { it.isNotBlank() } ?: item.fallbackName, + artists = tag?.artists?.takeIf { it.isNotEmpty() } ?: item.mediaStoreArtists, + durationMs = tag?.durationMs?.takeIf { it > 0 } ?: item.mediaStoreDurationMs, + album = tag?.album?.takeIf { it.isNotBlank() } ?: item.mediaStoreAlbum, + coverBytes = tag?.cover) + } + }.awaitAll() + } + return tracks.groupBy({ it.first }, { it.second }) + .map { (path, list) -> + LocalMediaFolder( + path = path, + name = normalizeFolderName(path), + tracks = list.sortedBy { it.name.lowercase(Locale.getDefault()) }) + } + .sortedBy { it.name.lowercase(Locale.getDefault()) } + } + + private fun readTags(item: PendingTrack): TagInfo? { + val path = item.filePath ?: return null + val key = "$path:${item.modifiedAt}" + tagCache[key]?.let { return it } + val file = File(path) + if (!file.isFile || !file.canRead()) return null + val info = try { + val meta = uniffi.compose_app.readAudioMetadata(path) + TagInfo( + title = meta.title, + artists = meta.artists, + album = meta.album, + durationMs = meta.durationMs, + cover = meta.coverBytes?.let(::thumbnail)) + } catch (t: Throwable) { + logger.d { "readTags: failed path=$path reason=${t.message}" } + null + } + if (info != null) tagCache[key] = info + return info + } + + // Embedded covers can be several MB; the cache is serialised as JSON, so keep a small JPEG. + private fun thumbnail(bytes: ByteArray): ByteArray? { + if (bytes.isEmpty()) return null + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + var sample = 1 + while (bounds.outWidth / (sample * 2) >= THUMBNAIL_SIZE && bounds.outHeight / (sample * 2) >= THUMBNAIL_SIZE) sample *= 2 + val decoded = BitmapFactory.decodeByteArray( + bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return null + val scale = THUMBNAIL_SIZE.toFloat() / minOf(decoded.width, decoded.height) + val bitmap = if (scale < 1f) Bitmap.createScaledBitmap( + decoded, (decoded.width * scale).toInt().coerceAtLeast(1), + (decoded.height * scale).toInt().coerceAtLeast(1), true) else decoded + return ByteArrayOutputStream().use { + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) + it.toByteArray() + } + } + + private fun resolveCandidateRoots(roots: List): List { + val configured = roots.mapNotNull { raw -> + val normalized = raw.trim() + if (normalized.isBlank()) return@mapNotNull null + File(normalized) + } + + val defaults = listOfNotNull( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), + context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)) + + return (configured + defaults) + .map { it.absoluteFile } + .distinctBy { it.absolutePath } + .filter { it.exists() && it.isDirectory } + } + + private fun scanDirectory(root: File, onFile: (File) -> Unit) { + val queue = ArrayDeque() + queue.add(root) + + while (queue.isNotEmpty()) { + val current = queue.removeFirst() + val children = current.listFiles() ?: continue + children.forEach { child -> + if (child.isDirectory) { + queue.add(child) + } else if (child.isFile) { + onFile(child) + } + } + } + } + + companion object { + private const val THUMBNAIL_SIZE = 160 + private val SUPPORTED_EXTENSIONS = setOf( + "mp3", + "m4a", + "aac", + "flac", + "wav", + "ogg", + "opus", + "wma") + } +} diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt new file mode 100644 index 00000000..6b066575 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.local_tracks.media + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState { + val context = LocalContext.current + val permission = remember { + if (Build.VERSION.SDK_INT >= 33) { + Manifest.permission.READ_MEDIA_AUDIO + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + } + + var isGranted by remember { + mutableStateOf( + context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) + } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission()) { granted -> + isGranted = granted || + context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED + } + + return LocalMediaPermissionState( + isGranted = isGranted, + requestPermission = { launcher.launch(permission) }) +} diff --git a/composeApp/src/androidMain/res/drawable-hdpi/ic_stat_spotube.png b/composeApp/src/androidMain/res/drawable-hdpi/ic_stat_spotube.png new file mode 100644 index 00000000..3366b209 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-hdpi/ic_stat_spotube.png differ diff --git a/composeApp/src/androidMain/res/drawable-mdpi/ic_stat_spotube.png b/composeApp/src/androidMain/res/drawable-mdpi/ic_stat_spotube.png new file mode 100644 index 00000000..34ece2c6 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-mdpi/ic_stat_spotube.png differ diff --git a/composeApp/src/androidMain/res/drawable-xhdpi/ic_stat_spotube.png b/composeApp/src/androidMain/res/drawable-xhdpi/ic_stat_spotube.png new file mode 100644 index 00000000..c6c20ea4 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-xhdpi/ic_stat_spotube.png differ diff --git a/composeApp/src/androidMain/res/drawable-xxhdpi/ic_stat_spotube.png b/composeApp/src/androidMain/res/drawable-xxhdpi/ic_stat_spotube.png new file mode 100644 index 00000000..c1e5e263 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-xxhdpi/ic_stat_spotube.png differ diff --git a/composeApp/src/androidMain/res/drawable-xxxhdpi/ic_stat_spotube.png b/composeApp/src/androidMain/res/drawable-xxxhdpi/ic_stat_spotube.png new file mode 100644 index 00000000..65dff9e5 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-xxxhdpi/ic_stat_spotube.png differ diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..8fe69f1a --- /dev/null +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..8fe69f1a --- /dev/null +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..a5172fdf Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..c179cb79 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_monochrome.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..66caafd4 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..f454ac1a Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..11b63a79 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..f096f291 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_monochrome.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..108f8d41 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..4d1c0d9d Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..25ce076f Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..50fa2ecc Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_monochrome.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..c74596a5 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..c4e0a2f1 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..f7f2f2ff Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..57bc7641 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..9814b717 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..08078439 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..dd0287fc Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..92478998 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..2b749b28 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..f49af5e3 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/values/colors.xml b/composeApp/src/androidMain/res/values/colors.xml new file mode 100644 index 00000000..19670936 --- /dev/null +++ b/composeApp/src/androidMain/res/values/colors.xml @@ -0,0 +1,21 @@ + + + + + #000000 + diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml new file mode 100644 index 00000000..4ffaafbe --- /dev/null +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -0,0 +1,22 @@ + + + + Spotube + Now Playing + Play music from your phone to start listening in the car + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/xml/automotive_app_desc.xml b/composeApp/src/androidMain/res/xml/automotive_app_desc.xml new file mode 100644 index 00000000..1db61205 --- /dev/null +++ b/composeApp/src/androidMain/res/xml/automotive_app_desc.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml b/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml new file mode 100644 index 00000000..c5f82544 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/liked_tracks.jpg b/composeApp/src/commonMain/composeResources/drawable/liked_tracks.jpg new file mode 100644 index 00000000..71e010dc Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/liked_tracks.jpg differ diff --git a/composeApp/src/commonMain/composeResources/drawable/plugin_logo_lrclib.png b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_lrclib.png new file mode 100644 index 00000000..9da9cb69 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_lrclib.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.png b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.png new file mode 100644 index 00000000..3bcf0799 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.png differ diff --git a/website/public/images/spotube-logo.png b/composeApp/src/commonMain/composeResources/drawable/spotube_logo.png similarity index 100% rename from website/public/images/spotube-logo.png rename to composeApp/src/commonMain/composeResources/drawable/spotube_logo.png diff --git a/assets/fonts/Cookie-Regular.ttf b/composeApp/src/commonMain/composeResources/font/cookie_regular.ttf similarity index 100% rename from assets/fonts/Cookie-Regular.ttf rename to composeApp/src/commonMain/composeResources/font/cookie_regular.ttf diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml new file mode 100644 index 00000000..6671d6d9 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml @@ -0,0 +1,950 @@ + + + + قائمة الانتظار + مسح قائمة الانتظار + تصفية قائمة الانتظار... + لا توجد عناصر في قائمة الانتظار + + ابحث عن أغانٍ وفنانين وألبومات... + مسح السجل بالكامل + بحث... + + + مكتبتك + المكتبة + قوائم التشغيل + الألبومات + الفنانون + المقاطع المحلية + التنزيلات + ابحث في قوائم التشغيل المحفوظة… + ابحث في الألبومات المحفوظة… + ابحث في الفنانين المحفوظين… + ابحث في المجلدات أو المقاطع… + تصفية المكتبة + إغلاق التصفية + إنشاء قائمة تشغيل + تحديث المقاطع المحلية + التبديل إلى عرض الشبكة + التبديل إلى عرض القائمة + لا توجد قوائم تشغيل محفوظة + لا توجد قوائم تشغيل تطابق “%1$s” + لا توجد ألبومات محفوظة + لا توجد ألبومات تطابق “%1$s” + لا يوجد فنانون محفوظون + لا يوجد فنانون يطابقون “%1$s” + + + المجلدات + مجلد + %1$s • %2$s + + %1$d مقطع + مقطع واحد + %1$d مقطعان + %1$d مقاطع + %1$d مقطعًا + %1$d مقطع + + + %1$d مجلد + مجلد واحد + %1$d مجلدان + %1$d مجلدات + %1$d مجلدًا + %1$d مجلد + + العودة إلى المجلدات + تشغيل المجلد + يلزم الوصول إلى الصوت + اسمح بالوصول إلى الصوت لتصفح الموسيقى على جهازك. + منح الإذن + لم يتم العثور على مجلدات محلية + لا توجد مقاطع في هذا المجلد + لا توجد مجلدات أو مقاطع مطابقة + تعذّر تحديث الوسائط المحلية + + + %1$d نشط • %2$d مكتمل + مسح المكتملة + لا توجد تنزيلات بعد + جارٍ التنزيل %1$d% + في قائمة الانتظار + مكتمل + ملغى + فشل + فشل: %1$s + إلغاء التنزيل + إعادة محاولة التنزيل + إزالة التنزيل + + + فنان + محظور + المقاطع التي أعجبتك + أغانيك المحفوظة + فنان غير معروف + عنوان المقطع + اسم الفنان + + محدد + + الإعدادات + + الإضافات + اللغة والمنطقة + المظهر + التشغيل + التنزيلات + التخزين المؤقت + سطح المكتب + التحديثات + الدعم + حول + + إغلاق + حفظ + إلغاء + + اللغة + لغة واجهة التطبيق. الحالية: %1$s + افتراضي النظام + البلد + البلد المستخدم للمحتوى والتوفر. الحالي: %1$s + %1$s (%2$s) + + السمة + اختر كيف يتوافق Spotube مع مظهر نظامك. الحالية: %1$s + فاتح + داكن + النظام + + السطح + مادة خلفية التطبيق. الحالية: %1$s + مائي + جليدي + كروم + أكريليك + زجاج + + لون التمييز + لوحة الألوان الحالية: %1$s + لون التمييز + اختر لوحة ألوان تمييز واعرض معاينتها في السمتين الفاتحة والداكنة. + فاتح + داكن + معاينة + زر + أخضر غوبلن + بنفسجي كهربائي + سماوي محيطي + برتقالي الغروب + حديقة الورد + أزرق منتصف الليل + أردوازي معدني + + الشكل والمظهر + مادة السطح ولون التمييز. الحالي: %1$s · %2$s + الشكل والمظهر + اختر مادة السطح، ثم لون تمييز يتناسب معها. كل خيار هو تركيبة منتقاة بعناية. + السطح + لون التمييز + ثانوي + + لوحة الألوان + لوحة ألوان تمييز Millennium. الحالية: %1$s + لوحة الألوان + اختر لوحة ألوان تمييز Millennium واعرض معاينتها في الوضعين الفاتح والداكن. + لوحة الألوان + لوحة ألوان Millennium ولون التمييز. الحالية: %1$s + أزرق + برتقالي + ليموني + + تخصيص مجلد التنزيل + المجلد الحالي: %1$s + اتركه فارغًا لاستخدام مجلد الموسيقى الافتراضي. + C:\Music\Spotube + مجلدات الوسائط المحلية + مجلدات مخصصة للفحص على سطح المكتب. المضافة: %1$d + لم تتم إضافة أي مجلد مخصص بعد + مجلدات مخصصة لمكتبة موسيقاك المحلية. + إدارة + إضافة مجلد + إزالة المجلد + لم تتم إضافة مجلدات مخصصة. + إزالة %1$s من قائمة الفحص + يتم تضمين مجلد التنزيل ومجلد ذاكرة التخزين المؤقت للموسيقى تلقائيًا. + يتم تضمين مجلد التنزيل تلقائيًا. + يتم تضمين مجلد ذاكرة التخزين المؤقت للموسيقى تلقائيًا. + جارٍ الحفظ… + تعذّر حفظ المجلدات. لا تزال تغييراتك موجودة؛ حاول مرة أخرى. + + %1$d مجلد + مجلد واحد + %1$d مجلدان + %1$d مجلدات + %1$d مجلدًا + %1$d مجلد + + صيغة تنزيل الموسيقى + الترميز والحاوية المفضلان للتنزيلات. الحالي: %1$s + جودة تنزيل الموسيقى + + صيغة بث الموسيقى + الترميز والحاوية المفضلان لبث الموسيقى. الحالي: %1$s + جودة بث الموسيقى + الحالي: %1$s + تفعيل التخزين المؤقت للموسيقى + خزّن الصوت الذي يتم بثه محليًا لتسريع إعادة التشغيل. + مجلد ذاكرة التخزين المؤقت + الحالي: %1$s + الحالي: ذاكرة التخزين المؤقت الافتراضية للتطبيق + الحد الأقصى لحجم ذاكرة التخزين المؤقت + بلا حد + الحد الحالي: %1$d MB + حدد الحجم الأقصى لذاكرة التخزين المؤقت بوحدة MB. أدخل 0 أو اتركه فارغًا لعدم وضع حد. + 500 + يجب أن يكون الحجم 0 أو رقمًا موجبًا. + تفعيل التشغيل اللانهائي + واصل التشغيل بمقاطع مقترحة عند انتهاء قائمة الانتظار. + منفذ خادم وكيل التشغيل + السماح بالتحكم عن بُعد + اسمح للأجهزة الأخرى على الشبكة نفسها بالتحكم في التشغيل عند اتصالها. + اسم الجهاز للتحكم عن بُعد + الاسم الذي تراه الأجهزة الأخرى عند اكتشاف هذا الجهاز. الحالي: %1$s + اختر اسمًا مألوفًا يظهر للأجهزة الأخرى على شبكتك عند اكتشافها لنسخة Spotube هذه. + مثال: Spotube غرفة المعيشة + اسم مضيف الجهاز + المنفذ الذي يستخدمه خادم وكيل التشغيل. الحالي: %1$d + اختر منفذًا بين 1 و65535. + 14769 + أدخل عددًا صحيحًا صالحًا. + يجب أن يكون المنفذ بين 1 و65535. + + قائمة الحظر + إدارة المقاطع والفنانين المحظورين + + التصغير إلى شريط النظام + إبقاء Spotube قيد التشغيل في شريط النظام عند تصغير النافذة. + حالة Discord التفاعلية + اعرض حالة التشغيل الحالية في Discord على سطح المكتب. + + التحقق من التحديثات تلقائيًا + البحث عن إصدارات Spotube الجديدة في الخلفية. + + إدارة الإضافات + أدِر إضافاتك + إضافة %1$s الافتراضية + البيانات الوصفية + الصوت + كلمات الأغاني + تسجيل الاستماع + لم يتم تحديد إضافة + تغيير + تحديد + محددة + مسح التحديد + لا تتوفر إضافات + إضافة %1$s + مسح + + ضبط + مدير الإضافات + يُرجى إدخال URL + يجب أن يبدأ URL بـ http:// أو https:// + تعذّر تنزيل الإضافة + لم يتم تثبيت أي إضافات بعد + الصق رابط تنزيل أو اختر ملفًا أعلاه. + + %1$d إضافة مثبتة + إضافة واحدة مثبتة + %1$d إضافتان مثبتتان + %1$d إضافات مثبتة + %1$d إضافة مثبتة + %1$d إضافة مثبتة + + تثبيت إضافة + https://example.com/plugin.smplug + تنزيل + التثبيت من ملف (.smplug) + نشطة + v%1$s + مدمجة + إزالة الإضافة + تسجيل الدخول + تسجيل الخروج + التنزيل من URL + التثبيت من ملف + تثبيت + + • %1$s + الأذونات المطلوبة + لا تتطلب هذه الإضافة أي أذونات خاصة. + المثبتة مقابل المقدَّمة + المثبتة + المقدَّمة + API %1$s → %2$s + التخزين الدائم + قراءة البيانات وكتابتها على القرص + طلبات الشبكة + إرسال البيانات واستقبالها عبر الإنترنت + WebView + عرض محتوى الويب داخل التطبيق + الجام الجماعي + وسيط MQTT + مضيف الوسيط + %1$s:%2$d + منفذ الوسيط + استخدام TLS + اسم المستخدم (اختياري) + كلمة المرور (اختياري) + بادئة معرّف العميل + تُستخدم لتعريف نسخة Spotube هذه + تشفير الاتصال بالوسيط + تحقق من إعدادات الوسيط الحالية قبل بدء غرفة. + اختبار الاتصال + جارٍ الاختبار… + تم الاتصال خلال %1$d ms + فشل: %1$s + وسيط مؤقت — اضبط وسيطك الخاص للاستضافة الذاتية + الاتصال + بيانات الاعتماد + الهوية + التشخيص + + + الجام الجماعي + إنشاء + انضمام + أنشئ غرفة، ثم شارك رمزها مع أصدقائك. + إنشاء غرفة + الانضمام إلى غرفة + جارٍ الاتصال… + رمز الغرفة + ABC123 + الوسيط: %1$s + اضبط وسيطًا في الإعدادات لاستضافة غرفة أو الانضمام إليها. + إعدادات الوسيط + متصل + جارٍ إعادة الاتصال… + نسخ الرمز + تم النسخ! + مشاركة + المشاركون (%1$d) + المضيف + مستمع + في انتظار المستمعين… + إزالة + حظر + إدارة + مغادرة الغرفة + تعذّر الاتصال + تجاهل + تعذّر إنشاء جلسة الجام + تعذّر الانضمام إلى جلسة الجام + أدخل رمز غرفة مكوّنًا من ستة أحرف وأرقام. + انضم إلى جام Spotube الخاص بي باستخدام الرمز: %1$s + + الانتقال إلى الألبوم + مؤقت النوم + تفاصيل المقطع + تشغيل + إيقاف مؤقت + لا شيء قيد التشغيل + إضافة إلى قائمة الانتظار + + تجاهل + تحديث + يتوفر Spotube %1$s + اطّلع على الجديد في هذا الإصدار في صفحة تنزيلات Spotube. + يتوفر %1$s %2$s + المثبت حاليًا: %1$s + اطّلع على الجديد في هذا التحديث في صفحة الإضافة. + + تخطٍّ + التالي + رجوع + البدء + Spotube + موسيقى بلا قيود. + مشغّل مفتوح المصدر لعالم من الموسيقى.\nلك أن تستكشفه. ولك أن تجعله على طريقتك. + ساعد في استمرار هذا المشروع. + يبني Spotube أناسٌ حقيقيون، لا شركة اشتراكات. إن أصبح جزءًا من حياتك اليومية، فإن القليل من الدعم يساعدنا على منحه الرعاية التي يستحقها.\n\nشكرًا لإفساحك المجال للبرمجيات المستقلة. + ابدأ بإضافة كتالوج للعثور على الأغاني وقوائم التشغيل. اختر إضافة صوت لتشغيلها. كلمات الأغاني وسجل الاستماع اختياريان. اكتشف إضافة أدناه، وراجع أذوناتها، وثبّتها، ثم حدّدها هنا. + اكتشاف الإضافات + مصادرك + إعادة المحاولة + المزيد من الإضافات + لم يتم العثور على إضافات جديدة. يمكنك أيضًا تثبيت إضافة من رابط أو من ملف .smplug أعلاه. + اكتشاف الإضافات غير متاح حاليًا. حاول مرة أخرى، أو ثبّت من رابط أو ملف. + مرحبًا + الدعم + الإضافات + الدليل + + كيفية استخدام Spotube + أربع خطوات سريعة وتبدأ الاستماع. + تسجيل الدخول + اربط حسابك على Spotify، أو اختر إضافة الكتالوج الخاصة بك. + ابحث وشغّل + اعثر على أي أغنية أو ألبوم أو فنان، ثم اضغط تشغيل. + استمع بلا إنترنت + نزّل المقاطع لمواصلة التشغيل دون اتصال. + تابع كلمات الأغاني + افتح كلمات الأغاني المتزامنة من المشغّل أثناء تشغيل المقطع. + + + موسيقاك. مصادرك. قواعدك. + مشغّل موسيقى مجاني ومفتوح المصدر، بناه مجتمع يحب الموسيقى ببساطة. + مفتوح المصدر + بلا إعلانات + بلا قياس عن بُعد + مدعوم بالإضافات + هيا بنا + + + الداعمون يبقوننا مستمرين + صُنع بشغف. ويستمر بفضلك. + Spotube مجاني 100%، وحر، ومفتوح المصدر — بلا إعلانات، وبلا اشتراكات، وبلا تتبع. كل ميزة موجودة لأن أشخاصًا مثلك اختاروا المساعدة في استمرار تدفق الشيفرة. + Patreon + راعٍ شهري + ادعم التطوير والبنية التحتية بشكل مستمر + Buy Me a Coffee + قهوة لمرة واحدة + موّل جلسة برمجة ليلية بإكرامية سريعة + Open Collective + صندوق المجتمع + دعم مالي شفاف يديره المجتمع + نشكرك من أعماق قلوبنا لإيمانك بالموسيقى المفتوحة. + الدعم اختياري تمامًا — يبقى Spotube مجانيًا في كل الأحوال. + ادعم + فتح %1$s + + + ادعم Spotube + ادعم Spotube + Spotube مجاني وحر ومفتوح المصدر — بلا إعلانات، وبلا اشتراكات، وبلا تتبع. إن كان يُسعدك، ففكّر في دعم المشروع. + يمكنك إيقاف هذه التذكيرات من الإعدادات ← الدعم. + ربما لاحقًا + تذكيرات الدعم + عرض تذكير لطيف لدعم المشروع كل 7 أيام. + + + بنية معيارية + اختر مصدر موسيقاك. + يفصل Spotube بين اكتشاف الكتالوج وبث الصوت وكلمات الأغاني المتزامنة. اضبط محركاتك أدناه أو بدّلها في أي وقت. + محرك البيانات الوصفية + يشغّل اكتشاف الموسيقى والبحث وقوائم المقاطع وقوائم التشغيل. + محرك الصوت + يجلب تدفقات الصوت التي يتم تشغيلها ويفك ترميزها. + محرك كلمات الأغاني + يعرض كلمات كاريوكي متزامنة زمنيًا في الوقت الفعلي. + محرك تسجيل الاستماع + يسجّل سجل الاستماع في Last.fm أو ListenBrainz. + نشط + مدمج + جاهز + اختياري + اختر محركًا + المحركات المثبتة + تعيين كافتراضي + NewPipe YouTube وLRCLib Lyrics مدمجان وجاهزان للبث فورًا. + أدِر إضافات المجتمع أو أضف إضافات جديدة في أي وقت من الإعدادات ← الإضافات. + + + بنية عصرية بروح كلاسيكية + صوت Hi-Fi نقي، بلا وسطاء + إليك كيف ينسّق Spotube التشغيل اللامركزي مباشرةً على جهازك. + اتصال مباشر من نظير إلى نظير • 256 kbps Opus + الساعة الزرقاء + Spotube · عرض توضيحي للمشغّل + قليل من الموسيقى للحظات العابرة. + 1. اكتشاف لامركزي + تذهب الاستعلامات مباشرةً إلى إضافة الكتالوج التي اخترتها. لا ينشئ Spotube أبدًا ملفًا تعريفيًا لذوقك. + 2. تحديد البث المباشر + يُبث الصوت مباشرةً من خوادم المحتوى إلى جهازك. بلا قفزات عبر وكلاء ولا جمع للبيانات عن بُعد. + 3. معالجة صوتية وتشغيل محليان + يتم فك الترميز والتخزين المؤقت محليًا مع عناصر تحكم في المعادل وخيار التنزيل للاستماع دون اتصال. + 4. كلمات متزامنة وجام + تتبع كلمات الكاريوكي توقيت التشغيل، مع تحكم عن بُعد عبر LAN والجام الجماعي. + لا خوادم وسيطة. يتم التشغيل على جهازك مباشرةً — الخصوصية في صميم التصميم. + + + اختر جهازًا + %1$s: «%2$s» + تشغيل + إضافة إلى قائمة الانتظار + تشغيل التالي + هذا الجهاز + على هذا الجهاز + جهاز بعيد + جهاز بعيد (%1$s) + على الجهاز المتصل + + + فشل الاتصال + انقطع الاتصال + فشل الإرسال + فشل الاكتشاف: %1$s + تعذّر الاتصال: %1$s + + + الأجهزة + تحديث + قطع الاتصال + جارٍ الاتصال... + جارٍ البحث عن أجهزة على الشبكة... + لم يتم العثور على أجهزة + تأكد من تفعيل \"السماح بالتحكم عن بُعد\" في إعدادات الجهاز الآخر. + جهاز غير معروف + جارٍ التحديد... + + + التحكم عن بُعد + غير متصل + خطأ في الاتصال: %1$s + مقطع غير معروف + فنان غير معروف + مستوى الصوت + إزالة من قائمة الانتظار + خيارات إضافية + + + أين تريد التشغيل؟ + لديك جلسة جام نشطة. اختر أين تريد تشغيل هذه المجموعة. + التشغيل هنا + اقتراح للجام + + + لم يتم ضبط وسيط للجام + مضيف الوسيط فارغ + فشل الاتصال + انقطع الاتصال + + + الوسيط + مجهول + + + تمت الإضافة إلى قائمة انتظار الجام + + تمت إضافة %1$d مقطع إلى قائمة انتظار الجام + تمت إضافة مقطع واحد إلى قائمة انتظار الجام + تمت إضافة %1$d مقطعين إلى قائمة انتظار الجام + تمت إضافة %1$d مقاطع إلى قائمة انتظار الجام + تمت إضافة %1$d مقطعًا إلى قائمة انتظار الجام + تمت إضافة %1$d مقطع إلى قائمة انتظار الجام + + + + طلب تحكم عن بُعد + يريد \"%1$s\" التحكم في التشغيل على هذا الجهاز. + رفض + سماح + السماح دائمًا + + + نتائج البحث + الكل + المقاطع + المستخدمون + لا تتوفر أنواع بحث + ابدأ الكتابة للبحث + ابدأ الكتابة للبحث عن مقاطع + لم يتم العثور على نتائج + عرض الكل + جارٍ البحث + جارٍ تحميل المقاطع... + جارٍ تحميل الألبومات... + جارٍ تحميل الفنانين... + جارٍ تحميل قوائم التشغيل... + جارٍ تحميل المستخدمين... + لم يتم العثور على ألبومات + لم يتم العثور على فنانين + لم يتم العثور على قوائم تشغيل + لم يتم العثور على مستخدمين + نبحث بين صناديق الأسطوانات... + نبحث في أرجاء الكون... + نُحمّي مكبرات الصوت... + ننفض الغبار عن الأسطوانات... + نستشير آلهة الموسيقى... + نضبط الهوائيات... + نقلّب بين التسجيلات... + تعذّر تحميل إمكانات البحث + تعذّر البحث + تعذّر البحث عن المقاطع + تعذّر البحث عن الألبومات + تعذّر البحث عن الفنانين + تعذّر البحث عن قوائم التشغيل + تعذّر البحث عن المستخدمين + + + تصفّح + + + متزامنة + عادية + لا تتوفر كلمات متزامنة + لا تتوفر كلمات عادية + + + %1$s في %2$s + %1$d kbps + %1$s kHz • %2$d قناة + لم يتم تحديد إضافة بيانات وصفية + + + رجوع + إعادة المحاولة + إغلاق مربع الحوار + جارٍ التحميل... + غير معروف + خطأ غير معروف + + + مستخدم + قائمة تشغيل + + + نعتذر بشدة + هناك خلل ما في الوقت الحالي. تجربتك تهمنا، ونبذل قصارى جهدنا لإصلاح ذلك. يُرجى المحاولة مرة أخرى أو مراجعة تفاصيل الخطأ أدناه. + تفاصيل الخطأ + نسخ إلى الحافظة + عرض السجل الكامل + سجل الخطأ الكامل + + + ترتيب عشوائي + تشغيل عشوائي + متابعة + إلغاء المتابعة + تعديل قائمة التشغيل + بواسطة %1$s + + + خيارات المقطع + إضافة إلى الجام + بدء الراديو + تشغيل التالي + نقل إلى التالي + إزالة من قائمة الانتظار + إزالة من المفضلة + حفظ في المفضلة + إضافة إلى قائمة تشغيل + تنزيل + مشاركة + + + الأصلي + العنوان + الفنان + الألبوم + المدة + ترتيب + لم يتم العثور على مقاطع + تصفية + إجراءات جماعية + تنزيل الكل + تنزيل %1$d + إضافة الكل إلى قائمة الانتظار + إضافة %1$d إلى قائمة الانتظار + تشغيل الكل تاليًا + تشغيل %1$d تاليًا + إضافة الكل إلى قائمة تشغيل + إضافة %1$d إلى قائمة تشغيل + إضافة الكل إلى الجام + إضافة %1$d إلى الجام + +%1$d آخرين + + + لا يوجد URL لتحميله + الرجوع في سجل المتصفح + التقدم في سجل المتصفح + + + تصفية قائمة الحظر... + لا توجد عناصر محظورة + لا توجد عناصر محظورة تطابق \'%1$s\' + المقاطع + مقطع + إضافة إلى قائمة الحظر + إزالة من قائمة الحظر + + + لا توجد بيانات وصفية للمقطع + تعذّر تحديد URL البث + تعذّر تحديد URL البث. جرّب تشغيل المقطع أولًا. + + + المقاطع المحفوظة + أنت + + + تتابعه + أفضل المقاطع + تشغيل أفضل المقاطع + إضافة أفضل المقاطع إلى قائمة الانتظار + فنانون مشابهون + قوائم تشغيل مميزة + تعذّر تحميل نظرة عامة على الفنان + عدد المتابعين غير متاح + %1$s ألف متابع + %1$s مليون متابع + %1$s مليار متابع + + %1$d متابع + متابع واحد + %1$d متابعان + %1$d متابعين + %1$d متابعًا + %1$d متابع + + + + إعجاب + إلغاء الإعجاب + عنوان مقطع افتراضي + فنانون افتراضيون + ألبوم غير معروف + التقديم والترجيع + مستوى الصوت + تفعيل الترتيب العشوائي + إيقاف الترتيب العشوائي + السابق + التالي + تشغيل أو إيقاف مؤقت + التكرار متوقف + تكرار مقطع واحد + تكرار الكل + كتم الصوت + إلغاء كتم الصوت + خيارات إضافية + خيارات المشغّل + طي المشغّل + توسيع كلمات الأغنية + قيد التشغيل الآن + كلمات الأغنية + مصدر بديل + مصدر بديل + تنزيل + تم التنزيل + جارٍ التنزيل... + تم التنزيل مسبقًا + جارٍ إعادة محاولة التنزيل... + جارٍ تنزيل %1$s + سيتوقف التشغيل مؤقتًا بعد %1$s. + إيقاف التشغيل مؤقتًا بعد عدد الدقائق المحدد. + الدقائق + اختر بين 1 و600 دقيقة + إلغاء المؤقت + بدء + تم تحديد URL التشغيل بإعدادات بث مختلفة. يطابق البث المميز تفضيلك الحالي؛ وسيتم تحديث URL النشط عند طلب البث التالي. + مصدر الصوت المطابق + جارٍ تحميل المصدر المطابق… + لا حاجة إلى مصدر بعيد مطابق أثناء تشغيل الملف المخزن مؤقتًا محليًا. + لا يتوفر مصدر مطابق. + لا يوجد مصدر صوت مطابق مخزن مؤقتًا لهذا المقطع. + لم يتم تخزين تفاصيل البث مؤقتًا بعد. شغّل المقطع أولًا لتحميلها. + تعذّر تحميل تفاصيل المقطع. + التدفقات المتاحة + يتم تقديم هذا المقطع من ذاكرة التخزين المؤقت المحلية. لا تُستخدم متغيرات البث لهذا التشغيل. + جارٍ تحميل التدفقات… + لا تتوفر تدفقات لهذا المصدر. + البث المحدد حاليًا + يطابق تفضيلات صيغة البث وجودته + URL التشغيل النشط + مُقدَّم من ذاكرة التخزين المؤقت المحلية + مقطع البيانات الوصفية + العنوان + المعرّف + الألبوم + درجة الثقة + URI خارجي + معرّف الألبوم + نوع الألبوم + تاريخ إصدار الألبوم + عدد مقاطع الألبوم + أنواع الألبوم الموسيقية + المدة + رقم المقطع + رقم القرص + محتوى صريح + الشعبية + ISRC + عناوين URL للصور المصغرة + + + إظهار/إخفاء الشريط الجانبي + + + مصادر بديلة + لا يوجد مقطع قيد التشغيل حاليًا + جارٍ البحث عن مصادر بديلة... + لم يتم العثور على مصادر بديلة + درجة الثقة: %1$d% + المصدر النشط + + + طرد + إزالته من الجلسة + طرده ومنعه من الانضمام مجددًا + إزالة الاقتراحات + إزالة كل المقاطع التي أضافها إلى قائمة الانتظار + إعادة الترتيب + إزالة من قائمة الانتظار + %1$s • أضافه %2$s + + + غير معروف + جارٍ تحميل قائمة التشغيل... + إضافة مقاطع + إضافة مقاطع إلى قائمة التشغيل + ابحث عن مقاطع... + بحث + لم يتم العثور على مقاطع + ابحث عن مقاطع لإضافتها + تم + إضافة + تمت الإضافة + + + جارٍ تحميل الألبوم... + + + إضافة إلى قائمة تشغيل خاصة بك + بحث + ابحث في قوائم التشغيل الخاصة بك... + جارٍ تحميل قوائم التشغيل الخاصة بك... + لا تملك أي قوائم تشغيل بعد.\nأنشئ واحدة لإضافة المقاطع. + إنشاء قائمة تشغيل + تعديل قائمة التشغيل + إنشاء + الاسم + اسم قائمة التشغيل + الوصف + الوصف (اختياري) + عامة + يمكن للجميع رؤية قائمة التشغيل هذه + تعاونية + يمكن للآخرين إضافة مقاطع إلى قائمة التشغيل هذه + صورة الغلاف + غلاف قائمة التشغيل + لا توجد صورة + تغيير الصورة + اختيار صورة + + + تثبيت + استبدال + لم يتم العثور على plugin.json في ملف zip + لم يتم العثور على manifest.zipline.json في ملف zip + تنسيق plugin.json غير صالح: %1$s + تعذّرت قراءة الإضافة: %1$s + تعذّر تثبيت الإضافة: %1$s + يتوفر إصدار أحدث من %1$s. أكّد التحديث قبل التثبيت. + %1$s مثبتة بالفعل. أكّد استبدالها قبل التثبيت. + الإضافات المدمجة مضمّنة بالفعل ولا يمكن إضافتها مرة أخرى. + لا يمكن إزالة الإضافات المدمجة. + تعلن %1$s عن قيمة apiVersion غير صالحة (%2$s). المتوقع إصدار دلالي متوافق مع %3$s. + إصدار API الإضافات في Spotube %1$s غير صالح. + تستهدف %1$s إصدار API الإضافات %2$s، لكن Spotube يدعم إصدار API الإضافات الرئيسي %3$s. ثبّت إضافة مبنية لنفس الإصدار الرئيسي من API. + + + API الإضافة غير متوافق + تثبيت الإضافة؟ + ستتم إضافة %1$s إلى إضافاتك المثبتة. + تحديث الإضافة؟ + إصدار أقدم مثبت (%1$s). هل تريد التحديث إلى %2$s؟ + استبدال الإضافة المثبتة؟ + تستخدم %1$s إصدارًا دلاليًا غير صالح (%2$s) ولا يمكن مقارنتها كتحديث. هل تريد استبدال الإضافة المثبتة على أي حال؟ + لا يمكن مقارنة الإصدار المثبت (%1$s) باستخدام الإصدار الدلالي. هل تريد استبداله بـ %2$s؟ + الإصدار %1$s مثبت بالفعل. هل تريد استبدال الإضافة الحالية بالنسخة المقدَّمة؟ + الإصدار المثبت %1$s أحدث من الإصدار المقدَّم %2$s. هل تريد استبداله على أي حال؟ + هل تريد استبدال الإضافة المثبتة بالنسخة المقدَّمة؟ + + + %1$s: + الإصدار + إصدار API + المؤلف + الترخيص + الإمكانات + القدرات + المستودع + التواصل + الإبلاغ عن الأخطاء + ادعم %1$s + + + رسمية + موثّقة + نجوم GitHub + URL مستودع GitHub + + + لم يتم العثور على إصدارات + اختيار الإصدار + اختر إصدارًا + إصدار تجريبي + ملاحظات الإصدار + + + الرئيسية + بحث + على الجهاز + الاتصال + الأجهزة + + + إخفاء النافذة + إظهار النافذة + المقطع التالي + المقطع السابق + ترتيب عشوائي + تكرار + التكرار: متوقف + التكرار: مقطع واحد + التكرار: الكل + مستوى الصوت: %1$d% + رفع الصوت + + خفض الصوت - + كتم الصوت + إلغاء كتم الصوت + الإعجاب بالمقطع + إلغاء الإعجاب بالمقطع + خروج + + + تصغير + تكبير + استعادة + إغلاق + + + التشغيل + عناصر التحكم في تشغيل الوسائط + + + تصفّح + قيد التشغيل الآن + مميز + المقاطع المحفوظة + أفضل المقاطع + diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml new file mode 100644 index 00000000..a3c26ed2 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml @@ -0,0 +1,926 @@ + + + + কিউ + কিউ খালি করুন + কিউ ফিল্টার করুন... + কিউতে কিছু নেই + + গান, শিল্পী, অ্যালবাম খুঁজুন... + সব হিস্ট্রি মুছুন + খুঁজুন... + + + আপনার লাইব্রেরি + লাইব্রেরি + প্লেলিস্ট + অ্যালবাম + শিল্পী + লোকাল ট্র্যাক + ডাউনলোড + সেভ করা প্লেলিস্ট খুঁজুন… + সেভ করা অ্যালবাম খুঁজুন… + সেভ করা শিল্পী খুঁজুন… + ফোল্ডার বা ট্র্যাক খুঁজুন… + লাইব্রেরি ফিল্টার করুন + ফিল্টার বন্ধ করুন + প্লেলিস্ট তৈরি করুন + লোকাল ট্র্যাক রিফ্রেশ করুন + গ্রিড ভিউতে যান + লিস্ট ভিউতে যান + কোনো সেভ করা প্লেলিস্ট নেই + “%1$s”-এর সাথে মেলে এমন কোনো প্লেলিস্ট নেই + কোনো সেভ করা অ্যালবাম নেই + “%1$s”-এর সাথে মেলে এমন কোনো অ্যালবাম নেই + কোনো সেভ করা শিল্পী নেই + “%1$s”-এর সাথে মেলে এমন কোনো শিল্পী নেই + + + ফোল্ডার + ফোল্ডার + %1$s • %2$s + + %1$dটি ট্র্যাক + %1$dটি ট্র্যাক + + + %1$dটি ফোল্ডার + %1$dটি ফোল্ডার + + ফোল্ডারে ফিরে যান + ফোল্ডার প্লে করুন + অডিও অ্যাক্সেস প্রয়োজন + আপনার ডিভাইসের গান দেখতে অডিও অ্যাক্সেসের অনুমতি দিন। + অনুমতি দিন + কোনো লোকাল ফোল্ডার পাওয়া যায়নি + এই ফোল্ডারে কোনো ট্র্যাক নেই + মেলে এমন কোনো ফোল্ডার বা ট্র্যাক নেই + লোকাল মিডিয়া রিফ্রেশ করা যায়নি + + + %1$dটি চলমান • %2$dটি সম্পন্ন + সম্পন্নগুলো মুছুন + এখনো কোনো ডাউনলোড নেই + ডাউনলোড হচ্ছে %1$d% + অপেক্ষমাণ + সম্পন্ন + বাতিল + ব্যর্থ + ব্যর্থ: %1$s + ডাউনলোড বাতিল করুন + আবার ডাউনলোড করুন + ডাউনলোড সরান + + + শিল্পী + ব্ল্যাকলিস্টেড + পছন্দের ট্র্যাক + আপনার সেভ করা গান + অজানা শিল্পী + ট্র্যাকের নাম + শিল্পীর নাম + + নির্বাচিত + + সেটিংস + + প্লাগইন + ভাষা ও অঞ্চল + অ্যাপিয়ারেন্স + প্লেব্যাক + ডাউনলোড + ক্যাশিং + ডেস্কটপ + আপডেট + সাপোর্ট + সম্পর্কে + + বন্ধ করুন + সেভ করুন + বাতিল + + ভাষা + অ্যাপ ইন্টারফেসের ভাষা। বর্তমান: %1$s + সিস্টেম ডিফল্ট + দেশ + কনটেন্ট ও প্রাপ্যতার জন্য দেশ। বর্তমান: %1$s + %1$s (%2$s) + + থিম + Spotube আপনার সিস্টেমের অ্যাপিয়ারেন্সের সাথে কীভাবে মিলবে তা বেছে নিন। বর্তমান: %1$s + লাইট + ডার্ক + সিস্টেম + + সারফেস + অ্যাপের ব্যাকগ্রাউন্ড ম্যাটেরিয়াল। বর্তমান: %1$s + জলীয় + বরফি + ক্রোম + অ্যাক্রিলিক + কাচ + + অ্যাকসেন্ট রং + বর্তমান প্যালেট: %1$s + অ্যাকসেন্ট রং + একটি অ্যাকসেন্ট প্যালেট বেছে নিন এবং লাইট ও ডার্ক দুই থিমেই প্রিভিউ দেখুন। + লাইট + ডার্ক + প্রিভিউ + বাটন + গ্রিন গবলিন + ইলেকট্রিক ভায়োলেট + ওশেনিক সায়ান + সানসেট অরেঞ্জ + রোজ গার্ডেন + মিডনাইট ব্লু + মেটালিক স্লেট + + লুক ও ফিল + সারফেস ম্যাটেরিয়াল ও অ্যাকসেন্ট। বর্তমান: %1$s · %2$s + লুক ও ফিল + একটি সারফেস ম্যাটেরিয়াল বেছে নিন, তারপর তার সাথে মানানসই একটি অ্যাকসেন্ট। প্রতিটি অপশনই বাছাই করা কম্বিনেশন। + সারফেস + অ্যাকসেন্ট + সেকেন্ডারি + + প্যালেট + Millennium অ্যাকসেন্ট প্যালেট। বর্তমান: %1$s + প্যালেট + একটি Millennium অ্যাকসেন্ট প্যালেট বেছে নিন এবং লাইট ও ডার্কে প্রিভিউ দেখুন। + প্যালেট + Millennium প্যালেট ও অ্যাকসেন্ট। বর্তমান: %1$s + নীল + কমলা + লাইম + + ডাউনলোড ফোল্ডার পরিবর্তন + বর্তমান ফোল্ডার: %1$s + ডিফল্ট মিউজিক ফোল্ডার ব্যবহার করতে এটি ফাঁকা রাখুন। + C:\Music\Spotube + লোকাল মিডিয়া ফোল্ডার + ডেস্কটপে স্ক্যান করার জন্য কাস্টম ফোল্ডার। যোগ করা হয়েছে: %1$d + এখনো কোনো কাস্টম ফোল্ডার যোগ করা হয়নি + আপনার লোকাল মিউজিক লাইব্রেরির জন্য কাস্টম ফোল্ডার। + ম্যানেজ করুন + ফোল্ডার যোগ করুন + ফোল্ডার সরান + কোনো কাস্টম ফোল্ডার যোগ করা হয়নি। + স্ক্যান তালিকা থেকে %1$s সরান + ডাউনলোড ও মিউজিক ক্যাশ ফোল্ডার স্বয়ংক্রিয়ভাবে অন্তর্ভুক্ত হয়। + আপনার ডাউনলোড ফোল্ডার স্বয়ংক্রিয়ভাবে অন্তর্ভুক্ত হয়। + মিউজিক ক্যাশ ফোল্ডার স্বয়ংক্রিয়ভাবে অন্তর্ভুক্ত হয়। + সেভ হচ্ছে… + ফোল্ডার সেভ করা যায়নি। আপনার পরিবর্তনগুলো রয়ে গেছে; আবার চেষ্টা করুন। + + %1$dটি ফোল্ডার + %1$dটি ফোল্ডার + + ডাউনলোডের মিউজিক ফরম্যাট + ডাউনলোডের জন্য পছন্দের কোডেক ও কন্টেইনার। বর্তমান: %1$s + ডাউনলোডের মিউজিক কোয়ালিটি + + স্ট্রিমিংয়ের মিউজিক ফরম্যাট + মিউজিক স্ট্রিমিংয়ের জন্য পছন্দের কোডেক ও কন্টেইনার। বর্তমান: %1$s + স্ট্রিমিংয়ের মিউজিক কোয়ালিটি + বর্তমান: %1$s + মিউজিক ক্যাশিং চালু করুন + বারবার প্লে দ্রুত করতে স্ট্রিম করা অডিও লোকালি সংরক্ষণ করুন। + ক্যাশ ফোল্ডার + বর্তমান: %1$s + বর্তমান: ডিফল্ট অ্যাপ্লিকেশন ক্যাশ + ক্যাশ সাইজের সীমা + কোনো সীমা নেই + বর্তমান সীমা: %1$d MB + MB-তে সর্বোচ্চ ক্যাশ সাইজ দিন। সীমাহীন রাখতে 0 দিন বা ফাঁকা রাখুন। + 500 + সাইজ 0 বা একটি ধনাত্মক সংখ্যা হতে হবে। + অবিরাম প্লেব্যাক চালু করুন + কিউ শেষ হলে সাজেস্ট করা ট্র্যাক দিয়ে প্লেব্যাক চালু রাখুন। + প্লেব্যাক প্রক্সি সার্ভার পোর্ট + রিমোট কন্ট্রোলের অনুমতি দিন + একই নেটওয়ার্কের অন্য ডিভাইস কানেক্ট হলে প্লেব্যাক নিয়ন্ত্রণ করতে দিন। + রিমোট কন্ট্রোল ডিভাইসের নাম + অন্য ডিভাইস এই ডিভাইসটি খুঁজে পেলে যে নাম দেখবে। বর্তমান: %1$s + আপনার নেটওয়ার্কের অন্য ডিভাইস এই Spotube খুঁজে পেলে দেখানোর জন্য একটি সহজ নাম বেছে নিন। + যেমন: বসার ঘরের Spotube + ডিভাইসের হোস্টনেম + প্লেব্যাক প্রক্সি সার্ভারের ব্যবহৃত পোর্ট। বর্তমান: %1$d + 1 থেকে 65535-এর মধ্যে একটি পোর্ট বেছে নিন। + 14769 + একটি সঠিক পূর্ণসংখ্যা দিন। + পোর্ট 1 থেকে 65535-এর মধ্যে হতে হবে। + + ব্ল্যাকলিস্ট + ব্ল্যাকলিস্ট করা ট্র্যাক ও শিল্পী ম্যানেজ করুন + + ট্রেতে মিনিমাইজ করুন + উইন্ডো মিনিমাইজ করলে Spotube সিস্টেম ট্রেতে চালু রাখুন। + Discord রিচ প্রেজেন্স + ডেস্কটপে Discord-এ আপনার বর্তমান প্লেব্যাক স্ট্যাটাস দেখান। + + স্বয়ংক্রিয়ভাবে আপডেট চেক করুন + ব্যাকগ্রাউন্ডে Spotube-এর নতুন রিলিজ খুঁজুন। + + প্লাগইন ম্যানেজ করুন + আপনার প্লাগইনগুলো ম্যানেজ করুন + ডিফল্ট %1$s প্লাগইন + মেটাডেটা + অডিও + লিরিক্স + স্ক্রবল + কোনো প্লাগইন নির্বাচিত নয় + পরিবর্তন করুন + নির্বাচন করুন + নির্বাচিত + নির্বাচন মুছুন + কোনো প্লাগইন উপলব্ধ নেই + %1$s প্লাগইন + মুছুন + + কনফিগার করুন + প্লাগইন ম্যানেজার + অনুগ্রহ করে একটি URL দিন + URL অবশ্যই http:// বা https:// দিয়ে শুরু হতে হবে + প্লাগইন ডাউনলোড করা যায়নি + এখনো কোনো প্লাগইন ইনস্টল করা হয়নি + একটি ডাউনলোড URL পেস্ট করুন বা ওপর থেকে একটি ফাইল বেছে নিন। + + %1$dটি প্লাগইন ইনস্টল করা আছে + %1$dটি প্লাগইন ইনস্টল করা আছে + + প্লাগইন ইনস্টল করুন + https://example.com/plugin.smplug + ডাউনলোড + ফাইল থেকে ইনস্টল করুন (.smplug) + সক্রিয় + v%1$s + বিল্ট-ইন + প্লাগইন সরান + লগইন + লগআউট + URL থেকে ডাউনলোড করুন + ফাইল থেকে ইনস্টল করুন + ইনস্টল করুন + + • %1$s + অনুরোধ করা পারমিশন + এই প্লাগইনের কোনো বিশেষ পারমিশন প্রয়োজন নেই। + ইনস্টল করা বনাম প্রদত্ত + ইনস্টল করা + প্রদত্ত + API %1$s → %2$s + স্থায়ী স্টোরেজ + ডিস্কে ডেটা পড়া ও লেখা + নেটওয়ার্ক রিকোয়েস্ট + ইন্টারনেটে ডেটা পাঠানো ও গ্রহণ করা + WebView + অ্যাপের ভেতরে ওয়েব কনটেন্ট দেখানো + গ্রুপ জ্যাম + MQTT ব্রোকার + ব্রোকার হোস্ট + %1$s:%2$d + ব্রোকার পোর্ট + TLS ব্যবহার করুন + ইউজারনেম (ঐচ্ছিক) + পাসওয়ার্ড (ঐচ্ছিক) + ক্লায়েন্ট ID প্রিফিক্স + এই Spotube শনাক্ত করতে ব্যবহৃত হয় + ব্রোকার কানেকশন এনক্রিপ্ট করুন + রুম শুরু করার আগে বর্তমান ব্রোকার সেটিংস যাচাই করুন। + কানেকশন পরীক্ষা করুন + পরীক্ষা চলছে… + %1$d ms-এ কানেক্ট হয়েছে + ব্যর্থ: %1$s + প্লেসহোল্ডার ব্রোকার — সেলফ-হোস্ট করতে নিজেরটি কনফিগার করুন + কানেকশন + ক্রেডেনশিয়াল + পরিচিতি + ডায়াগনস্টিকস + + + গ্রুপ জ্যাম + তৈরি করুন + যোগ দিন + একটি রুম তৈরি করুন, তারপর এর কোড বন্ধুদের সাথে শেয়ার করুন। + রুম তৈরি করুন + রুমে যোগ দিন + কানেক্ট হচ্ছে… + রুম কোড + ABC123 + ব্রোকার: %1$s + রুম হোস্ট করতে বা যোগ দিতে সেটিংসে একটি ব্রোকার সেট আপ করুন। + ব্রোকার সেটিংস + কানেক্টেড + আবার কানেক্ট হচ্ছে… + কোড কপি করুন + কপি হয়েছে! + শেয়ার করুন + অংশগ্রহণকারী (%1$d) + হোস্ট + শ্রোতা + শ্রোতাদের জন্য অপেক্ষা করা হচ্ছে… + সরান + ব্যান করুন + ম্যানেজ করুন + রুম ছেড়ে দিন + কানেক্ট করা যায়নি + বাদ দিন + জ্যাম সেশন তৈরি করা যায়নি + জ্যাম সেশনে যোগ দেওয়া যায়নি + অক্ষর ও সংখ্যা দিয়ে ছয় অক্ষরের একটি রুম কোড দিন। + এই কোড দিয়ে আমার Spotube জ্যামে যোগ দিন: %1$s + + অ্যালবামে যান + স্লিপ টাইমার + ট্র্যাকের বিস্তারিত + প্লে + পজ + কিছুই বাজছে না + কিউতে যোগ করুন + + উপেক্ষা করুন + আপডেট করুন + Spotube %1$s উপলব্ধ + এই রিলিজে নতুন কী আছে তা Spotube ডাউনলোড পেজে দেখুন। + %1$s %2$s উপলব্ধ + বর্তমানে ইনস্টল করা: %1$s + এই আপডেটে নতুন কী আছে তা প্লাগইনের পেজে দেখুন। + + এড়িয়ে যান + পরবর্তী + পেছনে + শুরু করুন + Spotube + মিউজিক, কোনো বাঁধন ছাড়াই। + মিউজিকের জগতের জন্য একটি ওপেন-সোর্স প্লেয়ার।\nআপনার মতো করে ঘুরে দেখুন। নিজের মতো করে সাজিয়ে নিন। + এই প্রজেক্টটি বাঁচিয়ে রাখুন। + Spotube মানুষের তৈরি, কোনো সাবস্ক্রিপশন ব্যবসার নয়। এটি যদি আপনার প্রতিদিনের জীবনের অংশ হয়ে ওঠে, তাহলে একটু সাপোর্ট আমাদের এর প্রাপ্য যত্ন নিতে সাহায্য করে।\n\nস্বাধীন সফটওয়্যারকে জায়গা দেওয়ার জন্য ধন্যবাদ। + গান ও প্লেলিস্ট খুঁজতে একটি ক্যাটালগ প্লাগইন দিয়ে শুরু করুন। সেগুলো বাজাতে একটি অডিও প্লাগইন বেছে নিন। লিরিক্স ও শোনার হিস্ট্রি ঐচ্ছিক। নিচে একটি প্লাগইন খুঁজুন, এর পারমিশন দেখে নিন, ইনস্টল করুন, তারপর এখানে নির্বাচন করুন। + প্লাগইন খুঁজুন + আপনার সোর্স + আবার চেষ্টা করুন + আরও প্লাগইন + নতুন কোনো প্লাগইন পাওয়া যায়নি। ওপরের লিংক বা .smplug ফাইল থেকেও ইনস্টল করতে পারেন। + এই মুহূর্তে প্লাগইন খোঁজা যাচ্ছে না। আবার চেষ্টা করুন, অথবা লিংক বা ফাইল থেকে ইনস্টল করুন। + স্বাগতম + সাপোর্ট + প্লাগইন + গাইড + + Spotube যেভাবে ব্যবহার করবেন + চারটি সহজ ধাপ, তারপরই গান শুনুন। + সাইন ইন + আপনার Spotify অ্যাকাউন্ট কানেক্ট করুন, অথবা নিজের পছন্দের ক্যাটালগ প্লাগইন বেছে নিন। + খুঁজুন ও প্লে করুন + যেকোনো গান, অ্যালবাম বা শিল্পী খুঁজুন, তারপর প্লে চাপুন। + অফলাইনে শুনুন + কানেকশন ছাড়াই শুনতে ট্র্যাক ডাউনলোড করুন। + লিরিক্সের সাথে গান + ট্র্যাক বাজার সময় প্লেয়ার থেকে সিঙ্ক করা লিরিক্স খুলুন। + + + আপনার মিউজিক। আপনার সোর্স। আপনার নিয়ম। + একটি ফ্রি, ওপেন-সোর্স মিউজিক প্লেয়ার, যা তৈরি করেছে মিউজিক ভালোবাসা একটি কমিউনিটি। + ওপেন সোর্স + বিজ্ঞাপন নেই + টেলিমেট্রি নেই + প্লাগইন চালিত + চলুন শুরু করি + + + সমর্থকরাই আমাদের এগিয়ে নেন + ভালোবাসায় তৈরি। আপনার কারণেই টিকে আছে। + Spotube ১০০% ফ্রি, লিব্রে ও ওপেন সোর্স — কোনো বিজ্ঞাপন, সাবস্ক্রিপশন বা ট্র্যাকিং নেই। প্রতিটি ফিচার আছে কারণ আপনার মতো মানুষ কোড চালু রাখতে সাহায্য করেছেন। + Patreon + মাসিক স্পনসর + নিয়মিত ডেভেলপমেন্ট ও ইনফ্রাস্ট্রাকচারে সাপোর্ট করুন + Buy Me a Coffee + এককালীন কফি + একটি ছোট্ট টিপ দিয়ে রাত জাগা কোডিং সেশনে শক্তি জোগান + Open Collective + কমিউনিটি ফান্ড + স্বচ্ছ, কমিউনিটি-পরিচালিত আর্থিক সহায়তা + ওপেন মিউজিকে বিশ্বাস রাখার জন্য অন্তরের অন্তস্তল থেকে ধন্যবাদ। + সাপোর্ট করা সম্পূর্ণ ঐচ্ছিক — যেকোনো অবস্থাতেই Spotube ফ্রি থাকবে। + স্পনসর করুন + %1$s খুলুন + + + Spotube-কে সাপোর্ট করুন + Spotube-কে সাপোর্ট করুন + Spotube ফ্রি, লিব্রে ও ওপেন সোর্স — কোনো বিজ্ঞাপন, সাবস্ক্রিপশন বা ট্র্যাকিং নেই। এটি যদি আপনাকে আনন্দ দেয়, তাহলে প্রজেক্টটিকে সাপোর্ট করার কথা ভাবুন। + এই রিমাইন্ডারগুলো সেটিংস → সাপোর্ট থেকে বন্ধ করতে পারেন। + পরে দেখব + সাপোর্ট রিমাইন্ডার + প্রতি ৭ দিনে প্রজেক্টটিকে সাপোর্ট করার একটি ছোট রিমাইন্ডার দেখান। + + + মডুলার আর্কিটেকচার + আপনার মিউজিক কোথা থেকে আসবে তা বেছে নিন। + Spotube ক্যাটালগ খোঁজা, অডিও স্ট্রিমিং ও সিঙ্ক করা লিরিক্স আলাদা রাখে। নিচে আপনার ইঞ্জিনগুলো কনফিগার করুন বা যেকোনো সময় বদলে নিন। + মেটাডেটা ইঞ্জিন + মিউজিক খোঁজা, সার্চ, ট্র্যাক তালিকা ও প্লেলিস্ট চালায়। + অডিও ইঞ্জিন + যে অডিও স্ট্রিম বাজে তা আনে ও ডিকোড করে। + লিরিক্স ইঞ্জিন + রিয়েল টাইমে সময়-সিঙ্ক করা কারাওকে লিরিক্স দেখায়। + স্ক্রবল ইঞ্জিন + Last.fm বা ListenBrainz-এ শোনার হিস্ট্রি রেকর্ড করে। + সক্রিয় + বিল্ট-ইন + প্রস্তুত + ঐচ্ছিক + একটি ইঞ্জিন বেছে নিন + ইনস্টল করা ইঞ্জিন + ডিফল্ট হিসেবে সেট করুন + NewPipe YouTube ও LRCLib Lyrics বিল্ট-ইন এবং শুরু থেকেই স্ট্রিম করার জন্য প্রস্তুত। + সেটিংস → প্লাগইন থেকে যেকোনো সময় কমিউনিটি প্লাগইন ম্যানেজ বা যোগ করুন। + + + রেট্রো-মডার্ন আর্কিটেকচার + খাঁটি Hi-Fi, কোনো মধ্যস্থতাকারী নেই + Spotube কীভাবে সরাসরি আপনার মেশিনে বিকেন্দ্রীভূত প্লেব্যাক পরিচালনা করে, দেখুন। + সরাসরি পিয়ার-টু-পিয়ার • 256 KBPS OPUS + নীল প্রহর + Spotube · প্লেয়ার ডেমো + মাঝের মুহূর্তগুলোর জন্য একটু মিউজিক। + ১. বিকেন্দ্রীভূত খোঁজ + সার্চ সরাসরি আপনার বেছে নেওয়া ক্যাটালগ প্লাগইনে যায়। Spotube কখনো আপনার রুচির প্রোফাইল তৈরি করে না। + ২. সরাসরি স্ট্রিম রেজোলিউশন + অডিও কনটেন্ট সার্ভার থেকে সরাসরি আপনার মেশিনে স্ট্রিম হয়। কোনো প্রক্সি বা রিমোট ডেটা সংগ্রহ নেই। + ৩. লোকাল DSP ও প্লেব্যাক + ইকুয়ালাইজার কন্ট্রোল ও ঐচ্ছিক অফলাইন ডাউনলোডসহ লোকালি ডিকোড ও ক্যাশ করা হয়। + ৪. সিঙ্ক করা লিরিক্স ও জ্যাম + কারাওকে লিরিক্স প্লেব্যাকের সময়ের সাথে মিলে চলে, সাথে আছে LAN রিমোট কন্ট্রোল ও গ্রুপ জ্যাম। + কোনো মধ্যস্থতাকারী সার্ভার নেই। প্লেব্যাক হয় সরাসরি আপনার ডিভাইসে — ডিজাইনেই প্রাইভেট। + + + একটি ডিভাইস বেছে নিন + %1$s: “%2$s” + প্লে + কিউতে যোগ + পরবর্তীতে প্লে + এই ডিভাইস + এই ডিভাইসে + রিমোট ডিভাইস + রিমোট ডিভাইস (%1$s) + সংযুক্ত ডিভাইসে + + + কানেকশন ব্যর্থ হয়েছে + কানেকশন বিচ্ছিন্ন হয়েছে + পাঠানো যায়নি + খোঁজা ব্যর্থ হয়েছে: %1$s + কানেক্ট করা যায়নি: %1$s + + + ডিভাইস + রিফ্রেশ + ডিসকানেক্ট + কানেক্ট হচ্ছে... + নেটওয়ার্কে ডিভাইস খোঁজা হচ্ছে... + কোনো ডিভাইস পাওয়া যায়নি + অন্য ডিভাইসের সেটিংসে \"রিমোট কন্ট্রোলের অনুমতি দিন\" চালু আছে কিনা নিশ্চিত করুন। + অজানা ডিভাইস + খোঁজা হচ্ছে... + + + রিমোট কন্ট্রোল + ডিসকানেক্টেড + কানেকশন ত্রুটি: %1$s + অজানা ট্র্যাক + অজানা শিল্পী + ভলিউম + কিউ থেকে সরান + আরও অপশন + + + কোথায় প্লে করবেন? + আপনার একটি জ্যাম সেশন চালু আছে। এই কালেকশন কোথায় প্লে করবেন বেছে নিন। + এখানে প্লে করুন + জ্যামে সাজেস্ট করুন + + + কোনো জ্যাম ব্রোকার কনফিগার করা নেই + ব্রোকার হোস্ট ফাঁকা + কানেকশন ব্যর্থ হয়েছে + কানেকশন বিচ্ছিন্ন হয়েছে + + + ব্রোকার + anonymous + + + জ্যাম কিউতে যোগ করা হয়েছে + + জ্যাম কিউতে %1$dটি যোগ করা হয়েছে + জ্যাম কিউতে %1$dটি যোগ করা হয়েছে + + + + রিমোট কন্ট্রোলের অনুরোধ + \"%1$s\" এই ডিভাইসের প্লেব্যাক নিয়ন্ত্রণ করতে চায়। + প্রত্যাখ্যান + অনুমতি দিন + সবসময় অনুমতি দিন + + + সার্চের ফলাফল + সব + ট্র্যাক + ইউজার + কোনো সার্চ টাইপ উপলব্ধ নেই + খুঁজতে টাইপ করা শুরু করুন + ট্র্যাক খুঁজতে টাইপ করা শুরু করুন + কোনো ফলাফল পাওয়া যায়নি + সব দেখুন + খোঁজা হচ্ছে + ট্র্যাক লোড হচ্ছে... + অ্যালবাম লোড হচ্ছে... + শিল্পী লোড হচ্ছে... + প্লেলিস্ট লোড হচ্ছে... + ইউজার লোড হচ্ছে... + কোনো অ্যালবাম পাওয়া যায়নি + কোনো শিল্পী পাওয়া যায়নি + কোনো প্লেলিস্ট পাওয়া যায়নি + কোনো ইউজার পাওয়া যায়নি + রেকর্ডের বাক্স ঘাঁটা হচ্ছে... + মহাবিশ্বে খোঁজা হচ্ছে... + স্পিকার গরম করা হচ্ছে... + ভাইনিলের ধুলো ঝাড়া হচ্ছে... + সংগীতের দেবতাদের জিজ্ঞেস করা হচ্ছে... + অ্যান্টেনা টিউন করা হচ্ছে... + রেকর্ড উল্টে দেখা হচ্ছে... + সার্চের সক্ষমতা লোড করা যায়নি + সার্চ করা যায়নি + ট্র্যাক খোঁজা যায়নি + অ্যালবাম খোঁজা যায়নি + শিল্পী খোঁজা যায়নি + প্লেলিস্ট খোঁজা যায়নি + ইউজার খোঁজা যায়নি + + + ব্রাউজ + + + সিঙ্কড + সাধারণ + কোনো সিঙ্ক করা লিরিক্স নেই + কোনো সাধারণ লিরিক্স নেই + + + %2$s-এ %1$s + %1$d kbps + %1$s kHz • %2$d ch + কোনো মেটাডেটা প্লাগইন নির্বাচিত নয় + + + পেছনে + আবার চেষ্টা করুন + ডায়ালগ বন্ধ করুন + লোড হচ্ছে... + অজানা + অজানা ত্রুটি + + + ইউজার + প্লেলিস্ট + + + আমরা সত্যিই দুঃখিত + এই মুহূর্তে কিছু একটা ঠিকমতো কাজ করছে না। আপনার অভিজ্ঞতা আমাদের কাছে গুরুত্বপূর্ণ, এবং আমরা এটি ঠিক করার সর্বোচ্চ চেষ্টা করছি। অনুগ্রহ করে আবার চেষ্টা করুন অথবা নিচে ত্রুটির বিস্তারিত দেখুন। + ত্রুটির বিস্তারিত + ক্লিপবোর্ডে কপি করুন + সম্পূর্ণ লগ দেখুন + সম্পূর্ণ ত্রুটি লগ + + + শাফল + শাফল প্লে + ফলো করুন + আনফলো করুন + প্লেলিস্ট এডিট করুন + %1$s-এর + + + ট্র্যাক অপশন + জ্যামে যোগ করুন + রেডিও শুরু করুন + পরবর্তীতে প্লে করুন + পরবর্তীতে সরান + কিউ থেকে সরান + পছন্দের তালিকা থেকে সরান + পছন্দের তালিকায় সেভ করুন + প্লেলিস্টে যোগ করুন + ডাউনলোড + শেয়ার করুন + + + মূল ক্রম + নাম + শিল্পী + অ্যালবাম + দৈর্ঘ্য + সাজান + কোনো ট্র্যাক পাওয়া যায়নি + ফিল্টার + একসাথে অ্যাকশন + সব ডাউনলোড করুন + %1$dটি ডাউনলোড করুন + সব কিউতে যোগ করুন + %1$dটি কিউতে যোগ করুন + সব পরবর্তীতে প্লে করুন + %1$dটি পরবর্তীতে প্লে করুন + সব প্লেলিস্টে যোগ করুন + %1$dটি প্লেলিস্টে যোগ করুন + সব জ্যামে যোগ করুন + %1$dটি জ্যামে যোগ করুন + +আরও %1$d জন + + + লোড করার মতো কোনো URL নেই + ব্রাউজার হিস্ট্রিতে পেছনে যান + ব্রাউজার হিস্ট্রিতে সামনে যান + + + ব্ল্যাকলিস্ট ফিল্টার করুন... + ব্ল্যাকলিস্টে কিছু নেই + \'%1$s\'-এর সাথে মেলে এমন কিছু ব্ল্যাকলিস্টে নেই + ট্র্যাক + ট্র্যাক + ব্ল্যাকলিস্টে যোগ করুন + ব্ল্যাকলিস্ট থেকে সরান + + + ট্র্যাকের কোনো মেটাডেটা নেই + স্ট্রিম URL পাওয়া যায়নি + স্ট্রিম URL পাওয়া যায়নি। প্রথমে ট্র্যাকটি প্লে করে দেখুন। + + + সেভ করা ট্র্যাক + আপনি + + + ফলো করছেন + জনপ্রিয় ট্র্যাক + জনপ্রিয় ট্র্যাক প্লে করুন + জনপ্রিয় ট্র্যাক কিউতে যোগ করুন + সম্পর্কিত শিল্পী + ফিচার্ড প্লেলিস্ট + শিল্পীর ওভারভিউ লোড করা যায়নি + ফলোয়ার সংখ্যা পাওয়া যাচ্ছে না + %1$sK ফলোয়ার + %1$sM ফলোয়ার + %1$sB ফলোয়ার + + %1$d জন ফলোয়ার + %1$d জন ফলোয়ার + + + + লাইক + আনলাইক + No-op ট্র্যাকের নাম + No-op শিল্পী + অজানা অ্যালবাম + সিক + ভলিউম + শাফল চালু করুন + শাফল বন্ধ করুন + আগের + পরের + প্লে বা পজ + লুপ মোড বন্ধ + লুপ মোড: একটি + লুপ মোড: সব + মিউট + আনমিউট + আরও অপশন + প্লেয়ার অপশন + প্লেয়ার ছোট করুন + লিরিক্স বড় করুন + এখন বাজছে + লিরিক্স + বিকল্প সোর্স + বিকল্প সোর্স + ডাউনলোড + ডাউনলোড হয়েছে + ডাউনলোড হচ্ছে... + আগেই ডাউনলোড করা হয়েছে + আবার ডাউনলোডের চেষ্টা হচ্ছে... + %1$s ডাউনলোড হচ্ছে + %1$s পর প্লেব্যাক পজ হবে। + নির্বাচিত মিনিট পরে প্লেব্যাক পজ করুন। + মিনিট + 1 থেকে 600 মিনিটের মধ্যে বেছে নিন + টাইমার বাতিল করুন + শুরু করুন + প্লেব্যাক URL ভিন্ন স্ট্রিমিং সেটিংসে পাওয়া গেছে। হাইলাইট করা স্ট্রিমটি আপনার বর্তমান পছন্দের সাথে মেলে; পরবর্তী স্ট্রিম রিকোয়েস্টে সক্রিয় URL রিফ্রেশ হবে। + মিলে যাওয়া অডিও সোর্স + মিলে যাওয়া সোর্স লোড হচ্ছে… + লোকাল ক্যাশ ফাইল প্লে করার সময় মিলে যাওয়া রিমোট সোর্সের প্রয়োজন নেই। + মিলে যাওয়া কোনো সোর্স উপলব্ধ নেই। + এই ট্র্যাকের জন্য মিলে যাওয়া কোনো অডিও সোর্স ক্যাশ করা নেই। + স্ট্রিমের বিস্তারিত এখনো ক্যাশ করা হয়নি। লোড করতে প্রথমে ট্র্যাকটি প্লে করুন। + ট্র্যাকের বিস্তারিত লোড করা যায়নি। + উপলব্ধ স্ট্রিম + এই ট্র্যাকটি লোকাল ক্যাশ থেকে চালানো হচ্ছে। এই প্লেব্যাকে স্ট্রিম ভ্যারিয়েন্ট ব্যবহৃত হয় না। + স্ট্রিম লোড হচ্ছে… + এই সোর্সের জন্য কোনো স্ট্রিম উপলব্ধ নেই। + বর্তমানে নির্বাচিত স্ট্রিম + আপনার স্ট্রিমিং ফরম্যাট ও কোয়ালিটি পছন্দের সাথে মেলে + সক্রিয় প্লেব্যাক URL + লোকাল ক্যাশ থেকে চালানো হচ্ছে + মেটাডেটা ট্র্যাক + নাম + ID + অ্যালবাম + নির্ভরযোগ্যতা + এক্সটার্নাল URI + অ্যালবাম ID + অ্যালবামের ধরন + অ্যালবাম প্রকাশের তারিখ + অ্যালবামের ট্র্যাক সংখ্যা + অ্যালবামের জনরা + দৈর্ঘ্য + ট্র্যাক নম্বর + ডিস্ক নম্বর + এক্সপ্লিসিট + জনপ্রিয়তা + ISRC + থাম্বনেইল URL + + + সাইডবার টগল করুন + + + বিকল্প সোর্স + এখন কোনো ট্র্যাক বাজছে না + বিকল্প সোর্স খোঁজা হচ্ছে... + কোনো বিকল্প সোর্স পাওয়া যায়নি + নির্ভরযোগ্যতা: %1$d% + সক্রিয় সোর্স + + + বের করে দিন + তাকে সেশন থেকে সরিয়ে দিন + বের করে দিন এবং আবার যোগ দেওয়া আটকান + সাজেশন সরান + তার কিউতে যোগ করা সব ট্র্যাক সরিয়ে দিন + ক্রম বদলান + কিউ থেকে সরান + %1$s • যোগ করেছেন %2$s + + + অজানা + প্লেলিস্ট লোড হচ্ছে... + ট্র্যাক যোগ করুন + প্লেলিস্টে ট্র্যাক যোগ করুন + ট্র্যাক খুঁজুন... + খুঁজুন + কোনো ট্র্যাক পাওয়া যায়নি + যোগ করার জন্য ট্র্যাক খুঁজুন + সম্পন্ন + যোগ করুন + যোগ করা হয়েছে + + + অ্যালবাম লোড হচ্ছে... + + + আপনার প্লেলিস্টে যোগ করুন + খুঁজুন + আপনার প্লেলিস্ট খুঁজুন... + আপনার প্লেলিস্ট লোড হচ্ছে... + আপনার এখনো কোনো প্লেলিস্ট নেই।\nট্র্যাক যোগ করতে একটি তৈরি করুন। + প্লেলিস্ট তৈরি করুন + প্লেলিস্ট এডিট করুন + তৈরি করুন + নাম + প্লেলিস্টের নাম + বিবরণ + বিবরণ (ঐচ্ছিক) + পাবলিক + যে কেউ এই প্লেলিস্ট দেখতে পারবে + কোলাবোরেটিভ + অন্যরা এই প্লেলিস্টে ট্র্যাক যোগ করতে পারবে + কভার ছবি + প্লেলিস্ট কভার + কোনো ছবি নেই + ছবি পরিবর্তন করুন + ছবি বেছে নিন + + + ইনস্টল করুন + প্রতিস্থাপন করুন + zip ফাইলে plugin.json পাওয়া যায়নি + zip ফাইলে manifest.zipline.json পাওয়া যায়নি + plugin.json-এর ফরম্যাট সঠিক নয়: %1$s + প্লাগইন পড়া যায়নি: %1$s + প্লাগইন ইনস্টল করা যায়নি: %1$s + %1$s-এর একটি নতুন ভার্সন উপলব্ধ। ইনস্টল করার আগে আপডেট নিশ্চিত করুন। + %1$s আগেই ইনস্টল করা আছে। ইনস্টল করার আগে প্রতিস্থাপন নিশ্চিত করুন। + বিল্ট-ইন প্লাগইনগুলো আগে থেকেই অন্তর্ভুক্ত, আবার যোগ করা যাবে না। + বিল্ট-ইন প্লাগইন সরানো যাবে না। + %1$s একটি অবৈধ apiVersion (%2$s) ঘোষণা করেছে। %3$s-এর সাথে সামঞ্জস্যপূর্ণ সিমান্টিক ভার্সনিং প্রত্যাশিত। + Spotube প্লাগইন API ভার্সন %1$s অবৈধ। + %1$s প্লাগইন API %2$s-এর জন্য তৈরি, কিন্তু Spotube ব্রেকিং প্লাগইন API %3$s সাপোর্ট করে। একই ব্রেকিং API ভার্সনের জন্য তৈরি প্লাগইন ইনস্টল করুন। + + + প্লাগইন API সামঞ্জস্যপূর্ণ নয় + প্লাগইন ইনস্টল করবেন? + %1$s আপনার ইনস্টল করা প্লাগইনে যোগ হবে। + প্লাগইন আপডেট করবেন? + একটি পুরোনো ভার্সন (%1$s) ইনস্টল করা আছে। %2$s-এ আপডেট করবেন? + ইনস্টল করা প্লাগইন প্রতিস্থাপন করবেন? + %1$s একটি অবৈধ সিমান্টিক ভার্সন (%2$s) ব্যবহার করে, তাই আপডেট হিসেবে তুলনা করা যাচ্ছে না। তবুও ইনস্টল করা প্লাগইন প্রতিস্থাপন করবেন? + ইনস্টল করা ভার্সন (%1$s) সিমান্টিক ভার্সনিং দিয়ে তুলনা করা যাচ্ছে না। এটিকে %2$s দিয়ে প্রতিস্থাপন করবেন? + ভার্সন %1$s আগেই ইনস্টল করা আছে। বিদ্যমান প্লাগইনটি প্রদত্ত কপি দিয়ে প্রতিস্থাপন করবেন? + ইনস্টল করা ভার্সন %1$s প্রদত্ত ভার্সন %2$s-এর চেয়ে নতুন। তবুও প্রতিস্থাপন করবেন? + ইনস্টল করা প্লাগইনটি প্রদত্ত কপি দিয়ে প্রতিস্থাপন করবেন? + + + %1$s: + ভার্সন + API ভার্সন + লেখক + লাইসেন্স + সক্ষমতা + ক্ষমতা + রিপোজিটরি + যোগাযোগ + বাগ রিপোর্ট করুন + %1$s-কে সাপোর্ট করুন + + + অফিশিয়াল + ভেরিফায়েড + GitHub স্টার + GitHub রিপোজিটরি URL + + + কোনো রিলিজ পাওয়া যায়নি + রিলিজ নির্বাচন করুন + একটি রিলিজ বেছে নিন + প্রি-রিলিজ + রিলিজ নোট + + + হোম + সার্চ + এই ডিভাইসে + Connect + ডিভাইস + + + উইন্ডো লুকান + উইন্ডো দেখান + পরের ট্র্যাক + আগের ট্র্যাক + শাফল + লুপ + লুপ: বন্ধ + লুপ: একটি + লুপ: সব + ভলিউম: %1$d% + ভলিউম + + ভলিউম - + মিউট + আনমিউট + ট্র্যাক লাইক করুন + ট্র্যাক আনলাইক করুন + বের হন + + + মিনিমাইজ + ম্যাক্সিমাইজ + রিস্টোর + বন্ধ করুন + + + প্লেব্যাক + মিডিয়া প্লেব্যাক কন্ট্রোল + + + ব্রাউজ + এখন বাজছে + ফিচার্ড + সেভ করা ট্র্যাক + জনপ্রিয় ট্র্যাক + diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml new file mode 100644 index 00000000..4a4115dd --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -0,0 +1,926 @@ + + + + Warteschlange + Warteschlange leeren + Warteschlange filtern... + Keine Einträge in der Warteschlange + + Songs, Künstler, Alben suchen... + Verlauf löschen + Suchen... + + + Deine Bibliothek + Bibliothek + Playlists + Alben + Künstler + Lokale Titel + Downloads + Gespeicherte Playlists durchsuchen… + Gespeicherte Alben durchsuchen… + Gespeicherte Künstler durchsuchen… + Ordner oder Titel durchsuchen… + Bibliothek filtern + Filter schließen + Playlist erstellen + Lokale Titel aktualisieren + Zur Rasteransicht wechseln + Zur Listenansicht wechseln + Keine gespeicherten Playlists + Keine Playlists für „%1$s“ + Keine gespeicherten Alben + Keine Alben für „%1$s“ + Keine gespeicherten Künstler + Keine Künstler für „%1$s“ + + + Ordner + Ordner + %1$s • %2$s + + %1$d Titel + %1$d Titel + + + %1$d Ordner + %1$d Ordner + + Zurück zu den Ordnern + Ordner abspielen + Audiozugriff erforderlich + Erlaube den Audiozugriff, um Musik auf deinem Gerät zu durchsuchen. + Zugriff erlauben + Keine lokalen Ordner gefunden + Keine Titel in diesem Ordner + Keine passenden Ordner oder Titel + Lokale Medien konnten nicht aktualisiert werden + + + %1$d aktiv • %2$d abgeschlossen + Abgeschlossene entfernen + Noch keine Downloads + Wird heruntergeladen: %1$d% + In Warteschlange + Abgeschlossen + Abgebrochen + Fehlgeschlagen + Fehlgeschlagen: %1$s + Download abbrechen + Download wiederholen + Download entfernen + + + Künstler + Auf der Sperrliste + Lieblingssongs + Deine gespeicherten Songs + Unbekannter Künstler + Titelname + Künstlername + + Ausgewählt + + Einstellungen + + Plugins + Sprache und Region + Darstellung + Wiedergabe + Downloads + Zwischenspeicher + Desktop + Updates + Unterstützen + Über + + Schließen + Speichern + Abbrechen + + Sprache + Sprache der App-Oberfläche. Aktuell: %1$s + Systemstandard + Land + Land für Inhalte und Verfügbarkeit. Aktuell: %1$s + %1$s (%2$s) + + Design + Lege fest, wie sich Spotube an das Erscheinungsbild deines Systems anpasst. Aktuell: %1$s + Hell + Dunkel + System + + Oberfläche + Hintergrundmaterial der App. Aktuell: %1$s + Wasser + Eis + Chrom + Acryl + Glas + + Akzentfarbe + Aktuelle Palette: %1$s + Akzentfarbe + Wähle eine Akzentpalette und sieh sie dir im hellen und dunklen Design an. + Hell + Dunkel + Vorschau + Schaltfläche + Koboldgrün + Elektroviolett + Ozeancyan + Abendrot + Rosengarten + Mitternachtsblau + Schiefermetallic + + Look & Feel + Oberflächenmaterial und Akzent. Aktuell: %1$s · %2$s + Look & Feel + Wähle ein Oberflächenmaterial und dann einen passenden Akzent. Jede Option ist eine sorgfältig abgestimmte Kombination. + Oberfläche + Akzent + Sekundär + + Palette + Millennium-Akzentpalette. Aktuell: %1$s + Palette + Wähle eine Millennium-Akzentpalette und sieh sie dir hell und dunkel an. + Palette + Millennium-Palette und Akzent. Aktuell: %1$s + Blau + Orange + Limette + + Eigener Download-Ordner + Aktueller Ordner: %1$s + Leer lassen, um den Standard-Musikordner zu verwenden. + C:\Music\Spotube + Lokale Medienordner + Eigene Ordner, die auf dem Desktop durchsucht werden. Hinzugefügt: %1$d + Noch kein eigener Ordner hinzugefügt + Eigene Ordner für deine lokale Musikbibliothek. + Verwalten + Ordner hinzufügen + Ordner entfernen + Keine eigenen Ordner hinzugefügt. + %1$s aus der Suchliste entfernen + Download- und Musik-Cache-Ordner werden automatisch einbezogen. + Dein Download-Ordner wird automatisch einbezogen. + Der Musik-Cache-Ordner wird automatisch einbezogen. + Wird gespeichert… + Ordner konnten nicht gespeichert werden. Deine Änderungen sind noch da – versuche es erneut. + + %1$d Ordner + %1$d Ordner + + Download-Format + Bevorzugter Codec und Container für Downloads. Aktuell: %1$s + Download-Qualität + + Streaming-Format + Bevorzugter Codec und Container für Musik-Streaming. Aktuell: %1$s + Streaming-Qualität + Aktuell: %1$s + Musik zwischenspeichern + Gestreamte Audiodaten lokal speichern, damit sie beim erneuten Abspielen schneller laden. + Cache-Ordner + Aktuell: %1$s + Aktuell: Standard-App-Cache + Cache-Größenlimit + Kein Limit + Aktuelles Limit: %1$d MB + Lege die maximale Cache-Größe in MB fest. Gib 0 ein oder lass das Feld leer für kein Limit. + 500 + Die Größe muss 0 oder eine positive Zahl sein. + Endlose Wiedergabe aktivieren + Wenn deine Warteschlange endet, geht es mit empfohlenen Titeln weiter. + Port des Wiedergabe-Proxyservers + Fernsteuerung erlauben + Andere Geräte im selben Netzwerk dürfen die Wiedergabe steuern, wenn sie sich verbinden. + Gerätename für die Fernsteuerung + Der Name, den andere Geräte bei der Suche sehen. Aktuell: %1$s + Wähle einen gut erkennbaren Namen, den andere Geräte in deinem Netzwerk sehen, wenn sie diese Spotube-Instanz finden. + z. B. Spotube Wohnzimmer + Hostname des Geräts + Vom Wiedergabe-Proxyserver verwendeter Port. Aktuell: %1$d + Wähle einen Port zwischen 1 und 65535. + 14769 + Gib eine gültige ganze Zahl ein. + Der Port muss zwischen 1 und 65535 liegen. + + Sperrliste + Gesperrte Titel und Künstler verwalten + + In den Infobereich minimieren + Spotube läuft im Infobereich weiter, wenn das Fenster minimiert ist. + Discord Rich Presence + Zeige auf dem Desktop deinen aktuellen Wiedergabestatus in Discord an. + + Automatisch nach Updates suchen + Im Hintergrund nach neuen Spotube-Versionen suchen. + + Plugins verwalten + Verwalte deine Plugins + Standard-Plugin für %1$s + Metadaten + Audio + Songtexte + Scrobbling + Kein Plugin ausgewählt + Ändern + Auswählen + Ausgewählt + Auswahl aufheben + Keine Plugins verfügbar + Plugin %1$s + Löschen + + Konfigurieren + Plugin-Verwaltung + Bitte gib eine URL ein + Die URL muss mit http:// oder https:// beginnen + Plugin konnte nicht heruntergeladen werden + Noch keine Plugins installiert + Füge oben eine Download-URL ein oder wähle eine Datei aus. + + %1$d Plugin installiert + %1$d Plugins installiert + + Plugin installieren + https://example.com/plugin.smplug + Herunterladen + Aus Datei installieren (.smplug) + Aktiv + v%1$s + Integriert + Plugin entfernen + Anmelden + Abmelden + Von URL herunterladen + Aus Datei installieren + Installieren + + • %1$s + Angeforderte Berechtigungen + Dieses Plugin benötigt keine besonderen Berechtigungen. + Installiert vs. neu + Installiert + Neu + API %1$s → %2$s + Dauerhafter Speicher + Daten auf dem Datenträger lesen und schreiben + Netzwerkanfragen + Daten über das Internet senden und empfangen + WebView + Webinhalte in der App anzeigen + Gruppen-Jam + MQTT-Broker + Broker-Host + %1$s:%2$d + Broker-Port + TLS verwenden + Benutzername (optional) + Passwort (optional) + Client-ID-Präfix + Dient zur Identifizierung dieser Spotube-Instanz + Verbindung zum Broker verschlüsseln + Prüfe die aktuellen Broker-Einstellungen, bevor du einen Raum startest. + Verbindung testen + Wird getestet… + Verbunden in %1$d ms + Fehlgeschlagen: %1$s + Platzhalter-Broker – richte deinen eigenen ein, um selbst zu hosten + Verbindung + Zugangsdaten + Identität + Diagnose + + + Gruppen-Jam + Erstellen + Beitreten + Erstelle einen Raum und teile den Code mit deinen Freunden. + Raum erstellen + Raum beitreten + Verbinden… + Raumcode + ABC123 + Broker: %1$s + Richte in den Einstellungen einen Broker ein, um einen Raum zu hosten oder beizutreten. + Broker-Einstellungen + Verbunden + Verbindung wird wiederhergestellt… + Code kopieren + Kopiert! + Teilen + Teilnehmer (%1$d) + Host + Zuhörer + Warte auf Zuhörer… + Entfernen + Sperren + Verwalten + Raum verlassen + Verbindung fehlgeschlagen + Schließen + Jam-Session konnte nicht erstellt werden + Beitritt zur Jam-Session fehlgeschlagen + Gib einen sechsstelligen Raumcode aus Buchstaben und Ziffern ein. + Tritt meiner Spotube-Jam bei, Code: %1$s + + Zum Album + Sleep-Timer + Titeldetails + Abspielen + Pause + Keine Wiedergabe + Zur Warteschlange hinzufügen + + Ignorieren + Aktualisieren + Spotube %1$s ist verfügbar + Die Neuerungen dieser Version findest du auf der Spotube-Downloadseite. + %1$s %2$s ist verfügbar + Aktuell installiert: %1$s + Die Neuerungen dieses Updates findest du auf der Seite des Plugins. + + Überspringen + Weiter + Zurück + Los geht\'s + Spotube + Musik ohne Fesseln. + Ein Open-Source-Player für eine ganze Welt voller Musik.\nZum Entdecken. Zum Selbstgestalten. + Halte dieses Projekt am Leben. + Spotube wird von Menschen gemacht, nicht von einem Abo-Unternehmen. Wenn es Teil deines Alltags wird, hilft uns schon eine kleine Unterstützung, ihm die Pflege zu geben, die es verdient.\n\nDanke, dass du unabhängiger Software einen Platz gibst. + Beginne mit einem Katalog-Plugin, um Songs und Playlists zu finden. Wähle ein Audio-Plugin, um sie abzuspielen. Songtexte und Hörverlauf sind optional. Entdecke unten ein Plugin, prüfe seine Berechtigungen, installiere es und wähle es dann hier aus. + Plugins entdecken + Deine Quellen + Erneut versuchen + Weitere Plugins + Keine neuen Plugins gefunden. Du kannst auch oben eines über einen Link oder eine .smplug-Datei installieren. + Die Plugin-Suche ist gerade nicht verfügbar. Versuche es erneut oder installiere über einen Link oder eine Datei. + Willkommen + Unterstützen + Plugins + Anleitung + + So nutzt du Spotube + Vier schnelle Schritte und du hörst Musik. + Anmelden + Verbinde dein Spotify-Konto oder wähle ein eigenes Katalog-Plugin. + Suchen und abspielen + Finde einen Song, ein Album oder einen Künstler und tippe auf Abspielen. + Offline hören + Lade Titel herunter, um auch ohne Verbindung weiterzuhören. + Songtexte mitlesen + Öffne im Player synchronisierte Songtexte, während ein Titel läuft. + + + Deine Musik. Deine Quellen. Deine Regeln. + Ein kostenloser Open-Source-Musikplayer, entwickelt von einer Community, die Musik einfach liebt. + Open Source + Keine Werbung + Keine Telemetrie + Mit Plugins + Los geht\'s + + + Unterstützer halten uns am Laufen + Mit Leidenschaft gebaut. Von dir am Leben gehalten. + Spotube ist zu 100 % kostenlos, frei und Open Source – keine Werbung, keine Abos, kein Tracking. Jede Funktion gibt es nur, weil Menschen wie du geholfen haben, die Entwicklung am Laufen zu halten. + Patreon + Monatlicher Sponsor + Unterstütze laufende Entwicklung und Infrastruktur + Buy Me a Coffee + Einmaliger Kaffee + Gib einer nächtlichen Coding-Session mit einem kleinen Trinkgeld Energie + Open Collective + Community-Fonds + Transparente, von der Community verwaltete Finanzierung + Von Herzen danke, dass du an offene Musik glaubst. + Die Unterstützung ist völlig freiwillig – Spotube bleibt so oder so kostenlos. + Sponsern + %1$s öffnen + + + Spotube unterstützen + Spotube unterstützen + Spotube ist kostenlos, frei und Open Source – keine Werbung, keine Abos, kein Tracking. Wenn es dir Freude macht, unterstütze doch das Projekt. + Du kannst diese Erinnerungen unter Einstellungen → Unterstützen deaktivieren. + Vielleicht später + Unterstützungs-Erinnerungen + Alle 7 Tage eine dezente Erinnerung anzeigen, das Projekt zu unterstützen. + + + Modulare Architektur + Bestimme, woher deine Musik kommt. + Spotube trennt Katalogsuche, Audio-Streaming und synchronisierte Songtexte. Konfiguriere unten deine Engines oder wechsle sie jederzeit. + Metadaten-Engine + Sorgt für Musikentdeckung, Suche, Titellisten und Playlists. + Audio-Engine + Lädt und dekodiert die abgespielten Audiostreams. + Songtext-Engine + Liefert zeitsynchrone Karaoke-Songtexte in Echtzeit. + Scrobble-Engine + Zeichnet deinen Hörverlauf bei Last.fm oder ListenBrainz auf. + Aktiv + Integriert + Bereit + Optional + Engine auswählen + Installierte Engines + Als Standard festlegen + NewPipe YouTube und LRCLib Lyrics sind integriert und sofort einsatzbereit. + Unter Einstellungen → Plugins kannst du jederzeit Community-Plugins verwalten oder hinzufügen. + + + Retro-moderne Architektur + Reines Hi-Fi, ohne Zwischenhändler + So steuert Spotube die dezentrale Wiedergabe direkt auf deinem Gerät. + DIREKT PEER-TO-PEER • 256 KBPS OPUS + Blaue Stunde + Spotube · Player-Demo + Ein bisschen Musik für die Momente dazwischen. + 1. Dezentrale Entdeckung + Suchanfragen gehen direkt an dein gewähltes Katalog-Plugin. Spotube erstellt nie ein Profil deines Geschmacks. + 2. Direkte Stream-Auflösung + Audio wird direkt von den Content-Servern auf dein Gerät gestreamt. Keine Proxy-Umwege, kein Datensammeln aus der Ferne. + 3. Lokales DSP & Wiedergabe + Lokal dekodiert und zwischengespeichert, mit Equalizer und optionalem Offline-Download. + 4. Synchronisierte Songtexte & Jam + Karaoke-Songtexte folgen der Wiedergabeposition – dazu LAN-Fernsteuerung und Gruppen-Jam. + Keine Zwischenserver. Die Wiedergabe läuft direkt auf deinem Gerät – privat aus Prinzip. + + + Gerät auswählen + %1$s: „%2$s“ + Abspielen + Zur Warteschlange hinzufügen + Als Nächstes abspielen + Dieses Gerät + Auf diesem Gerät + Remote-Gerät + Remote-Gerät (%1$s) + Auf dem verbundenen Gerät + + + Verbindung fehlgeschlagen + Verbindung unterbrochen + Senden fehlgeschlagen + Gerätesuche fehlgeschlagen: %1$s + Verbindung fehlgeschlagen: %1$s + + + Geräte + Aktualisieren + Trennen + Verbinden... + Suche nach Geräten im Netzwerk... + Keine Geräte gefunden + Stelle sicher, dass auf dem anderen Gerät „Fernsteuerung erlauben“ in den Einstellungen aktiviert ist. + Unbekanntes Gerät + Wird aufgelöst... + + + Fernsteuerung + Getrennt + Verbindungsfehler: %1$s + Unbekannter Titel + Unbekannter Künstler + Lautstärke + Aus Warteschlange entfernen + Weitere Optionen + + + Wo abspielen? + Du hast eine aktive Jam-Session. Wähle, wo diese Sammlung abgespielt werden soll. + Hier abspielen + Für Jam vorschlagen + + + Kein Jam-Broker konfiguriert + Broker-Host ist leer + Verbindung fehlgeschlagen + Verbindung unterbrochen + + + Broker + anonym + + + Zur Jam-Warteschlange hinzugefügt + + %1$d Titel zur Jam-Warteschlange hinzugefügt + %1$d Titel zur Jam-Warteschlange hinzugefügt + + + + Fernsteuerungsanfrage + „%1$s“ möchte die Wiedergabe auf diesem Gerät steuern. + Ablehnen + Erlauben + Immer erlauben + + + Suchergebnisse + Alle + Titel + Nutzer + Keine Suchtypen verfügbar + Tippe, um zu suchen + Tippe, um nach Titeln zu suchen + Keine Ergebnisse gefunden + Alle anzeigen + Suche läuft + Titel werden geladen... + Alben werden geladen... + Künstler werden geladen... + Playlists werden geladen... + Nutzer werden geladen... + Keine Alben gefunden + Keine Künstler gefunden + Keine Playlists gefunden + Keine Nutzer gefunden + Wir wühlen in den Plattenkisten... + Wir durchsuchen den Kosmos... + Die Lautsprecher wärmen sich auf... + Wir entstauben das Vinyl... + Wir befragen die Musikgötter... + Wir richten die Antennen aus... + Wir blättern durch die Platten... + Suchfunktionen konnten nicht geladen werden + Suche fehlgeschlagen + Titelsuche fehlgeschlagen + Albumsuche fehlgeschlagen + Künstlersuche fehlgeschlagen + Playlist-Suche fehlgeschlagen + Nutzersuche fehlgeschlagen + + + Entdecken + + + Synchron + Nur Text + Keine synchronisierten Songtexte verfügbar + Keine Songtexte verfügbar + + + %1$s in %2$s + %1$d kbps + %1$s kHz • %2$d Kanäle + Kein Metadaten-Plugin ausgewählt + + + Zurück + Erneut versuchen + Dialog schließen + Wird geladen... + Unbekannt + Unbekannter Fehler + + + Nutzer + Playlist + + + Das tut uns wirklich leid + Gerade funktioniert etwas nicht richtig. Dein Erlebnis ist uns wichtig und wir tun unser Bestes, um das zu beheben. Versuche es bitte erneut oder sieh dir die Fehlerdetails unten an. + Fehlerdetails + In die Zwischenablage kopieren + Vollständiges Protokoll anzeigen + Vollständiges Fehlerprotokoll + + + Zufallswiedergabe + Zufällig abspielen + Folgen + Nicht mehr folgen + Playlist bearbeiten + Von %1$s + + + Titeloptionen + Zur Jam hinzufügen + Radio starten + Als Nächstes abspielen + Als Nächstes einreihen + Aus Warteschlange entfernen + Aus Favoriten entfernen + Als Favorit speichern + Zur Playlist hinzufügen + Herunterladen + Teilen + + + Original + Titel + Künstler + Album + Dauer + Sortieren + Keine Titel gefunden + Filtern + Sammelaktionen + Alle herunterladen + %1$d herunterladen + Alle zur Warteschlange hinzufügen + %1$d zur Warteschlange hinzufügen + Alle als Nächstes abspielen + %1$d als Nächstes abspielen + Alle zur Playlist hinzufügen + %1$d zur Playlist hinzufügen + Alle zur Jam hinzufügen + %1$d zur Jam hinzufügen + +%1$d weitere + + + Keine URL zum Laden + Im Browserverlauf zurück + Im Browserverlauf vorwärts + + + Sperrliste filtern... + Keine gesperrten Einträge + Keine gesperrten Einträge für „%1$s“ + Titel + Titel + Zur Sperrliste hinzufügen + Von der Sperrliste entfernen + + + Keine Titel-Metadaten + Stream-URL konnte nicht ermittelt werden + Stream-URL konnte nicht ermittelt werden. Spiele den Titel zuerst ab. + + + Gespeicherte Titel + Du + + + Gefolgt + Top-Titel + Top-Titel abspielen + Top-Titel zur Warteschlange hinzufügen + Ähnliche Künstler + Empfohlene Playlists + Künstlerübersicht konnte nicht geladen werden + Follower nicht verfügbar + %1$s Tsd. Follower + %1$s Mio. Follower + %1$s Mrd. Follower + + %1$d Follower + %1$d Follower + + + + Gefällt mir + Gefällt mir nicht mehr + Platzhalter-Titel + Platzhalter-Künstler + Unbekanntes Album + Spulen + Lautstärke + Zufallswiedergabe aktivieren + Zufallswiedergabe deaktivieren + Zurück + Weiter + Abspielen oder pausieren + Wiederholen aus + Titel wiederholen + Alle wiederholen + Stummschalten + Stummschaltung aufheben + Weitere Optionen + Player-Optionen + Player minimieren + Songtexte erweitern + Aktuelle Wiedergabe + Songtext + Alternative Quelle + Alternative Quelle + Herunterladen + Heruntergeladen + Wird heruntergeladen... + Bereits heruntergeladen + Download wird wiederholt... + %1$s wird heruntergeladen + Die Wiedergabe wird in %1$s pausiert. + Wiedergabe nach der ausgewählten Anzahl an Minuten pausieren. + Minuten + Zwischen 1 und 600 Minuten wählen + Timer abbrechen + Starten + Die Wiedergabe-URL wurde mit anderen Streaming-Einstellungen ermittelt. Der markierte Stream entspricht deiner aktuellen Einstellung; die aktive URL wird bei der nächsten Stream-Anfrage aktualisiert. + Zugeordnete Audioquelle + Zugeordnete Quelle wird geladen… + Beim Abspielen der lokal zwischengespeicherten Datei wird keine zugeordnete Remote-Quelle benötigt. + Keine zugeordnete Quelle verfügbar. + Für diesen Titel ist keine zugeordnete Audioquelle zwischengespeichert. + Stream-Details sind noch nicht zwischengespeichert. Spiele den Titel zuerst ab, um sie zu laden. + Titeldetails konnten nicht geladen werden. + Verfügbare Streams + Dieser Titel wird aus dem lokalen Cache abgespielt. Stream-Varianten werden für diese Wiedergabe nicht verwendet. + Streams werden geladen… + Für diese Quelle sind keine Streams verfügbar. + Aktuell ausgewählter Stream + Entspricht deinen Einstellungen für Streaming-Format & -Qualität + Aktive Wiedergabe-URL + Aus lokalem Cache abgespielt + Metadaten-Titel + Titel + ID + Album + Übereinstimmung + Externe URI + Album-ID + Albumtyp + Erscheinungsdatum des Albums + Anzahl der Titel im Album + Album-Genres + Dauer + Titelnummer + CD-Nummer + Explizit + Beliebtheit + ISRC + Vorschaubild-URLs + + + Seitenleiste ein-/ausblenden + + + Alternative Quellen + Aktuell wird kein Titel abgespielt + Suche nach alternativen Quellen... + Keine alternativen Quellen gefunden + Übereinstimmung: %1$d% + Aktive Quelle + + + Rauswerfen + Aus der Session entfernen + Rauswerfen und erneuten Beitritt verhindern + Vorschläge entfernen + Alle Titel entfernen, die diese Person zur Warteschlange hinzugefügt hat + Neu anordnen + Aus Warteschlange entfernen + %1$s • Hinzugefügt von %2$s + + + Unbekannt + Playlist wird geladen... + Titel hinzufügen + Titel zur Playlist hinzufügen + Titel suchen... + Suchen + Keine Titel gefunden + Suche nach Titeln zum Hinzufügen + Fertig + Hinzufügen + Hinzugefügt + + + Album wird geladen... + + + Zu deiner Playlist hinzufügen + Suchen + Deine Playlists durchsuchen... + Deine Playlists werden geladen... + Du hast noch keine eigenen Playlists.\nErstelle eine, um Titel hinzuzufügen. + Playlist erstellen + Playlist bearbeiten + Erstellen + Name + Name der Playlist + Beschreibung + Beschreibung (optional) + Öffentlich + Alle können diese Playlist sehen + Gemeinsam + Andere können Titel zu dieser Playlist hinzufügen + Coverbild + Playlist-Cover + Kein Bild + Bild ändern + Bild auswählen + + + Installieren + Ersetzen + plugin.json wurde in der ZIP-Datei nicht gefunden + manifest.zipline.json wurde in der ZIP-Datei nicht gefunden + Ungültiges plugin.json-Format: %1$s + Plugin konnte nicht gelesen werden: %1$s + Plugin konnte nicht installiert werden: %1$s + Eine neuere Version von %1$s ist verfügbar. Bestätige das Update vor der Installation. + %1$s ist bereits installiert. Bestätige das Ersetzen vor der Installation. + Integrierte Plugins sind bereits enthalten und können nicht erneut hinzugefügt werden. + Integrierte Plugins können nicht entfernt werden. + %1$s gibt eine ungültige apiVersion an (%2$s). Erwartet wird semantische Versionierung, kompatibel mit %3$s. + Die Spotube-Plugin-API-Version %1$s ist ungültig. + %1$s ist für Plugin-API %2$s gebaut, Spotube unterstützt jedoch die Breaking-API-Version %3$s. Installiere ein Plugin, das für dieselbe Breaking-API-Version gebaut wurde. + + + Plugin-API nicht kompatibel + Plugin installieren? + %1$s wird zu deinen installierten Plugins hinzugefügt. + Plugin aktualisieren? + Eine ältere Version ist installiert (%1$s). Auf %2$s aktualisieren? + Installiertes Plugin ersetzen? + %1$s verwendet eine ungültige semantische Version (%2$s) und kann nicht als Update verglichen werden. Installiertes Plugin trotzdem ersetzen? + Die installierte Version (%1$s) lässt sich nicht per semantischer Versionierung vergleichen. Durch %2$s ersetzen? + Version %1$s ist bereits installiert. Vorhandenes Plugin durch die neue Kopie ersetzen? + Die installierte Version %1$s ist neuer als die bereitgestellte Version %2$s. Trotzdem ersetzen? + Installiertes Plugin durch die neue Kopie ersetzen? + + + %1$s: + Version + API-Version + Autor + Lizenz + Berechtigungen + Funktionen + Repository + Kontakt + Fehler melden + %1$s unterstützen + + + Offiziell + Verifiziert + GitHub-Sterne + URL des GitHub-Repositorys + + + Keine Releases gefunden + Release auswählen + Wähle ein Release + Vorabversion + Versionshinweise + + + Startseite + Suche + Auf dem Gerät + Verbinden + Geräte + + + Fenster ausblenden + Fenster anzeigen + Nächster Titel + Vorheriger Titel + Zufallswiedergabe + Wiederholen + Wiederholen: AUS + Wiederholen: TITEL + Wiederholen: ALLE + Lautstärke: %1$d% + Lautstärke + + Lautstärke - + Stummschalten + Stummschaltung aufheben + Titel liken + Titel nicht mehr liken + Beenden + + + Minimieren + Maximieren + Wiederherstellen + Schließen + + + Wiedergabe + Steuerung der Medienwiedergabe + + + Entdecken + Aktuelle Wiedergabe + Empfohlen + Gespeicherte Titel + Top-Titel + diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml new file mode 100644 index 00000000..48c78bf3 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -0,0 +1,932 @@ + + + + Cola + Vaciar cola + Filtrar cola... + La cola está vacía + + Busca canciones, artistas, álbumes... + Borrar todo el historial + Buscar... + + + Tu biblioteca + Biblioteca + Playlists + Álbumes + Artistas + Canciones locales + Descargas + Buscar playlists guardadas… + Buscar álbumes guardados… + Buscar artistas guardados… + Buscar carpetas o canciones… + Filtrar biblioteca + Cerrar filtro + Crear playlist + Actualizar canciones locales + Cambiar a vista de cuadrícula + Cambiar a vista de lista + No hay playlists guardadas + Ninguna playlist coincide con “%1$s” + No hay álbumes guardados + Ningún álbum coincide con “%1$s” + No hay artistas guardados + Ningún artista coincide con “%1$s” + + + Carpetas + Carpeta + %1$s • %2$s + + %1$d canción + %1$d canciones + %1$d canciones + + + %1$d carpeta + %1$d carpetas + %1$d carpetas + + Volver a carpetas + Reproducir carpeta + Se necesita acceso al audio + Permite el acceso al audio para explorar la música de tu dispositivo. + Permitir acceso + No se encontraron carpetas locales + No hay canciones en esta carpeta + No hay carpetas ni canciones que coincidan + No se pudo actualizar el contenido local + + + %1$d activas • %2$d completadas + Borrar completadas + Aún no hay descargas + Descargando %1$d% + En cola + Completada + Cancelada + Error + Error: %1$s + Cancelar descarga + Reintentar descarga + Quitar descarga + + + Artista + En lista negra + Canciones que te gustan + Tus canciones guardadas + Artista desconocido + Título de la canción + Nombre del artista + + Seleccionado + + Ajustes + + Plugins + Idioma y región + Apariencia + Reproducción + Descargas + Caché + Escritorio + Actualizaciones + Apoyo + Acerca de + + Cerrar + Guardar + Cancelar + + Idioma + Idioma de la interfaz de la app. Actual: %1$s + Predeterminado del sistema + País + País de referencia para el contenido y la disponibilidad. Actual: %1$s + %1$s (%2$s) + + Tema + Elige cómo se adapta Spotube a la apariencia del sistema. Actual: %1$s + Claro + Oscuro + Sistema + + Superficie + Material de fondo de la app. Actual: %1$s + Acuoso + Helado + Cromo + Acrílico + Cristal + + Color de acento + Paleta actual: %1$s + Color de acento + Elige una paleta de acento y previsualízala en los temas claro y oscuro. + Claro + Oscuro + Vista previa + Botón + Duende verde + Violeta eléctrico + Cian oceánico + Naranja atardecer + Jardín de rosas + Azul medianoche + Pizarra metálica + + Aspecto y estilo + Material de superficie y acento. Actual: %1$s · %2$s + Aspecto y estilo + Elige un material de superficie y luego un acento que combine con él. Cada opción es una combinación seleccionada. + Superficie + Acento + Secundario + + Paleta + Paleta de acento Millennium. Actual: %1$s + Paleta + Elige una paleta de acento Millennium y previsualízala en claro y oscuro. + Paleta + Paleta Millennium y acento. Actual: %1$s + Azul + Naranja + Lima + + Carpeta de descargas personalizada + Carpeta actual: %1$s + Déjalo vacío para usar la carpeta de música predeterminada. + C:\Music\Spotube + Carpetas de contenido local + Carpetas personalizadas que se analizan en el escritorio. Añadidas: %1$d + Aún no has añadido ninguna carpeta personalizada + Carpetas personalizadas para tu biblioteca de música local. + Gestionar + Añadir carpeta + Quitar carpeta + No se han añadido carpetas personalizadas. + Quitar %1$s de la lista de análisis + Las carpetas de descargas y de caché de música se incluyen automáticamente. + Tu carpeta de descargas se incluye automáticamente. + La carpeta de caché de música se incluye automáticamente. + Guardando… + No se pudieron guardar las carpetas. Tus cambios siguen aquí; inténtalo de nuevo. + + %1$d carpeta + %1$d carpetas + %1$d carpetas + + Formato de descarga + Códec y contenedor preferidos para las descargas. Actual: %1$s + Calidad de descarga + + Formato de streaming + Códec y contenedor preferidos para el streaming de música. Actual: %1$s + Calidad de streaming + Actual: %1$s + Activar caché de música + Guarda el audio reproducido en local para acelerar las siguientes reproducciones. + Carpeta de caché + Actual: %1$s + Actual: caché predeterminada de la app + Límite de tamaño de la caché + Sin límite + Límite actual: %1$d MB + Define el tamaño máximo de la caché en MB. Escribe 0 o déjalo vacío para no tener límite. + 500 + El tamaño debe ser 0 o un número positivo. + Activar reproducción infinita + Sigue reproduciendo canciones recomendadas cuando termine la cola. + Puerto del servidor proxy de reproducción + Permitir control remoto + Permite que otros dispositivos de la misma red controlen la reproducción al conectarse. + Nombre del dispositivo para control remoto + El nombre que ven otros dispositivos al detectar este. Actual: %1$s + Elige un nombre reconocible que verán otros dispositivos de tu red al detectar esta instancia de Spotube. + p. ej., Spotube del salón + nombre de host del dispositivo + Puerto que usa el servidor proxy de reproducción. Actual: %1$d + Elige un puerto entre 1 y 65535. + 14769 + Introduce un número entero válido. + El puerto debe estar entre 1 y 65535. + + Lista negra + Gestiona las canciones y artistas de la lista negra + + Minimizar a la bandeja + Mantén Spotube en ejecución en la bandeja del sistema al minimizar la ventana. + Rich Presence de Discord + Muestra lo que estás escuchando en Discord desde el escritorio. + + Buscar actualizaciones automáticamente + Busca nuevas versiones de Spotube en segundo plano. + + Gestionar plugins + Gestiona tus plugins + Plugin de %1$s predeterminado + Metadatos + Audio + Letras + Scrobbling + Ningún plugin seleccionado + Cambiar + Seleccionar + Seleccionado + Borrar selección + No hay plugins disponibles + Plugin %1$s + Borrar + + Configurar + Gestor de plugins + Introduce una URL + La URL debe empezar por http:// o https:// + No se pudo descargar el plugin + Aún no hay plugins instalados + Pega una URL de descarga o elige un archivo arriba. + + %1$d plugin instalado + %1$d de plugins instalados + %1$d plugins instalados + + Instalar un plugin + https://example.com/plugin.smplug + Descargar + Instalar desde archivo (.smplug) + Activo + v%1$s + Integrado + Quitar plugin + Iniciar sesión + Cerrar sesión + Descargar desde URL + Instalar desde archivo + Instalar + + • %1$s + Permisos solicitados + Este plugin no necesita permisos especiales. + Instalado frente a proporcionado + Instalado + Proporcionado + API %1$s → %2$s + Almacenamiento persistente + Leer y escribir datos en el disco + Solicitudes de red + Enviar y recibir datos por internet + WebView + Mostrar contenido web dentro de la app + Jam en grupo + Broker MQTT + Host del broker + %1$s:%2$d + Puerto del broker + Usar TLS + Usuario (opcional) + Contraseña (opcional) + Prefijo de ID de cliente + Se usa para identificar esta instancia de Spotube + Cifra la conexión con el broker + Comprueba los ajustes actuales del broker antes de crear una sala. + Probar conexión + Probando… + Conectado en %1$d ms + Error: %1$s + Broker de ejemplo: configura el tuyo para alojarlo tú mismo + Conexión + Credenciales + Identidad + Diagnóstico + + + Jam en grupo + Crear + Unirse + Crea una sala y comparte su código con tus amigos. + Crear sala + Unirse a la sala + Conectando… + Código de la sala + ABC123 + Broker: %1$s + Configura un broker en Ajustes para crear una sala o unirte a una. + Ajustes del broker + Conectado + Reconectando… + Copiar código + ¡Copiado! + Compartir + Participantes (%1$d) + Anfitrión + Oyente + Esperando a los oyentes… + Quitar + Bloquear + Gestionar + Salir de la sala + No se pudo conectar + Cerrar + No se pudo crear la sesión de Jam + No se pudo unir a la sesión de Jam + Introduce un código de sala de seis caracteres con letras y números. + Únete a mi Jam de Spotube con el código: %1$s + + Ir al álbum + Temporizador + Detalles de la canción + Reproducir + Pausar + No se está reproduciendo nada + Añadir a la cola + + Ignorar + Actualizar + Spotube %1$s está disponible + Descubre las novedades de esta versión en la página de descargas de Spotube. + %1$s %2$s está disponible + Versión instalada: %1$s + Descubre las novedades de esta actualización en la página del plugin. + + Omitir + Siguiente + Atrás + Empezar + Spotube + Música, sin ataduras. + Un reproductor de código abierto para todo un mundo de música.\nPara que lo explores. Para que lo hagas tuyo. + Mantén vivo este proyecto. + Spotube lo hacen personas, no un negocio de suscripciones. Si se convierte en parte de tu día a día, un pequeño apoyo nos ayuda a darle el cuidado que merece.\n\nGracias por hacerle un hueco al software independiente. + Empieza con un plugin de catálogo para encontrar canciones y playlists. Elige un plugin de audio para reproducirlas. Las letras y el historial de escucha son opcionales. Descubre un plugin abajo, revisa sus permisos, instálalo y selecciónalo aquí. + Descubrir plugins + Tus fuentes + Reintentar + Más plugins + No se encontraron plugins nuevos. También puedes instalar uno desde un enlace o un archivo .smplug arriba. + La búsqueda de plugins no está disponible ahora. Inténtalo de nuevo o instala uno desde un enlace o archivo. + Bienvenida + Apoyo + Plugins + Guía + + Cómo usar Spotube + Cuatro pasos rápidos y a escuchar. + Inicia sesión + Conecta tu cuenta de Spotify o elige tu propio plugin de catálogo. + Busca y reproduce + Encuentra cualquier canción, álbum o artista y dale al play. + Escucha sin conexión + Descarga canciones para seguir escuchando sin conexión. + Sigue la letra + Abre la letra sincronizada desde el reproductor mientras suena una canción. + + + Tu música. Tus fuentes. Tus reglas. + Un reproductor de música gratuito y de código abierto creado por una comunidad que simplemente ama la música. + Código abierto + Sin anuncios + Sin telemetría + Basado en plugins + ¡Vamos! + + + Nuestros mecenas nos mantienen en marcha + Hecho con pasión. Mantenido gracias a ti. + Spotube es 100 % gratuito, libre y de código abierto: sin anuncios, sin suscripciones, sin rastreo. Cada función existe porque personas como tú decidieron ayudar a que el código siga fluyendo. + Patreon + Mecenas mensual + Apoya el desarrollo continuo y la infraestructura + Buy Me a Coffee + Un café puntual + Da energía a una sesión de programación nocturna con una pequeña propina + Open Collective + Fondo comunitario + Financiación transparente y gestionada por la comunidad + Gracias de todo corazón por creer en la música abierta. + Apoyar es totalmente opcional: Spotube seguirá siendo gratis de todos modos. + Patrocinar + Abrir %1$s + + + Apoyar a Spotube + Apoyar a Spotube + Spotube es gratuito, libre y de código abierto: sin anuncios, sin suscripciones, sin rastreo. Si te alegra el día, plantéate apoyar el proyecto. + Puedes desactivar estos recordatorios en Ajustes → Apoyo. + Quizás más tarde + Recordatorios de apoyo + Muestra un recordatorio amable para apoyar el proyecto cada 7 días. + + + Arquitectura modular + Elige de dónde viene tu música. + Spotube separa el catálogo, el streaming de audio y las letras sincronizadas. Configura tus motores abajo o cámbialos cuando quieras. + Motor de metadatos + Se encarga del descubrimiento de música, la búsqueda, las listas de canciones y las playlists. + Motor de audio + Obtiene y decodifica las secuencias de audio que se reproducen. + Motor de letras + Muestra letras de karaoke sincronizadas en tiempo real. + Motor de scrobbling + Registra tu historial de escucha en Last.fm o ListenBrainz. + Activo + Integrado + Listo + Opcional + Selecciona un motor + Motores instalados + Establecer como predeterminado + NewPipe YouTube y LRCLib Lyrics vienen integrados y listos para usar desde el primer momento. + Gestiona o añade nuevos plugins de la comunidad cuando quieras en Ajustes → Plugins. + + + Arquitectura retromoderna + Alta fidelidad pura, sin intermediarios + Así es como Spotube orquesta la reproducción descentralizada directamente en tu equipo. + P2P DIRECTO • OPUS A 256 KBPS + Hora azul + Spotube · demostración del reproductor + Un poco de música para los momentos intermedios. + 1. Descubrimiento descentralizado + Las búsquedas van directamente al plugin de catálogo que elijas. Spotube nunca analiza tus gustos. + 2. Resolución directa del stream + El audio llega directamente de los servidores de contenido a tu equipo. Sin saltos por proxies ni recopilación remota de datos. + 3. DSP y reproducción en local + Decodificado y almacenado en caché localmente, con ecualizador y descarga opcional para escuchar sin conexión. + 4. Letras sincronizadas y Jam + Las letras de karaoke siguen la reproducción al segundo, con control remoto por LAN y Jam en grupo. + Sin servidores intermediarios. La reproducción ocurre en tu dispositivo: privada por diseño. + + + Elige un dispositivo + %1$s: «%2$s» + Reproducir + Añadir a la cola + Reproducir a continuación + Este dispositivo + En este dispositivo + Dispositivo remoto + Dispositivo remoto (%1$s) + En el dispositivo conectado + + + Error de conexión + Se perdió la conexión + Error al enviar + Error al buscar dispositivos: %1$s + No se pudo conectar: %1$s + + + Dispositivos + Actualizar + Desconectar + Conectando... + Buscando dispositivos en la red... + No se encontraron dispositivos + Asegúrate de que el otro dispositivo tenga activado \"Permitir control remoto\" en los ajustes. + Dispositivo desconocido + Resolviendo... + + + Control remoto + Desconectado + Error de conexión: %1$s + Canción desconocida + Artista desconocido + Volumen + Quitar de la cola + Más opciones + + + ¿Dónde reproducir? + Tienes una sesión de Jam activa. Elige dónde reproducir esta colección. + Reproducir aquí + Sugerir al Jam + + + No hay ningún broker de Jam configurado + El host del broker está vacío + Error de conexión + Se perdió la conexión + + + Broker + anónimo + + + Añadida a la cola del Jam + + %1$d añadida a la cola del Jam + %1$d añadidas a la cola del Jam + %1$d añadidas a la cola del Jam + + + + Solicitud de control remoto + \"%1$s\" quiere controlar la reproducción en este dispositivo. + Denegar + Permitir + Permitir siempre + + + Resultados de búsqueda + Todo + Canciones + Usuarios + No hay tipos de búsqueda disponibles + Empieza a escribir para buscar + Empieza a escribir para buscar canciones + No se encontraron resultados + Ver todo + Buscando + Cargando canciones... + Cargando álbumes... + Cargando artistas... + Cargando playlists... + Cargando usuarios... + No se encontraron álbumes + No se encontraron artistas + No se encontraron playlists + No se encontraron usuarios + Rebuscando entre los discos... + Buscando por el cosmos... + Calentando los altavoces... + Quitando el polvo a los vinilos... + Consultando a los dioses de la música... + Sintonizando las antenas... + Pasando discos... + No se pudieron cargar las opciones de búsqueda + No se pudo realizar la búsqueda + No se pudieron buscar canciones + No se pudieron buscar álbumes + No se pudieron buscar artistas + No se pudieron buscar playlists + No se pudieron buscar usuarios + + + Explorar + + + Sincronizada + Texto + No hay letra sincronizada disponible + No hay letra disponible + + + %1$s en %2$s + %1$d kbps + %1$s kHz • %2$d canales + No hay ningún plugin de metadatos seleccionado + + + Atrás + Reintentar + Cerrar diálogo + Cargando... + Desconocido + Error desconocido + + + Usuario + Playlist + + + Lo sentimos de verdad + Algo no funciona bien en este momento. Tu experiencia nos importa y estamos haciendo todo lo posible para solucionarlo. Inténtalo de nuevo o consulta los detalles del error a continuación. + Detalles del error + Copiar al portapapeles + Ver registro completo + Registro de errores completo + + + Aleatorio + Reproducción aleatoria + Seguir + Dejar de seguir + Editar playlist + De %1$s + + + Opciones de la canción + Añadir al Jam + Iniciar radio + Reproducir a continuación + Mover a siguiente + Quitar de la cola + Quitar de favoritos + Guardar en favoritos + Añadir a playlist + Descargar + Compartir + + + Original + Título + Artista + Álbum + Duración + Ordenar + No se encontraron canciones + Filtrar + Acciones en bloque + Descargar todo + Descargar %1$d + Añadir todo a la cola + Añadir %1$d a la cola + Reproducir todo a continuación + Reproducir %1$d a continuación + Añadir todo a una playlist + Añadir %1$d a una playlist + Añadir todo al Jam + Añadir %1$d al Jam + +%1$d más + + + No hay ninguna URL que cargar + Retroceder en el historial del navegador + Avanzar en el historial del navegador + + + Filtrar lista negra... + No hay elementos en la lista negra + Ningún elemento de la lista negra coincide con “%1$s” + Canciones + Canción + Añadir a la lista negra + Quitar de la lista negra + + + No hay metadatos de la canción + No se pudo obtener la URL del stream + No se pudo obtener la URL del stream. Prueba a reproducir la canción primero. + + + Canciones guardadas + Tú + + + Siguiendo + Canciones populares + Reproducir canciones populares + Añadir canciones populares a la cola + Artistas similares + Playlists destacadas + No se pudo cargar la información del artista + Seguidores no disponibles + %1$s mil seguidores + %1$s M de seguidores + %1$s mil M de seguidores + + %1$d seguidor + %1$d de seguidores + %1$d seguidores + + + + Me gusta + Ya no me gusta + Título de canción no disponible + Artistas no disponibles + Álbum desconocido + Avanzar + Volumen + Activar aleatorio + Desactivar aleatorio + Anterior + Siguiente + Reproducir o pausar + Repetición desactivada + Repetir una + Repetir todo + Silenciar + Activar sonido + Más opciones + Opciones del reproductor + Contraer reproductor + Ampliar letra + Reproduciendo ahora + Letra + Fuente alternativa + Fuente alternativa + Descargar + Descargada + Descargando... + Ya descargada + Reintentando la descarga... + Descargando %1$s + La reproducción se pausará en %1$s. + Pausa la reproducción tras el número de minutos seleccionado. + Minutos + Elige entre 1 y 600 minutos + Cancelar temporizador + Iniciar + La URL de reproducción se obtuvo con otros ajustes de streaming. El stream resaltado coincide con tu preferencia actual; la URL activa se actualizará en la próxima solicitud de stream. + Fuente de audio asociada + Cargando fuente asociada… + No se necesita una fuente remota asociada mientras se reproduce el archivo local en caché. + No hay ninguna fuente asociada disponible. + No hay ninguna fuente de audio asociada en caché para esta canción. + Los detalles del stream aún no están en caché. Reproduce primero la canción para cargarlos. + No se pudieron cargar los detalles de la canción. + Streams disponibles + Esta canción se está reproduciendo desde la caché local. Las variantes de stream no se usan en esta reproducción. + Cargando streams… + No hay streams disponibles para esta fuente. + Stream seleccionado actualmente + Coincide con tu preferencia de formato y calidad de streaming + URL de reproducción activa + Reproducido desde la caché local + Canción de metadatos + Título + ID + Álbum + Fiabilidad + URI externa + ID del álbum + Tipo de álbum + Fecha de lanzamiento del álbum + Número de canciones del álbum + Géneros del álbum + Duración + Número de pista + Número de disco + Explícito + Popularidad + ISRC + URL de miniaturas + + + Mostrar u ocultar barra lateral + + + Fuentes alternativas + No se está reproduciendo ninguna canción + Buscando fuentes alternativas... + No se encontraron fuentes alternativas + Fiabilidad: %1$d% + Fuente activa + + + Expulsar + Sacarlo de la sesión + Expulsarlo e impedir que vuelva a unirse + Quitar sugerencias + Quitar todas las canciones que añadió a la cola + Reordenar + Quitar de la cola + %1$s • Añadida por %2$s + + + Desconocido + Cargando playlist... + Añadir canciones + Añadir canciones a la playlist + Buscar canciones... + Buscar + No se encontraron canciones + Busca canciones para añadir + Listo + Añadir + Añadida + + + Cargando álbum... + + + Añadir a tu playlist + Buscar + Busca en tus playlists... + Cargando tus playlists... + Aún no tienes ninguna playlist.\nCrea una para añadir canciones. + Crear playlist + Editar playlist + Crear + Nombre + Nombre de la playlist + Descripción + Descripción (opcional) + Pública + Cualquiera puede ver esta playlist + Colaborativa + Otras personas pueden añadir canciones a esta playlist + Imagen de portada + Portada de la playlist + Sin imagen + Cambiar imagen + Elegir imagen + + + Instalar + Reemplazar + No se encontró plugin.json en el archivo zip + No se encontró manifest.zipline.json en el archivo zip + Formato de plugin.json no válido: %1$s + No se pudo leer el plugin: %1$s + No se pudo instalar el plugin: %1$s + Hay una versión más reciente de %1$s. Confirma la actualización antes de instalarla. + %1$s ya está instalado. Confirma que quieres reemplazarlo antes de instalarlo. + Los plugins integrados ya están incluidos y no se pueden volver a añadir. + Los plugins integrados no se pueden quitar. + %1$s declara una apiVersion no válida (%2$s). Se esperaba un versionado semántico compatible con %3$s. + La versión %1$s de la API de plugins de Spotube no es válida. + %1$s está diseñado para la API de plugins %2$s, pero Spotube es compatible con la API de plugins %3$s, que incluye cambios incompatibles. Instala un plugin creado para la misma versión principal de la API. + + + API de plugin no compatible + ¿Instalar plugin? + %1$s se añadirá a tus plugins instalados. + ¿Actualizar plugin? + Hay una versión anterior instalada (%1$s). ¿Actualizar a %2$s? + ¿Reemplazar el plugin instalado? + %1$s usa una versión semántica no válida (%2$s) y no se puede comparar como actualización. ¿Reemplazar el plugin instalado de todos modos? + La versión instalada (%1$s) no se puede comparar con el versionado semántico. ¿Reemplazarla por %2$s? + La versión %1$s ya está instalada. ¿Reemplazar el plugin existente por la copia proporcionada? + La versión instalada %1$s es más reciente que la versión proporcionada %2$s. ¿Reemplazarla de todos modos? + ¿Reemplazar el plugin instalado por la copia proporcionada? + + + %1$s: + Versión + Versión de la API + Autor + Licencia + Capacidades + Funciones + Repositorio + Contacto + Informar de errores + Apoyar a %1$s + + + Oficial + Verificado + Estrellas en GitHub + URL del repositorio de GitHub + + + No se encontraron versiones + Seleccionar versión + Selecciona una versión + Versión preliminar + Notas de la versión + + + Inicio + Buscar + En el dispositivo + Connect + Dispositivos + + + Ocultar ventana + Mostrar ventana + Siguiente canción + Canción anterior + Aleatorio + Repetir + Repetir: NO + Repetir: UNA + Repetir: TODO + Volumen: %1$d% + Volumen + + Volumen - + Silenciar + Activar sonido + Me gusta + Ya no me gusta + Salir + + + Minimizar + Maximizar + Restaurar + Cerrar + + + Reproducción + Controles de reproducción multimedia + + + Explorar + Reproduciendo ahora + Destacados + Canciones guardadas + Canciones populares + diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml new file mode 100644 index 00000000..8a5659ed --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml @@ -0,0 +1,926 @@ + + + + صف پخش + پاک کردن صف + فیلتر صف... + صف پخش خالی است + + جستجوی آهنگ‌ها، هنرمندان، آلبوم‌ها... + پاک کردن کل تاریخچه + جستجو... + + + کتابخانهٔ شما + کتابخانه + پلی‌لیست‌ها + آلبوم‌ها + هنرمندان + آهنگ‌های محلی + دانلودها + جستجو در پلی‌لیست‌های ذخیره‌شده… + جستجو در آلبوم‌های ذخیره‌شده… + جستجو در هنرمندان ذخیره‌شده… + جستجوی پوشه‌ها یا آهنگ‌ها… + فیلتر کتابخانه + بستن فیلتر + ساخت پلی‌لیست + به‌روزرسانی آهنگ‌های محلی + نمایش شبکه‌ای + نمایش فهرستی + هیچ پلی‌لیست ذخیره‌شده‌ای ندارید + هیچ پلی‌لیستی با «%1$s» مطابقت ندارد + هیچ آلبوم ذخیره‌شده‌ای ندارید + هیچ آلبومی با «%1$s» مطابقت ندارد + هیچ هنرمند ذخیره‌شده‌ای ندارید + هیچ هنرمندی با «%1$s» مطابقت ندارد + + + پوشه‌ها + پوشه + %1$s • %2$s + + %1$d آهنگ + %1$d آهنگ + + + %1$d پوشه + %1$d پوشه + + بازگشت به پوشه‌ها + پخش پوشه + دسترسی به صدا لازم است + برای مرور موسیقی‌های دستگاهتان، اجازهٔ دسترسی به فایل‌های صوتی را بدهید. + اعطای دسترسی + هیچ پوشهٔ محلی‌ای پیدا نشد + این پوشه آهنگی ندارد + هیچ پوشه یا آهنگ مطابقی پیدا نشد + به‌روزرسانی رسانه‌های محلی ناموفق بود + + + %1$d در حال انجام • %2$d تکمیل‌شده + پاک کردن تکمیل‌شده‌ها + هنوز دانلودی ندارید + در حال دانلود %1$d% + در صف + تکمیل شد + لغو شد + ناموفق + ناموفق: %1$s + لغو دانلود + تلاش دوباره برای دانلود + حذف دانلود + + + هنرمند + در فهرست سیاه + آهنگ‌های پسندیده + آهنگ‌های ذخیره‌شدهٔ شما + هنرمند ناشناس + عنوان آهنگ + نام هنرمند + + انتخاب‌شده + + تنظیمات + + افزونه‌ها + زبان و منطقه + ظاهر + پخش + دانلودها + حافظهٔ پنهان + دسکتاپ + به‌روزرسانی‌ها + حمایت + درباره + + بستن + ذخیره + لغو + + زبان + زبان رابط کاربری برنامه. فعلی: %1$s + پیش‌فرض سیستم + کشور + کشور منطقه برای محتوا و دسترس‌پذیری. فعلی: %1$s + %1$s (%2$s) + + پوسته + انتخاب کنید Spotube چگونه با ظاهر سیستم شما هماهنگ شود. فعلی: %1$s + روشن + تیره + سیستم + + سطح + جنس پس‌زمینهٔ برنامه. فعلی: %1$s + آبی‌گون + یخی + کروم + آکریلیک + شیشه + + رنگ تأکیدی + پالت فعلی: %1$s + رنگ تأکیدی + یک پالت رنگ تأکیدی انتخاب کنید و پیش‌نمایش آن را در پوستهٔ روشن و تیره ببینید. + روشن + تیره + پیش‌نمایش + دکمه + گابلین سبز + بنفش الکتریکی + فیروزه‌ای اقیانوسی + نارنجی غروب + باغ رز + آبی نیمه‌شب + خاکستری فلزی + + ظاهر و حس + جنس سطح و رنگ تأکیدی. فعلی: %1$s · %2$s + ظاهر و حس + یک جنس سطح انتخاب کنید، سپس رنگ تأکیدی‌ای که با آن جور باشد. هر گزینه ترکیبی دست‌چین‌شده است. + سطح + رنگ تأکیدی + ثانویه + + پالت + پالت رنگ تأکیدی Millennium. فعلی: %1$s + پالت + یک پالت رنگ تأکیدی Millennium انتخاب کنید و پیش‌نمایش آن را در حالت روشن و تیره ببینید. + پالت + پالت Millennium و رنگ تأکیدی. فعلی: %1$s + آبی + نارنجی + لیمویی + + پوشهٔ دانلود سفارشی + پوشهٔ فعلی: %1$s + برای استفاده از پوشهٔ پیش‌فرض موسیقی، این قسمت را خالی بگذارید. + C:\Music\Spotube + پوشه‌های رسانهٔ محلی + پوشه‌های سفارشی برای اسکن در دسکتاپ. افزوده‌شده: %1$d + هنوز پوشهٔ سفارشی‌ای اضافه نشده است + پوشه‌های سفارشی برای کتابخانهٔ موسیقی محلی شما. + مدیریت + افزودن پوشه + حذف پوشه + هیچ پوشهٔ سفارشی‌ای اضافه نشده است. + حذف %1$s از فهرست اسکن + پوشه‌های دانلود و حافظهٔ پنهان موسیقی به‌طور خودکار اضافه می‌شوند. + پوشهٔ دانلود شما به‌طور خودکار اضافه می‌شود. + پوشهٔ حافظهٔ پنهان موسیقی به‌طور خودکار اضافه می‌شود. + در حال ذخیره… + ذخیرهٔ پوشه‌ها ممکن نشد. تغییرات شما هنوز اینجاست؛ دوباره تلاش کنید. + + %1$d پوشه + %1$d پوشه + + قالب دانلود موسیقی + کدک و قالب ترجیحی برای دانلودها. فعلی: %1$s + کیفیت دانلود موسیقی + + قالب پخش آنلاین موسیقی + کدک و قالب ترجیحی برای پخش آنلاین موسیقی. فعلی: %1$s + کیفیت پخش آنلاین موسیقی + فعلی: %1$s + فعال‌سازی حافظهٔ پنهان موسیقی + صدای پخش‌شده را به‌صورت محلی ذخیره کنید تا پخش دوباره سریع‌تر شود. + پوشهٔ حافظهٔ پنهان + فعلی: %1$s + فعلی: حافظهٔ پنهان پیش‌فرض برنامه + محدودیت حجم حافظهٔ پنهان + بدون محدودیت + محدودیت فعلی: %1$d MB + حداکثر حجم حافظهٔ پنهان را به MB تعیین کنید. برای نامحدود، 0 وارد کنید یا خالی بگذارید. + 500 + حجم باید 0 یا یک عدد مثبت باشد. + فعال‌سازی پخش بی‌پایان + وقتی صف پخش تمام شد، پخش با آهنگ‌های پیشنهادی ادامه پیدا کند. + درگاه سرور پراکسی پخش + اجازهٔ کنترل از راه دور + به دستگاه‌های دیگر در همان شبکه اجازه دهید هنگام اتصال، پخش را کنترل کنند. + نام دستگاه برای کنترل از راه دور + نامی که دستگاه‌های دیگر هنگام یافتن این دستگاه می‌بینند. فعلی: %1$s + یک نام دوستانه انتخاب کنید تا دستگاه‌های دیگر شبکه هنگام یافتن این نسخه از Spotube آن را ببینند. + مثلاً Spotube اتاق نشیمن + نام میزبان دستگاه + درگاه مورد استفادهٔ سرور پراکسی پخش. فعلی: %1$d + درگاهی بین 1 و 65535 انتخاب کنید. + 14769 + یک عدد صحیح معتبر وارد کنید. + درگاه باید بین 1 و 65535 باشد. + + فهرست سیاه + مدیریت آهنگ‌ها و هنرمندان فهرست سیاه + + کوچک‌سازی به سینی سیستم + وقتی پنجره کوچک می‌شود، Spotube در سینی سیستم در حال اجرا بماند. + وضعیت Discord + وضعیت پخش فعلی خود را در Discord روی دسکتاپ نمایش دهید. + + بررسی خودکار به‌روزرسانی‌ها + نسخه‌های جدید Spotube را در پس‌زمینه بررسی کنید. + + مدیریت افزونه‌ها + افزونه‌های خود را مدیریت کنید + افزونهٔ پیش‌فرض %1$s + فراداده + صدا + متن آهنگ + اسکراب + هیچ افزونه‌ای انتخاب نشده است + تغییر + انتخاب + انتخاب‌شده + پاک کردن انتخاب + هیچ افزونه‌ای در دسترس نیست + افزونهٔ %1$s + پاک کردن + + پیکربندی + مدیریت افزونه‌ها + لطفاً یک URL وارد کنید + URL باید با http:// یا https:// شروع شود + دانلود افزونه ناموفق بود + هنوز افزونه‌ای نصب نشده است + یک URL دانلود وارد کنید یا از بالا فایلی انتخاب کنید. + + %1$d افزونه نصب‌شده + %1$d افزونه نصب‌شده + + نصب افزونه + https://example.com/plugin.smplug + دانلود + نصب از فایل (.smplug) + فعال + v%1$s + داخلی + حذف افزونه + ورود + خروج + دانلود از URL + نصب از فایل + نصب + + • %1$s + مجوزهای درخواستی + این افزونه به مجوز خاصی نیاز ندارد. + نصب‌شده در برابر ارائه‌شده + نصب‌شده + ارائه‌شده + API %1$s → %2$s + ذخیره‌سازی دائمی + خواندن و نوشتن داده روی دیسک + درخواست‌های شبکه + ارسال و دریافت داده از طریق اینترنت + WebView + نمایش محتوای وب درون برنامه + جم گروهی + بروکر MQTT + میزبان بروکر + %1$s:%2$d + درگاه بروکر + استفاده از TLS + نام کاربری (اختیاری) + گذرواژه (اختیاری) + پیشوند شناسهٔ کلاینت + برای شناسایی این نسخه از Spotube استفاده می‌شود + رمزگذاری اتصال به بروکر + پیش از شروع اتاق، تنظیمات فعلی بروکر را بررسی کنید. + آزمایش اتصال + در حال آزمایش… + در %1$d میلی‌ثانیه متصل شد + ناموفق: %1$s + بروکر نمونه — برای میزبانی شخصی، بروکر خودتان را پیکربندی کنید + اتصال + اطلاعات ورود + هویت + عیب‌یابی + + + جم گروهی + ساختن + پیوستن + یک اتاق بسازید و کد آن را با دوستانتان به اشتراک بگذارید. + ساخت اتاق + پیوستن به اتاق + در حال اتصال… + کد اتاق + ABC123 + بروکر: %1$s + برای میزبانی یا پیوستن به اتاق، یک بروکر در تنظیمات راه‌اندازی کنید. + تنظیمات بروکر + متصل + در حال اتصال دوباره… + کپی کد + کپی شد! + اشتراک‌گذاری + شرکت‌کنندگان (%1$d) + میزبان + شنونده + در انتظار شنوندگان… + حذف + مسدود کردن + مدیریت + ترک اتاق + اتصال برقرار نشد + بستن + ساخت جلسهٔ جم ناموفق بود + پیوستن به جلسهٔ جم ناموفق بود + یک کد اتاق شش‌نویسه‌ای شامل حروف و اعداد وارد کنید. + با این کد به جم Spotube من بپیوندید: %1$s + + رفتن به آلبوم + زمان‌سنج خواب + جزئیات آهنگ + پخش + توقف + چیزی در حال پخش نیست + افزودن به صف + + نادیده گرفتن + به‌روزرسانی + Spotube %1$s در دسترس است + تازه‌های این نسخه را در صفحهٔ دانلود Spotube ببینید. + %1$s %2$s در دسترس است + نسخهٔ نصب‌شدهٔ فعلی: %1$s + تازه‌های این به‌روزرسانی را در صفحهٔ افزونه ببینید. + + رد شدن + بعدی + بازگشت + شروع کنید + Spotube + موسیقی، بی‌هیچ قید و بندی. + پخش‌کننده‌ای متن‌باز برای دنیایی از موسیقی.\nبرای کشف کردن. برای از آنِ خود کردن. + این پروژه را زنده نگه دارید. + Spotube را آدم‌ها ساخته‌اند، نه یک کسب‌وکار اشتراکی. اگر بخشی از زندگی روزمره‌تان شده، کمی حمایت به ما کمک می‌کند آن‌طور که شایسته است از آن مراقبت کنیم.\n\nسپاس که برای نرم‌افزار مستقل جا باز می‌کنید. + با یک افزونهٔ کاتالوگ شروع کنید تا آهنگ‌ها و پلی‌لیست‌ها را پیدا کنید. یک افزونهٔ صوتی برای پخش آن‌ها انتخاب کنید. متن آهنگ و تاریخچهٔ شنیدن اختیاری‌اند. افزونه‌ای را در پایین پیدا کنید، مجوزهایش را بررسی کنید، نصبش کنید و سپس آن را اینجا انتخاب کنید. + کشف افزونه‌ها + منابع شما + تلاش دوباره + افزونه‌های بیشتر + افزونهٔ جدیدی پیدا نشد. می‌توانید از طریق پیوند یا فایل .smplug در بالا هم افزونه نصب کنید. + کشف افزونه‌ها در حال حاضر در دسترس نیست. دوباره تلاش کنید یا از پیوند یا فایل نصب کنید. + خوش آمدید + حمایت + افزونه‌ها + راهنما + + نحوهٔ استفاده از Spotube + چهار گام سریع و مشغول شنیدن هستید. + ورود + حساب Spotify خود را متصل کنید یا افزونهٔ کاتالوگ دلخواهتان را انتخاب کنید. + جستجو و پخش + هر آهنگ، آلبوم یا هنرمندی را پیدا کنید و پخش را بزنید. + گوش دادن آفلاین + آهنگ‌ها را دانلود کنید تا بدون اینترنت هم پخش شوند. + همراهی با متن آهنگ + هنگام پخش آهنگ، متن همگام‌شده را از پخش‌کننده باز کنید. + + + موسیقی شما. منابع شما. قوانین شما. + پخش‌کننده‌ای رایگان و متن‌باز، ساختهٔ جامعه‌ای که فقط عاشق موسیقی است. + متن‌باز + بدون تبلیغات + بدون ردیابی + مبتنی بر افزونه + برویم + + + حامیان ما را سرپا نگه می‌دارند + ساخته‌شده با عشق. زنده به لطف شما. + Spotube صددرصد رایگان، آزاد و متن‌باز است — بدون تبلیغات، بدون اشتراک، بدون ردیابی. هر قابلیتی وجود دارد چون کسانی مثل شما تصمیم گرفتند به ادامهٔ توسعه کمک کنند. + Patreon + حامی ماهانه + از توسعه و زیرساخت مستمر حمایت کنید + Buy Me a Coffee + یک قهوهٔ یک‌باره + با یک انعام کوچک، به یک جلسهٔ کدنویسی شبانه انرژی بدهید + Open Collective + صندوق جامعه + پشتیبانی مالی شفاف و تحت مدیریت جامعه + از صمیم قلب سپاسگزاریم که به موسیقی آزاد باور دارید. + حمایت کاملاً اختیاری است — Spotube در هر صورت رایگان می‌ماند. + حمایت + باز کردن %1$s + + + حمایت از Spotube + حمایت از Spotube + Spotube رایگان، آزاد و متن‌باز است — بدون تبلیغات، بدون اشتراک، بدون ردیابی. اگر برایتان شادی می‌آورد، حمایت از پروژه را در نظر بگیرید. + می‌توانید این یادآوری‌ها را در تنظیمات → حمایت خاموش کنید. + شاید بعداً + یادآوری‌های حمایت + هر ۷ روز یک یادآوری ملایم برای حمایت از پروژه نمایش داده شود. + + + معماری ماژولار + انتخاب کنید موسیقی‌تان از کجا بیاید. + Spotube کشف کاتالوگ، پخش صدا و متن همگام‌شده را از هم جدا می‌کند. موتورهای خود را در پایین پیکربندی کنید یا هر زمان عوضشان کنید. + موتور فراداده + کشف موسیقی، جستجو، فهرست آهنگ‌ها و پلی‌لیست‌ها را ممکن می‌کند. + موتور صدا + جریان‌های صوتی در حال پخش را دریافت و رمزگشایی می‌کند. + موتور متن آهنگ + متن کارائوکهٔ همگام با زمان را به‌صورت زنده نمایش می‌دهد. + موتور اسکراب + تاریخچهٔ شنیدن را در Last.fm یا ListenBrainz ثبت می‌کند. + فعال + داخلی + آماده + اختیاری + یک موتور انتخاب کنید + موتورهای نصب‌شده + تنظیم به‌عنوان پیش‌فرض + NewPipe YouTube و LRCLib Lyrics داخلی هستند و از همان ابتدا آمادهٔ پخش‌اند. + هر زمان در تنظیمات → افزونه‌ها، افزونه‌های جامعه را مدیریت یا اضافه کنید. + + + معماری رترو-مدرن + Hi-Fi خالص، بدون واسطه + این‌گونه Spotube پخش غیرمتمرکز را مستقیماً روی دستگاه شما هماهنگ می‌کند. + نظیربه‌نظیر مستقیم • 256 KBPS OPUS + ساعت آبی + Spotube · نمایش پخش‌کننده + کمی موسیقی برای لحظه‌های میان‌راه. + ۱. کشف غیرمتمرکز + جستجوها مستقیماً به افزونهٔ کاتالوگ انتخابی شما می‌روند. Spotube هرگز از سلیقهٔ شما نمایه نمی‌سازد. + ۲. دریافت مستقیم جریان + صدا مستقیماً از سرورهای محتوا به دستگاه شما می‌رسد. بدون پراکسی میانی یا جمع‌آوری داده از راه دور. + ۳. پردازش صدا و پخش محلی + رمزگشایی و ذخیرهٔ محلی، همراه با اکولایزر و دانلود آفلاین اختیاری. + ۴. متن همگام و جم + متن کارائوکه با زمان پخش همگام می‌شود، همراه با کنترل از راه دور در LAN و جم گروهی. + بدون سرور واسطه. پخش درست روی دستگاه شما انجام می‌شود — خصوصی از پایه. + + + یک دستگاه انتخاب کنید + %1$s: «%2$s» + پخش + افزودن به صف + پخش بعدی + همین دستگاه + روی همین دستگاه + دستگاه راه دور + دستگاه راه دور (%1$s) + روی دستگاه متصل + + + اتصال ناموفق بود + اتصال قطع شد + ارسال ناموفق بود + یافتن دستگاه‌ها ناموفق بود: %1$s + اتصال ناموفق بود: %1$s + + + دستگاه‌ها + به‌روزرسانی + قطع اتصال + در حال اتصال... + در حال جستجوی دستگاه‌ها در شبکه... + هیچ دستگاهی پیدا نشد + مطمئن شوید گزینهٔ \"اجازهٔ کنترل از راه دور\" در تنظیمات دستگاه دیگر فعال است. + دستگاه ناشناس + در حال شناسایی... + + + کنترل از راه دور + قطع شد + خطای اتصال: %1$s + آهنگ ناشناس + هنرمند ناشناس + صدا + حذف از صف + گزینه‌های بیشتر + + + کجا پخش شود؟ + یک جلسهٔ جم فعال دارید. انتخاب کنید این مجموعه کجا پخش شود. + پخش در اینجا + پیشنهاد به جم + + + هیچ بروکر جمی پیکربندی نشده است + میزبان بروکر خالی است + اتصال ناموفق بود + اتصال قطع شد + + + بروکر + ناشناس + + + به صف جم اضافه شد + + %1$d آهنگ به صف جم اضافه شد + %1$d آهنگ به صف جم اضافه شد + + + + درخواست کنترل از راه دور + \"%1$s\" می‌خواهد پخش را روی این دستگاه کنترل کند. + رد کردن + اجازه دادن + همیشه اجازه بده + + + نتایج جستجو + همه + آهنگ‌ها + کاربران + هیچ نوع جستجویی در دسترس نیست + برای جستجو شروع به تایپ کنید + برای جستجوی آهنگ‌ها شروع به تایپ کنید + نتیجه‌ای پیدا نشد + مشاهدهٔ همه + در حال جستجو + در حال بارگیری آهنگ‌ها... + در حال بارگیری آلبوم‌ها... + در حال بارگیری هنرمندان... + در حال بارگیری پلی‌لیست‌ها... + در حال بارگیری کاربران... + هیچ آلبومی پیدا نشد + هیچ هنرمندی پیدا نشد + هیچ پلی‌لیستی پیدا نشد + هیچ کاربری پیدا نشد + در حال گشتن میان صفحه‌ها... + در حال جستجوی کیهان... + در حال گرم کردن بلندگوها... + در حال گردگیری صفحه‌های گرامافون... + در حال مشورت با خدایان موسیقی... + در حال تنظیم آنتن‌ها... + در حال ورق زدن آلبوم‌ها... + بارگیری قابلیت‌های جستجو ناموفق بود + جستجو ناموفق بود + جستجوی آهنگ‌ها ناموفق بود + جستجوی آلبوم‌ها ناموفق بود + جستجوی هنرمندان ناموفق بود + جستجوی پلی‌لیست‌ها ناموفق بود + جستجوی کاربران ناموفق بود + + + مرور + + + همگام + ساده + متن همگام‌شده‌ای در دسترس نیست + متن ساده‌ای در دسترس نیست + + + %1$s در %2$s + %1$d kbps + %1$s kHz • %2$d کانال + هیچ افزونهٔ فراداده‌ای انتخاب نشده است + + + بازگشت + تلاش دوباره + بستن پنجره + در حال بارگیری... + ناشناس + خطای ناشناخته + + + کاربر + پلی‌لیست + + + واقعاً متأسفیم + در حال حاضر چیزی درست کار نمی‌کند. تجربهٔ شما برای ما مهم است و تمام تلاشمان را برای رفع آن می‌کنیم. لطفاً دوباره تلاش کنید یا جزئیات خطا را در پایین بررسی کنید. + جزئیات خطا + کپی در کلیپ‌بورد + مشاهدهٔ گزارش کامل + گزارش کامل خطا + + + پخش تصادفی + پخش تصادفی + دنبال کردن + لغو دنبال کردن + ویرایش پلی‌لیست + از %1$s + + + گزینه‌های آهنگ + افزودن به جم + شروع رادیو + پخش بعدی + انتقال به بعدی + حذف از صف + حذف از علاقه‌مندی‌ها + افزودن به علاقه‌مندی‌ها + افزودن به پلی‌لیست + دانلود + اشتراک‌گذاری + + + اصلی + عنوان + هنرمند + آلبوم + مدت + مرتب‌سازی + هیچ آهنگی پیدا نشد + فیلتر + کارهای گروهی + دانلود همه + دانلود %1$d + افزودن همه به صف + افزودن %1$d به صف + پخش همه در ادامه + پخش %1$d در ادامه + افزودن همه به پلی‌لیست + افزودن %1$d به پلی‌لیست + افزودن همه به جم + افزودن %1$d به جم + +%1$d دیگر + + + URL برای بارگیری وجود ندارد + بازگشت در تاریخچهٔ مرورگر + رفتن به جلو در تاریخچهٔ مرورگر + + + فیلتر فهرست سیاه... + هیچ موردی در فهرست سیاه نیست + هیچ مورد فهرست سیاهی با «%1$s» مطابقت ندارد + آهنگ‌ها + آهنگ + افزودن به فهرست سیاه + حذف از فهرست سیاه + + + فرادادهٔ آهنگ موجود نیست + URL جریان پیدا نشد + URL جریان پیدا نشد. ابتدا آهنگ را پخش کنید. + + + آهنگ‌های ذخیره‌شده + شما + + + دنبال می‌کنید + آهنگ‌های برتر + پخش آهنگ‌های برتر + افزودن آهنگ‌های برتر به صف + هنرمندان مرتبط + پلی‌لیست‌های ویژه + بارگیری نمای کلی هنرمند ناموفق بود + تعداد دنبال‌کنندگان در دسترس نیست + %1$sK دنبال‌کننده + %1$sM دنبال‌کننده + %1$sB دنبال‌کننده + + %1$d دنبال‌کننده + %1$d دنبال‌کننده + + + + پسندیدن + لغو پسند + عنوان آهنگ خالی + هنرمندان خالی + آلبوم ناشناس + جابه‌جایی + صدا + فعال کردن پخش تصادفی + غیرفعال کردن پخش تصادفی + قبلی + بعدی + پخش یا توقف + تکرار خاموش + تکرار یک آهنگ + تکرار همه + بی‌صدا + باصدا + گزینه‌های بیشتر + گزینه‌های پخش‌کننده + جمع کردن پخش‌کننده + باز کردن متن آهنگ + در حال پخش + متن آهنگ + منبع جایگزین + منبع جایگزین + دانلود + دانلود شد + در حال دانلود... + قبلاً دانلود شده است + تلاش دوباره برای دانلود... + در حال دانلود %1$s + پخش تا %1$s دیگر متوقف می‌شود. + پخش پس از تعداد دقیقهٔ انتخاب‌شده متوقف شود. + دقیقه + عددی بین 1 تا 600 دقیقه انتخاب کنید + لغو زمان‌سنج + شروع + URL پخش با تنظیمات پخش آنلاین متفاوتی به دست آمده است. جریان برجسته‌شده با ترجیح فعلی شما مطابقت دارد؛ URL فعال در درخواست بعدی جریان به‌روز می‌شود. + منبع صوتی منطبق + در حال بارگیری منبع منطبق… + هنگام پخش فایل ذخیره‌شدهٔ محلی، به منبع راه دور منطبق نیازی نیست. + هیچ منبع منطبقی در دسترس نیست. + هیچ منبع صوتی منطبقی برای این آهنگ در حافظهٔ پنهان نیست. + جزئیات جریان هنوز در حافظهٔ پنهان نیست. برای بارگیری آن‌ها ابتدا آهنگ را پخش کنید. + بارگیری جزئیات آهنگ ناموفق بود. + جریان‌های در دسترس + این آهنگ از حافظهٔ پنهان محلی پخش می‌شود. نسخه‌های جریان برای این پخش استفاده نمی‌شوند. + در حال بارگیری جریان‌ها… + هیچ جریانی برای این منبع در دسترس نیست. + جریان انتخاب‌شدهٔ فعلی + مطابق با قالب و کیفیت پخش آنلاین ترجیحی شما + URL پخش فعال + پخش از حافظهٔ پنهان محلی + آهنگ فراداده + عنوان + شناسه + آلبوم + اطمینان + URI خارجی + شناسهٔ آلبوم + نوع آلبوم + تاریخ انتشار آلبوم + تعداد آهنگ‌های آلبوم + ژانرهای آلبوم + مدت + شمارهٔ آهنگ + شمارهٔ دیسک + صریح + محبوبیت + ISRC + URLهای تصویر بندانگشتی + + + نمایش/پنهان کردن نوار کناری + + + منابع جایگزین + هیچ آهنگی در حال پخش نیست + در حال جستجوی منابع جایگزین... + هیچ منبع جایگزینی پیدا نشد + اطمینان: %1$d% + منبع فعال + + + بیرون کردن + او را از جلسه حذف کنید + بیرون کردن و جلوگیری از پیوستن دوباره + حذف پیشنهادها + حذف همهٔ آهنگ‌هایی که او به صف اضافه کرده است + تغییر ترتیب + حذف از صف + %1$s • افزوده‌شده توسط %2$s + + + ناشناس + در حال بارگیری پلی‌لیست... + افزودن آهنگ + افزودن آهنگ به پلی‌لیست + جستجوی آهنگ‌ها... + جستجو + هیچ آهنگی پیدا نشد + آهنگ‌هایی را برای افزودن جستجو کنید + تمام + افزودن + اضافه شد + + + در حال بارگیری آلبوم... + + + افزودن به پلی‌لیست شما + جستجو + جستجو در پلی‌لیست‌های شما... + در حال بارگیری پلی‌لیست‌های شما... + هنوز پلی‌لیستی ندارید.\nبرای افزودن آهنگ، یکی بسازید. + ساخت پلی‌لیست + ویرایش پلی‌لیست + ساختن + نام + نام پلی‌لیست + توضیحات + توضیحات (اختیاری) + عمومی + همه می‌توانند این پلی‌لیست را ببینند + مشارکتی + دیگران می‌توانند به این پلی‌لیست آهنگ اضافه کنند + تصویر کاور + کاور پلی‌لیست + بدون تصویر + تغییر تصویر + انتخاب تصویر + + + نصب + جایگزینی + plugin.json در فایل zip پیدا نشد + manifest.zipline.json در فایل zip پیدا نشد + قالب plugin.json نامعتبر است: %1$s + خواندن افزونه ناموفق بود: %1$s + نصب افزونه ناموفق بود: %1$s + نسخهٔ جدیدتری از %1$s در دسترس است. پیش از نصب، به‌روزرسانی را تأیید کنید. + %1$s از قبل نصب شده است. پیش از نصب، جایگزینی آن را تأیید کنید. + افزونه‌های داخلی از قبل وجود دارند و نمی‌توان دوباره اضافه‌شان کرد. + افزونه‌های داخلی را نمی‌توان حذف کرد. + %1$s یک apiVersion نامعتبر (%2$s) اعلام کرده است. نسخه‌بندی معنایی سازگار با %3$s انتظار می‌رود. + نسخهٔ API افزونهٔ Spotube (%1$s) نامعتبر است. + %1$s برای API افزونهٔ %2$s ساخته شده، اما Spotube از نسخهٔ اصلی API افزونهٔ %3$s پشتیبانی می‌کند. افزونه‌ای نصب کنید که برای همان نسخهٔ اصلی API ساخته شده باشد. + + + API افزونه سازگار نیست + افزونه نصب شود؟ + %1$s به افزونه‌های نصب‌شدهٔ شما اضافه می‌شود. + افزونه به‌روز شود؟ + نسخهٔ قدیمی‌تری نصب است (%1$s). به %2$s به‌روز شود؟ + افزونهٔ نصب‌شده جایگزین شود؟ + %1$s از یک نسخهٔ معنایی نامعتبر (%2$s) استفاده می‌کند و نمی‌توان آن را به‌عنوان به‌روزرسانی مقایسه کرد. با این حال افزونهٔ نصب‌شده جایگزین شود؟ + نسخهٔ نصب‌شده (%1$s) را نمی‌توان با نسخه‌بندی معنایی مقایسه کرد. با %2$s جایگزین شود؟ + نسخهٔ %1$s از قبل نصب است. افزونهٔ موجود با نسخهٔ ارائه‌شده جایگزین شود؟ + نسخهٔ نصب‌شدهٔ %1$s از نسخهٔ ارائه‌شدهٔ %2$s جدیدتر است. با این حال جایگزین شود؟ + افزونهٔ نصب‌شده با نسخهٔ ارائه‌شده جایگزین شود؟ + + + %1$s: + نسخه + نسخهٔ API + سازنده + مجوز + قابلیت‌ها + توانایی‌ها + مخزن + تماس + گزارش اشکال + حمایت از %1$s + + + رسمی + تأییدشده + ستاره‌های GitHub + URL مخزن GitHub + + + هیچ نسخه‌ای پیدا نشد + انتخاب نسخه + یک نسخه انتخاب کنید + پیش‌انتشار + یادداشت‌های انتشار + + + خانه + جستجو + روی دستگاه + اتصال + دستگاه‌ها + + + پنهان کردن پنجره + نمایش پنجره + آهنگ بعدی + آهنگ قبلی + پخش تصادفی + تکرار + تکرار: خاموش + تکرار: یک آهنگ + تکرار: همه + صدا: %1$d% + صدا + + صدا - + بی‌صدا + باصدا + پسندیدن آهنگ + لغو پسند آهنگ + خروج + + + کوچک کردن + بزرگ کردن + بازگرداندن + بستن + + + پخش + کنترل‌های پخش رسانه + + + مرور + در حال پخش + ویژه + آهنگ‌های ذخیره‌شده + آهنگ‌های برتر + diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml new file mode 100644 index 00000000..2737c4bc --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -0,0 +1,932 @@ + + + + File d\'attente + Vider la file d\'attente + Filtrer la file d\'attente... + La file d\'attente est vide + + Rechercher des titres, artistes, albums... + Effacer tout l\'historique + Rechercher... + + + Bibliothèque + Bibliothèque + Playlists + Albums + Artistes + Titres locaux + Téléchargements + Rechercher dans les playlists enregistrées… + Rechercher dans les albums enregistrés… + Rechercher dans les artistes enregistrés… + Rechercher des dossiers ou des titres… + Filtrer la bibliothèque + Fermer le filtre + Créer une playlist + Actualiser les titres locaux + Afficher en grille + Afficher en liste + Aucune playlist enregistrée + Aucune playlist ne correspond à « %1$s » + Aucun album enregistré + Aucun album ne correspond à « %1$s » + Aucun artiste enregistré + Aucun artiste ne correspond à « %1$s » + + + Dossiers + Dossier + %1$s • %2$s + + %1$d titre + %1$d titres + %1$d titres + + + %1$d dossier + %1$d dossiers + %1$d dossiers + + Retour aux dossiers + Lire le dossier + Accès à l\'audio requis + Autorisez l\'accès à l\'audio pour parcourir la musique de votre appareil. + Autoriser l\'accès + Aucun dossier local trouvé + Aucun titre dans ce dossier + Aucun dossier ou titre correspondant + Échec de l\'actualisation des médias locaux + + + %1$d en cours • %2$d terminé(s) + Effacer les terminés + Aucun téléchargement pour l\'instant + Téléchargement %1$d% + En attente + Terminé + Annulé + Échec + Échec : %1$s + Annuler le téléchargement + Relancer le téléchargement + Supprimer le téléchargement + + + Artiste + Sur liste noire + Titres likés + Vos titres enregistrés + Artiste inconnu + Titre du morceau + Nom de l\'artiste + + Sélectionné + + Paramètres + + Plugins + Langue et région + Apparence + Lecture + Téléchargements + Cache + Bureau + Mises à jour + Soutien + À propos + + Fermer + Enregistrer + Annuler + + Langue + Langue de l\'interface. Actuelle : %1$s + Langue du système + Pays + Pays utilisé pour le contenu et la disponibilité. Actuel : %1$s + %1$s (%2$s) + + Thème + Choisissez comment Spotube s\'adapte à l\'apparence du système. Actuel : %1$s + Clair + Sombre + Système + + Surface + Matériau d\'arrière-plan de l\'application. Actuel : %1$s + Aqueux + Givré + Chrome + Acrylique + Verre + + Couleur d\'accentuation + Palette actuelle : %1$s + Couleur d\'accentuation + Choisissez une palette d\'accentuation et prévisualisez-la en thème clair et sombre. + Clair + Sombre + Aperçu + Bouton + Bouffon vert + Violet électrique + Cyan océan + Orange couchant + Roseraie + Bleu nuit + Ardoise métallique + + Style et rendu + Matériau de surface et accentuation. Actuel : %1$s · %2$s + Style et rendu + Choisissez un matériau de surface, puis une couleur d\'accentuation assortie. Chaque option est une combinaison soigneusement choisie. + Surface + Accentuation + Secondaire + + Palette + Palette d\'accentuation Millennium. Actuelle : %1$s + Palette + Choisissez une palette d\'accentuation Millennium et prévisualisez-la en clair et en sombre. + Palette + Palette Millennium et accentuation. Actuelle : %1$s + Bleu + Orange + Citron vert + + Dossier de téléchargement personnalisé + Dossier actuel : %1$s + Laissez vide pour utiliser le dossier Musique par défaut. + C:\Music\Spotube + Dossiers de médias locaux + Dossiers personnalisés à analyser sur ordinateur. Ajoutés : %1$d + Aucun dossier personnalisé ajouté + Dossiers personnalisés pour votre bibliothèque musicale locale. + Gérer + Ajouter un dossier + Supprimer le dossier + Aucun dossier personnalisé ajouté. + Retirer %1$s de la liste d\'analyse + Les dossiers de téléchargement et de cache musical sont inclus automatiquement. + Votre dossier de téléchargement est inclus automatiquement. + Le dossier de cache musical est inclus automatiquement. + Enregistrement… + Impossible d\'enregistrer les dossiers. Vos modifications sont conservées ; réessayez. + + %1$d dossier + %1$d dossiers + %1$d dossiers + + Format de téléchargement + Codec et conteneur préférés pour les téléchargements. Actuel : %1$s + Qualité de téléchargement + + Format de streaming + Codec et conteneur préférés pour le streaming. Actuel : %1$s + Qualité de streaming + Actuel : %1$s + Activer le cache musical + Stocker l\'audio diffusé en local pour accélérer les écoutes suivantes. + Dossier du cache + Actuel : %1$s + Actuel : cache par défaut de l\'application + Taille maximale du cache + Aucune limite + Limite actuelle : %1$d MB + Définissez la taille maximale du cache en MB. Saisissez 0 ou laissez vide pour aucune limite. + 500 + La taille doit être 0 ou un nombre positif. + Activer la lecture continue + Poursuivre la lecture avec des titres recommandés à la fin de la file d\'attente. + Port du serveur proxy de lecture + Autoriser le contrôle à distance + Permettre aux autres appareils du même réseau de contrôler la lecture lorsqu\'ils se connectent. + Nom de l\'appareil pour le contrôle à distance + Le nom que voient les autres appareils lorsqu\'ils détectent celui-ci. Actuel : %1$s + Choisissez un nom convivial à afficher aux autres appareils de votre réseau lorsqu\'ils détectent cette instance de Spotube. + p. ex. Spotube du salon + nom d\'hôte de l\'appareil + Port utilisé par le serveur proxy de lecture. Actuel : %1$d + Choisissez un port entre 1 et 65535. + 14769 + Saisissez un nombre entier valide. + Le port doit être compris entre 1 et 65535. + + Liste noire + Gérer les titres et artistes sur liste noire + + Réduire dans la zone de notification + Garder Spotube actif dans la zone de notification lorsque la fenêtre est réduite. + Statut enrichi Discord + Afficher votre lecture en cours dans Discord sur ordinateur. + + Rechercher automatiquement les mises à jour + Rechercher les nouvelles versions de Spotube en arrière-plan. + + Gérer les plugins + Gérez vos plugins + Plugin %1$s par défaut + Métadonnées + Audio + Paroles + Scrobbling + Aucun plugin sélectionné + Modifier + Sélectionner + Sélectionné + Effacer la sélection + Aucun plugin disponible + Plugin %1$s + Effacer + + Configurer + Gestionnaire de plugins + Veuillez saisir une URL + L\'URL doit commencer par http:// ou https:// + Échec du téléchargement du plugin + Aucun plugin installé pour l\'instant + Collez une URL de téléchargement ou choisissez un fichier ci-dessus. + + %1$d plugin installé + %1$d de plugins installés + %1$d plugins installés + + Installer un plugin + https://example.com/plugin.smplug + Télécharger + Installer depuis un fichier (.smplug) + Actif + v%1$s + Intégré + Supprimer le plugin + Se connecter + Se déconnecter + Télécharger depuis une URL + Installer depuis un fichier + Installer + + • %1$s + Autorisations demandées + Ce plugin ne nécessite aucune autorisation particulière. + Installé ou fourni + Installé + Fourni + API %1$s → %2$s + Stockage persistant + Lire et écrire des données sur le disque + Requêtes réseau + Envoyer et recevoir des données sur Internet + WebView + Afficher du contenu web dans l\'application + Jam de groupe + Broker MQTT + Hôte du broker + %1$s:%2$d + Port du broker + Utiliser TLS + Nom d\'utilisateur (facultatif) + Mot de passe (facultatif) + Préfixe de l\'ID client + Sert à identifier cette instance de Spotube + Chiffrer la connexion au broker + Vérifiez les paramètres actuels du broker avant de créer une salle. + Tester la connexion + Test en cours… + Connecté en %1$d ms + Échec : %1$s + Broker temporaire : configurez le vôtre pour l\'auto-héberger + Connexion + Identifiants + Identité + Diagnostic + + + Jam de groupe + Créer + Rejoindre + Créez une salle, puis partagez son code avec vos amis. + Créer une salle + Rejoindre la salle + Connexion… + Code de la salle + ABC123 + Broker : %1$s + Configurez un broker dans les Paramètres pour créer ou rejoindre une salle. + Paramètres du broker + Connecté + Reconnexion… + Copier le code + Copié ! + Partager + Participants (%1$d) + Hôte + Auditeur + En attente d\'auditeurs… + Retirer + Bannir + Gérer + Quitter la salle + Connexion impossible + Ignorer + Échec de la création de la session Jam + Impossible de rejoindre la session Jam + Saisissez un code de salle de six caractères (lettres et chiffres). + Rejoignez ma Jam Spotube avec le code : %1$s + + Accéder à l\'album + Minuteur de veille + Détails du titre + Lecture + Pause + Aucune lecture en cours + Ajouter à la file d\'attente + + Ignorer + Mettre à jour + Spotube %1$s est disponible + Découvrez les nouveautés de cette version sur la page de téléchargement de Spotube. + %1$s %2$s est disponible + Version installée : %1$s + Découvrez les nouveautés de cette mise à jour sur la page du plugin. + + Passer + Suivant + Retour + Commencer + Spotube + La musique, sans contraintes. + Un lecteur open source pour tout un monde de musique.\nÀ vous de l\'explorer. À vous de vous l\'approprier. + Faites vivre ce projet. + Spotube est créé par des personnes, pas par une entreprise d\'abonnements. S\'il fait partie de votre quotidien, un petit soutien nous aide à lui accorder l\'attention qu\'il mérite.\n\nMerci de faire une place aux logiciels indépendants. + Commencez par un plugin de catalogue pour trouver des titres et des playlists. Choisissez un plugin audio pour les écouter. Les paroles et l\'historique d\'écoute sont facultatifs. Découvrez un plugin ci-dessous, vérifiez ses autorisations, installez-le, puis sélectionnez-le ici. + Découvrir des plugins + Vos sources + Réessayer + Plus de plugins + Aucun nouveau plugin trouvé. Vous pouvez aussi en installer un depuis un lien ou un fichier .smplug ci-dessus. + La découverte de plugins est indisponible pour le moment. Réessayez, ou installez-en un depuis un lien ou un fichier. + Bienvenue + Soutien + Plugins + Guide + + Comment utiliser Spotube + Quatre étapes rapides et c\'est parti. + Se connecter + Connectez votre compte Spotify ou choisissez votre propre plugin de catalogue. + Rechercher et écouter + Trouvez n\'importe quel titre, album ou artiste, puis lancez la lecture. + Écouter hors connexion + Téléchargez des titres pour continuer à écouter sans connexion. + Suivre les paroles + Ouvrez les paroles synchronisées depuis le lecteur pendant la lecture d\'un titre. + + + Votre musique. Vos sources. Vos règles. + Un lecteur de musique gratuit et open source, créé par une communauté qui aime tout simplement la musique. + Open source + Sans publicité + Sans télémétrie + Basé sur des plugins + C\'est parti + + + Nos soutiens nous font avancer + Créé avec passion. Maintenu en vie grâce à vous. + Spotube est 100 % gratuit, libre et open source — sans publicité, sans abonnement, sans pistage. Chaque fonctionnalité existe parce que des personnes comme vous ont choisi d\'aider à faire avancer le code. + Patreon + Soutien mensuel + Soutenez le développement et l\'infrastructure dans la durée + Buy Me a Coffee + Un café ponctuel + Alimentez une session de code nocturne avec un petit pourboire + Open Collective + Fonds communautaire + Un financement transparent, géré par la communauté + Merci du fond du cœur de croire en la musique libre. + Le soutien est entièrement facultatif — Spotube reste gratuit dans tous les cas. + Soutenir + Ouvrir %1$s + + + Soutenir Spotube + Soutenir Spotube + Spotube est gratuit, libre et open source — sans publicité, sans abonnement, sans pistage. S\'il vous apporte de la joie, pensez à soutenir le projet. + Vous pouvez désactiver ces rappels dans Paramètres → Soutien. + Plus tard + Rappels de soutien + Afficher un petit rappel pour soutenir le projet tous les 7 jours. + + + Architecture modulaire + Choisissez d\'où vient votre musique. + Spotube sépare la découverte du catalogue, le streaming audio et les paroles synchronisées. Configurez vos moteurs ci-dessous ou changez-les à tout moment. + Moteur de métadonnées + Gère la découverte musicale, la recherche, les listes de titres et les playlists. + Moteur audio + Récupère et décode les flux audio lus. + Moteur de paroles + Diffuse des paroles karaoké synchronisées en temps réel. + Moteur de scrobbling + Enregistre l\'historique d\'écoute sur Last.fm ou ListenBrainz. + Actif + Intégré + Prêt + Facultatif + Sélectionnez un moteur + Moteurs installés + Définir par défaut + NewPipe YouTube et LRCLib Lyrics sont intégrés et prêts à l\'emploi. + Gérez ou ajoutez des plugins de la communauté à tout moment dans Paramètres → Plugins. + + + Architecture rétro-moderne + Hi-Fi pure, zéro intermédiaire + Voici comment Spotube orchestre une lecture décentralisée directement sur votre machine. + PAIR-À-PAIR DIRECT • OPUS 256 KBPS + L\'heure bleue + Spotube · démonstration du lecteur + Un peu de musique pour les moments entre deux. + 1. Découverte décentralisée + Les requêtes vont directement au plugin de catalogue choisi. Spotube ne profile jamais vos goûts. + 2. Résolution directe des flux + L\'audio est diffusé directement des serveurs de contenu vers votre machine. Aucun proxy ni collecte de données à distance. + 3. DSP et lecture en local + Décodage et mise en cache en local, avec égaliseur et téléchargement hors connexion facultatif. + 4. Paroles synchronisées et Jam + Les paroles karaoké suivent la position de lecture, avec contrôle à distance en LAN et Jam de groupe. + Aucun serveur intermédiaire. La lecture se fait directement sur votre appareil — confidentielle par conception. + + + Choisir un appareil + %1$s : « %2$s » + Lire + Ajouter à la file d\'attente + Lire ensuite + Cet appareil + Sur cet appareil + Appareil distant + Appareil distant (%1$s) + Sur l\'appareil connecté + + + Échec de la connexion + Connexion perdue + Échec de l\'envoi + Échec de la détection : %1$s + Échec de la connexion : %1$s + + + Appareils + Actualiser + Se déconnecter + Connexion... + Recherche d\'appareils sur le réseau... + Aucun appareil trouvé + Assurez-vous que l\'option \"Autoriser le contrôle à distance\" est activée dans les paramètres de l\'autre appareil. + Appareil inconnu + Résolution... + + + Contrôle à distance + Déconnecté + Erreur de connexion : %1$s + Titre inconnu + Artiste inconnu + Volume + Retirer de la file d\'attente + Plus d\'options + + + Où écouter ? + Une session Jam est en cours. Choisissez où lire cette collection. + Lire ici + Proposer à la Jam + + + Aucun broker Jam configuré + L\'hôte du broker est vide + Échec de la connexion + Connexion perdue + + + Broker + anonyme + + + Ajouté à la file d\'attente de la Jam + + %1$d titre ajouté à la file d\'attente de la Jam + %1$d titres ajoutés à la file d\'attente de la Jam + %1$d titres ajoutés à la file d\'attente de la Jam + + + + Demande de contrôle à distance + \"%1$s\" souhaite contrôler la lecture sur cet appareil. + Refuser + Autoriser + Toujours autoriser + + + Résultats de recherche + Tout + Titres + Utilisateurs + Aucun type de recherche disponible + Commencez à taper pour rechercher + Commencez à taper pour rechercher des titres + Aucun résultat + Tout afficher + Recherche en cours + Chargement des titres... + Chargement des albums... + Chargement des artistes... + Chargement des playlists... + Chargement des utilisateurs... + Aucun album trouvé + Aucun artiste trouvé + Aucune playlist trouvée + Aucun utilisateur trouvé + On fouille dans les bacs... + On explore le cosmos... + On fait chauffer les enceintes... + On dépoussière les vinyles... + On consulte les dieux de la musique... + On règle les antennes... + On feuillette les disques... + Impossible de charger les fonctions de recherche + Échec de la recherche + Échec de la recherche de titres + Échec de la recherche d\'albums + Échec de la recherche d\'artistes + Échec de la recherche de playlists + Échec de la recherche d\'utilisateurs + + + Parcourir + + + Synchronisées + Texte brut + Aucunes paroles synchronisées disponibles + Aucunes paroles disponibles + + + %1$s dans %2$s + %1$d kbps + %1$s kHz • %2$d canaux + Aucun plugin de métadonnées sélectionné + + + Retour + Réessayer + Fermer la boîte de dialogue + Chargement... + Inconnu + Erreur inconnue + + + Utilisateur + Playlist + + + Nous sommes vraiment désolés + Quelque chose ne fonctionne pas pour le moment. Votre expérience compte pour nous et nous faisons de notre mieux pour corriger le problème. Veuillez réessayer ou consulter les détails de l\'erreur ci-dessous. + Détails de l\'erreur + Copier dans le presse-papiers + Voir le journal complet + Journal d\'erreurs complet + + + Lecture aléatoire + Lecture aléatoire + Suivre + Ne plus suivre + Modifier la playlist + Par %1$s + + + Options du titre + Ajouter à la Jam + Lancer la radio + Lire ensuite + Placer en suivant + Retirer de la file d\'attente + Retirer des favoris + Ajouter aux favoris + Ajouter à une playlist + Télécharger + Partager + + + Ordre d\'origine + Titre + Artiste + Album + Durée + Trier + Aucun titre trouvé + Filtrer + Actions groupées + Tout télécharger + Télécharger %1$d + Tout ajouter à la file d\'attente + Ajouter %1$d à la file d\'attente + Tout lire ensuite + Lire %1$d ensuite + Tout ajouter à une playlist + Ajouter %1$d à une playlist + Tout ajouter à la Jam + Ajouter %1$d à la Jam + +%1$d autres + + + Aucune URL à charger + Page précédente + Page suivante + + + Filtrer la liste noire... + Aucun élément sur liste noire + Aucun élément sur liste noire ne correspond à « %1$s » + Titres + Titre + Ajouter à la liste noire + Retirer de la liste noire + + + Aucune métadonnée pour ce titre + Impossible de résoudre l\'URL du flux + Impossible de résoudre l\'URL du flux. Essayez d\'abord de lire le titre. + + + Titres enregistrés + Vous + + + Abonné + Titres populaires + Lire les titres populaires + Ajouter les titres populaires à la file d\'attente + Artistes similaires + Playlists à la une + Impossible de charger la présentation de l\'artiste + Abonnés indisponibles + %1$s k abonnés + %1$s M d\'abonnés + %1$s Md d\'abonnés + + %1$d abonné + %1$d d\'abonnés + %1$d abonnés + + + + J\'aime + Je n\'aime plus + Titre du morceau indisponible + Artistes indisponibles + Album inconnu + Position de lecture + Volume + Activer la lecture aléatoire + Désactiver la lecture aléatoire + Précédent + Suivant + Lecture ou pause + Répétition désactivée + Répéter le titre + Tout répéter + Couper le son + Réactiver le son + Plus d\'options + Options du lecteur + Réduire le lecteur + Agrandir les paroles + En cours de lecture + Paroles + Source alternative + Source alternative + Télécharger + Téléchargé + Téléchargement... + Déjà téléchargé + Nouvelle tentative de téléchargement... + Téléchargement de %1$s + La lecture sera mise en pause dans %1$s. + Mettre la lecture en pause après le nombre de minutes choisi. + Minutes + Entre 1 et 600 minutes + Annuler le minuteur + Démarrer + L\'URL de lecture a été résolue avec d\'autres paramètres de streaming. Le flux mis en évidence correspond à votre préférence actuelle ; l\'URL active sera actualisée à la prochaine demande de flux. + Source audio associée + Chargement de la source associée… + Aucune source distante associée n\'est nécessaire pendant la lecture du fichier en cache local. + Aucune source associée disponible. + Aucune source audio associée n\'est en cache pour ce titre. + Les détails du flux ne sont pas encore en cache. Lisez d\'abord le titre pour les charger. + Impossible de charger les détails du titre. + Flux disponibles + Ce titre est lu depuis le cache local. Les variantes de flux ne sont pas utilisées pour cette lecture. + Chargement des flux… + Aucun flux disponible pour cette source. + Flux actuellement sélectionné + Correspond à vos préférences de format et de qualité de streaming + URL de lecture active + Lu depuis le cache local + Titre (métadonnées) + Titre + ID + Album + Fiabilité + URI externe + ID de l\'album + Type d\'album + Date de sortie de l\'album + Nombre de titres de l\'album + Genres de l\'album + Durée + Numéro de piste + Numéro de disque + Explicite + Popularité + ISRC + URL des miniatures + + + Afficher/masquer la barre latérale + + + Sources alternatives + Aucun titre en cours de lecture + Recherche de sources alternatives... + Aucune source alternative trouvée + Fiabilité : %1$d% + Source active + + + Exclure + Le retirer de la session + L\'exclure et l\'empêcher de revenir + Retirer ses suggestions + Retirer tous les titres qu\'il a ajoutés à la file d\'attente + Réorganiser + Retirer de la file d\'attente + %1$s • Ajouté par %2$s + + + Inconnu + Chargement de la playlist... + Ajouter des titres + Ajouter des titres à la playlist + Rechercher des titres... + Rechercher + Aucun titre trouvé + Recherchez des titres à ajouter + OK + Ajouter + Ajouté + + + Chargement de l\'album... + + + Ajouter à une de vos playlists + Rechercher + Rechercher dans vos playlists... + Chargement de vos playlists... + Vous n\'avez encore aucune playlist.\nCréez-en une pour y ajouter des titres. + Créer une playlist + Modifier la playlist + Créer + Nom + Nom de la playlist + Description + Description (facultatif) + Publique + Tout le monde peut voir cette playlist + Collaborative + D\'autres personnes peuvent ajouter des titres à cette playlist + Image de couverture + Couverture de la playlist + Aucune image + Changer l\'image + Choisir une image + + + Installer + Remplacer + plugin.json introuvable dans le fichier zip + manifest.zipline.json introuvable dans le fichier zip + Format de plugin.json non valide : %1$s + Impossible de lire le plugin : %1$s + Impossible d\'installer le plugin : %1$s + Une version plus récente de %1$s est disponible. Confirmez la mise à jour avant l\'installation. + %1$s est déjà installé. Confirmez le remplacement avant l\'installation. + Les plugins intégrés sont déjà inclus et ne peuvent pas être ajoutés à nouveau. + Les plugins intégrés ne peuvent pas être supprimés. + %1$s déclare une apiVersion non valide (%2$s). Une version sémantique compatible avec %3$s est attendue. + La version %1$s de l\'API de plugins de Spotube n\'est pas valide. + %1$s cible l\'API de plugins %2$s, mais Spotube prend en charge la version majeure %3$s de l\'API de plugins. Installez un plugin conçu pour la même version majeure de l\'API. + + + API de plugin incompatible + Installer le plugin ? + %1$s sera ajouté à vos plugins installés. + Mettre à jour le plugin ? + Une version plus ancienne est installée (%1$s). Mettre à jour vers la version %2$s ? + Remplacer le plugin installé ? + %1$s utilise une version sémantique non valide (%2$s) et ne peut pas être comparé comme une mise à jour. Remplacer quand même le plugin installé ? + La version installée (%1$s) ne peut pas être comparée selon la gestion sémantique de version. La remplacer par %2$s ? + La version %1$s est déjà installée. Remplacer le plugin existant par la copie fournie ? + La version installée %1$s est plus récente que la version fournie %2$s. La remplacer quand même ? + Remplacer le plugin installé par la copie fournie ? + + + %1$s : + Version + Version de l\'API + Auteur + Licence + Capacités + Fonctions + Dépôt + Contact + Signaler un bug + Soutenir %1$s + + + Officiel + Vérifié + Étoiles GitHub + URL du dépôt GitHub + + + Aucune version trouvée + Choisir une version + Sélectionnez une version + Préversion + Notes de version + + + Accueil + Rechercher + Sur l\'appareil + Connect + Appareils + + + Masquer la fenêtre + Afficher la fenêtre + Titre suivant + Titre précédent + Lecture aléatoire + Répétition + Répétition : DÉSACTIVÉE + Répétition : TITRE + Répétition : TOUT + Volume : %1$d% + Volume + + Volume - + Couper le son + Réactiver le son + Aimer le titre + Ne plus aimer le titre + Quitter + + + Réduire + Agrandir + Restaurer + Fermer + + + Lecture + Commandes de lecture multimédia + + + Parcourir + En cours de lecture + À la une + Titres enregistrés + Titres populaires + diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml new file mode 100644 index 00000000..2ed9b459 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -0,0 +1,925 @@ + + + कतार + कतार साफ़ करें + कतार फ़िल्टर करें... + कतार में कुछ नहीं है + + गाने, कलाकार, एल्बम खोजें... + पूरा इतिहास साफ़ करें + खोजें... + + + आपकी लाइब्रेरी + लाइब्रेरी + प्लेलिस्ट + एल्बम + कलाकार + लोकल ट्रैक + डाउनलोड + सेव की गई प्लेलिस्ट खोजें… + सेव किए गए एल्बम खोजें… + सेव किए गए कलाकार खोजें… + फ़ोल्डर या ट्रैक खोजें… + लाइब्रेरी फ़िल्टर करें + फ़िल्टर बंद करें + प्लेलिस्ट बनाएं + लोकल ट्रैक रीफ़्रेश करें + ग्रिड व्यू पर जाएं + लिस्ट व्यू पर जाएं + कोई सेव की गई प्लेलिस्ट नहीं + “%1$s” से मेल खाती कोई प्लेलिस्ट नहीं + कोई सेव किया गया एल्बम नहीं + “%1$s” से मेल खाता कोई एल्बम नहीं + कोई सेव किया गया कलाकार नहीं + “%1$s” से मेल खाता कोई कलाकार नहीं + + + फ़ोल्डर + फ़ोल्डर + %1$s • %2$s + + %1$d ट्रैक + %1$d ट्रैक + + + %1$d फ़ोल्डर + %1$d फ़ोल्डर + + फ़ोल्डर पर वापस जाएं + फ़ोल्डर चलाएं + ऑडियो एक्सेस ज़रूरी है + अपने डिवाइस का संगीत देखने के लिए ऑडियो एक्सेस की अनुमति दें। + एक्सेस दें + कोई लोकल फ़ोल्डर नहीं मिला + इस फ़ोल्डर में कोई ट्रैक नहीं है + कोई मेल खाता फ़ोल्डर या ट्रैक नहीं + लोकल मीडिया रीफ़्रेश नहीं हो सका + + + %1$d जारी • %2$d पूरे + पूरे हुए हटाएं + अभी कोई डाउनलोड नहीं + डाउनलोड हो रहा है %1$d% + कतार में + पूरा हुआ + रद्द किया गया + विफल + विफल: %1$s + डाउनलोड रद्द करें + फिर से डाउनलोड करें + डाउनलोड हटाएं + + + कलाकार + ब्लैकलिस्ट में + पसंद किए गए ट्रैक + आपके सेव किए गए गाने + अज्ञात कलाकार + ट्रैक का नाम + कलाकार का नाम + + चुना गया + + सेटिंग + + प्लगइन + भाषा और क्षेत्र + दिखावट + प्लेबैक + डाउनलोड + कैशिंग + डेस्कटॉप + अपडेट + सहयोग + जानकारी + + बंद करें + सेव करें + रद्द करें + + भाषा + ऐप इंटरफ़ेस की भाषा। मौजूदा: %1$s + सिस्टम डिफ़ॉल्ट + देश + कंटेंट और उपलब्धता के लिए क्षेत्रीय देश। मौजूदा: %1$s + %1$s (%2$s) + + थीम + चुनें कि Spotube आपके सिस्टम की दिखावट से कैसे मेल खाए। मौजूदा: %1$s + लाइट + डार्क + सिस्टम + + सरफ़ेस + ऐप का बैकग्राउंड मटीरियल। मौजूदा: %1$s + जलीय + बर्फ़ीला + क्रोम + एक्रिलिक + ग्लास + + एक्सेंट रंग + मौजूदा पैलेट: %1$s + एक्सेंट रंग + एक्सेंट पैलेट चुनें और उसे लाइट और डार्क दोनों थीम में देखें। + लाइट + डार्क + प्रीव्यू + बटन + ग्रीन गॉब्लिन + इलेक्ट्रिक वायलेट + ओशनिक सियान + सनसेट ऑरेंज + रोज़ गार्डन + मिडनाइट ब्लू + मेटैलिक स्लेट + + लुक और फ़ील + सरफ़ेस मटीरियल और एक्सेंट। मौजूदा: %1$s · %2$s + लुक और फ़ील + एक सरफ़ेस मटीरियल चुनें, फिर उसके साथ जंचने वाला एक्सेंट। हर विकल्प एक चुना हुआ कॉम्बिनेशन है। + सरफ़ेस + एक्सेंट + सेकेंडरी + + पैलेट + Millennium एक्सेंट पैलेट। मौजूदा: %1$s + पैलेट + Millennium एक्सेंट पैलेट चुनें और उसे लाइट और डार्क में देखें। + पैलेट + Millennium पैलेट और एक्सेंट। मौजूदा: %1$s + नीला + नारंगी + लाइम + + डाउनलोड फ़ोल्डर बदलें + मौजूदा फ़ोल्डर: %1$s + डिफ़ॉल्ट म्यूज़िक फ़ोल्डर इस्तेमाल करने के लिए इसे खाली छोड़ें। + C:\Music\Spotube + लोकल मीडिया फ़ोल्डर + डेस्कटॉप पर स्कैन करने के लिए कस्टम फ़ोल्डर। जोड़े गए: %1$d + अभी तक कोई कस्टम फ़ोल्डर नहीं जोड़ा गया + आपकी लोकल म्यूज़िक लाइब्रेरी के लिए कस्टम फ़ोल्डर। + मैनेज करें + फ़ोल्डर जोड़ें + फ़ोल्डर हटाएं + कोई कस्टम फ़ोल्डर नहीं जोड़ा गया। + %1$s को स्कैन सूची से हटाएं + डाउनलोड और म्यूज़िक कैश फ़ोल्डर अपने-आप शामिल होते हैं। + आपका डाउनलोड फ़ोल्डर अपने-आप शामिल होता है। + म्यूज़िक कैश फ़ोल्डर अपने-आप शामिल होता है। + सेव हो रहा है… + फ़ोल्डर सेव नहीं हो सके। आपके बदलाव अभी भी यहीं हैं; फिर से कोशिश करें। + + %1$d फ़ोल्डर + %1$d फ़ोल्डर + + डाउनलोड म्यूज़िक फ़ॉर्मैट + डाउनलोड के लिए पसंदीदा कोडेक और कंटेनर। मौजूदा: %1$s + डाउनलोड म्यूज़िक क्वालिटी + + स्ट्रीमिंग म्यूज़िक फ़ॉर्मैट + म्यूज़िक स्ट्रीमिंग के लिए पसंदीदा कोडेक और कंटेनर। मौजूदा: %1$s + स्ट्रीमिंग म्यूज़िक क्वालिटी + मौजूदा: %1$s + म्यूज़िक कैशिंग चालू करें + दोबारा तेज़ी से चलाने के लिए स्ट्रीम किया गया ऑडियो डिवाइस पर सेव करें। + कैश फ़ोल्डर + मौजूदा: %1$s + मौजूदा: डिफ़ॉल्ट ऐप्लिकेशन कैश + कैश साइज़ सीमा + कोई सीमा नहीं + मौजूदा सीमा: %1$d MB + MB में अधिकतम कैश साइज़ सेट करें। कोई सीमा न रखने के लिए 0 डालें या खाली छोड़ें। + 500 + साइज़ 0 या कोई पॉज़िटिव संख्या होनी चाहिए। + लगातार प्लेबैक चालू करें + कतार खत्म होने पर सुझाए गए ट्रैक के साथ प्लेबैक जारी रखें। + प्लेबैक प्रॉक्सी सर्वर पोर्ट + रिमोट कंट्रोल की अनुमति दें + एक ही नेटवर्क के दूसरे डिवाइस कनेक्ट होने पर प्लेबैक कंट्रोल कर सकें। + रिमोट कंट्रोल डिवाइस का नाम + यह नाम दूसरे डिवाइस को इस डिवाइस को खोजते समय दिखता है। मौजूदा: %1$s + एक आसान नाम चुनें, जो आपके नेटवर्क के दूसरे डिवाइस को यह Spotube खोजते समय दिखे। + जैसे लिविंग रूम Spotube + डिवाइस होस्टनेम + प्लेबैक प्रॉक्सी सर्वर का पोर्ट। मौजूदा: %1$d + 1 से 65535 के बीच कोई पोर्ट चुनें। + 14769 + एक मान्य पूर्ण संख्या डालें। + पोर्ट 1 से 65535 के बीच होना चाहिए। + + ब्लैकलिस्ट + ब्लैकलिस्ट किए गए ट्रैक और कलाकार मैनेज करें + + ट्रे में छोटा करें + विंडो छोटी करने पर Spotube को सिस्टम ट्रे में चलता रहने दें। + Discord रिच प्रेज़ेंस + डेस्कटॉप पर Discord में आपका मौजूदा प्लेबैक स्टेटस दिखाएं। + + अपडेट अपने-आप जांचें + बैकग्राउंड में Spotube के नए रिलीज़ देखें। + + प्लगइन मैनेज करें + अपने प्लगइन मैनेज करें + डिफ़ॉल्ट %1$s प्लगइन + मेटाडेटा + ऑडियो + लिरिक्स + स्क्रॉबल + कोई प्लगइन नहीं चुना गया + बदलें + चुनें + चुना गया + चुनाव हटाएं + कोई प्लगइन उपलब्ध नहीं + %1$s प्लगइन + हटाएं + + कॉन्फ़िगर करें + प्लगइन मैनेजर + कृपया URL डालें + URL http:// या https:// से शुरू होना चाहिए + प्लगइन डाउनलोड नहीं हो सका + अभी तक कोई प्लगइन इंस्टॉल नहीं है + डाउनलोड URL पेस्ट करें या ऊपर से कोई फ़ाइल चुनें। + + %1$d प्लगइन इंस्टॉल है + %1$d प्लगइन इंस्टॉल हैं + + प्लगइन इंस्टॉल करें + https://example.com/plugin.smplug + डाउनलोड करें + फ़ाइल से इंस्टॉल करें (.smplug) + सक्रिय + v%1$s + बिल्ट-इन + प्लगइन हटाएं + लॉग इन करें + लॉग आउट करें + URL से डाउनलोड करें + फ़ाइल से इंस्टॉल करें + इंस्टॉल करें + + • %1$s + मांगी गई अनुमतियां + इस प्लगइन को किसी खास अनुमति की ज़रूरत नहीं है। + इंस्टॉल बनाम दिया गया + इंस्टॉल किया गया + दिया गया + API %1$s → %2$s + स्थायी स्टोरेज + डिस्क पर डेटा पढ़ें और लिखें + नेटवर्क अनुरोध + इंटरनेट पर डेटा भेजें और पाएं + WebView + ऐप के अंदर वेब कंटेंट दिखाएं + ग्रुप जैम + MQTT ब्रोकर + ब्रोकर होस्ट + %1$s:%2$d + ब्रोकर पोर्ट + TLS इस्तेमाल करें + यूज़रनेम (वैकल्पिक) + पासवर्ड (वैकल्पिक) + क्लाइंट ID प्रीफ़िक्स + इस Spotube की पहचान के लिए इस्तेमाल होता है + ब्रोकर कनेक्शन एन्क्रिप्ट करें + रूम शुरू करने से पहले मौजूदा ब्रोकर सेटिंग जांचें। + कनेक्शन टेस्ट करें + टेस्ट हो रहा है… + %1$d ms में कनेक्ट हुआ + विफल: %1$s + प्लेसहोल्डर ब्रोकर — खुद होस्ट करने के लिए अपना ब्रोकर सेट करें + कनेक्शन + क्रेडेंशियल + पहचान + डायग्नोस्टिक्स + + + ग्रुप जैम + बनाएं + जुड़ें + एक रूम बनाएं, फिर उसका कोड अपने दोस्तों के साथ शेयर करें। + रूम बनाएं + रूम से जुड़ें + कनेक्ट हो रहा है… + रूम कोड + ABC123 + ब्रोकर: %1$s + रूम होस्ट करने या उससे जुड़ने के लिए सेटिंग में ब्रोकर सेट करें। + ब्रोकर सेटिंग + कनेक्ट है + फिर से कनेक्ट हो रहा है… + कोड कॉपी करें + कॉपी हो गया! + शेयर करें + सदस्य (%1$d) + होस्ट + श्रोता + श्रोताओं का इंतज़ार है… + हटाएं + बैन करें + मैनेज करें + रूम छोड़ें + कनेक्ट नहीं हो सका + ख़ारिज करें + जैम सेशन नहीं बन सका + जैम सेशन से नहीं जुड़ सके + अक्षरों और अंकों वाला छह अक्षर का रूम कोड डालें। + मेरे Spotube जैम से जुड़ें, कोड: %1$s + + एल्बम पर जाएं + स्लीप टाइमर + ट्रैक की जानकारी + चलाएं + रोकें + कुछ नहीं चल रहा + कतार में जोड़ें + + अनदेखा करें + अपडेट करें + Spotube %1$s उपलब्ध है + इस रिलीज़ में क्या नया है, यह Spotube डाउनलोड पेज पर देखें। + %1$s %2$s उपलब्ध है + अभी इंस्टॉल है: %1$s + इस अपडेट में क्या नया है, यह प्लगइन के पेज पर देखें। + + छोड़ें + आगे + पीछे + शुरू करें + Spotube + संगीत, बिना किसी बंधन के। + संगीत की दुनिया के लिए एक ओपन-सोर्स प्लेयर।\nखोजिए अपने तरीके से। बनाइए इसे अपना। + इस प्रोजेक्ट को चलते रहने दें। + Spotube को लोग बनाते हैं, कोई सब्सक्रिप्शन बिज़नेस नहीं। अगर यह आपकी रोज़ की ज़िंदगी का हिस्सा बन जाए, तो थोड़ा सा सहयोग हमें इसकी वह देखभाल करने में मदद करता है जिसका यह हकदार है।\n\nस्वतंत्र सॉफ़्टवेयर को जगह देने के लिए धन्यवाद। + गाने और प्लेलिस्ट ढूंढने के लिए किसी कैटलॉग प्लगइन से शुरुआत करें। उन्हें चलाने के लिए एक ऑडियो प्लगइन चुनें। लिरिक्स और सुनने का इतिहास वैकल्पिक हैं। नीचे कोई प्लगइन खोजें, उसकी अनुमतियां देखें, इंस्टॉल करें, फिर उसे यहां चुनें। + प्लगइन खोजें + आपके सोर्स + फिर से कोशिश करें + और प्लगइन + कोई नया प्लगइन नहीं मिला। आप ऊपर दिए लिंक या .smplug फ़ाइल से भी इंस्टॉल कर सकते हैं। + प्लगइन खोज अभी उपलब्ध नहीं है। फिर से कोशिश करें, या लिंक या फ़ाइल से इंस्टॉल करें। + स्वागत + सहयोग + प्लगइन + गाइड + + Spotube कैसे इस्तेमाल करें + चार आसान स्टेप और आप सुनना शुरू कर सकते हैं। + साइन इन करें + अपना Spotify अकाउंट कनेक्ट करें, या अपना कैटलॉग प्लगइन चुनें। + खोजें और चलाएं + कोई भी गाना, एल्बम या कलाकार खोजें, फिर प्ले दबाएं। + ऑफ़लाइन सुनें + बिना कनेक्शन के सुनते रहने के लिए ट्रैक डाउनलोड करें। + लिरिक्स के साथ गाएं + ट्रैक चलते समय प्लेयर से सिंक्ड लिरिक्स खोलें। + + + आपका संगीत। आपके सोर्स। आपके नियम। + एक मुफ़्त, ओपन-सोर्स म्यूज़िक प्लेयर, जिसे संगीत से प्यार करने वाली कम्युनिटी ने बनाया है। + ओपन सोर्स + कोई विज्ञापन नहीं + कोई टेलीमेट्री नहीं + प्लगइन से चलने वाला + चलिए शुरू करें + + + सहयोगियों से हम चलते हैं + जुनून से बना। आपसे ज़िंदा। + Spotube 100% मुफ़्त, स्वतंत्र और ओपन सोर्स है — न विज्ञापन, न सब्सक्रिप्शन, न ट्रैकिंग। हर फ़ीचर इसलिए है क्योंकि आप जैसे लोगों ने कोड को आगे बढ़ाने में मदद करना चुना। + Patreon + मासिक स्पॉन्सर + लगातार डेवलपमेंट और इंफ्रास्ट्रक्चर में सहयोग करें + Buy Me a Coffee + एक बार की कॉफ़ी + एक छोटी सी टिप से देर रात की कोडिंग को ऊर्जा दें + Open Collective + कम्युनिटी फ़ंड + पारदर्शी, कम्युनिटी द्वारा संचालित आर्थिक सहयोग + ओपन म्यूज़िक पर भरोसा करने के लिए दिल से धन्यवाद। + सहयोग करना पूरी तरह वैकल्पिक है — Spotube हर हाल में मुफ़्त रहेगा। + स्पॉन्सर करें + %1$s खोलें + + + Spotube को सहयोग दें + Spotube को सहयोग दें + Spotube मुफ़्त, स्वतंत्र और ओपन सोर्स है — न विज्ञापन, न सब्सक्रिप्शन, न ट्रैकिंग। अगर यह आपको खुशी देता है, तो प्रोजेक्ट को सहयोग देने के बारे में सोचें। + आप ये रिमाइंडर सेटिंग → सहयोग में बंद कर सकते हैं। + बाद में + सहयोग रिमाइंडर + हर 7 दिन में प्रोजेक्ट को सहयोग देने का एक छोटा सा रिमाइंडर दिखाएं। + + + मॉड्यूलर आर्किटेक्चर + चुनें कि आपका संगीत कहां से आए। + Spotube कैटलॉग खोज, ऑडियो स्ट्रीमिंग और सिंक्ड लिरिक्स को अलग रखता है। नीचे अपने इंजन सेट करें या उन्हें कभी भी बदलें। + मेटाडेटा इंजन + म्यूज़िक खोज, सर्च, ट्रैक लिस्टिंग और प्लेलिस्ट को चलाता है। + ऑडियो इंजन + चलने वाली ऑडियो स्ट्रीम लाता और डिकोड करता है। + लिरिक्स इंजन + समय के साथ सिंक कराओके लिरिक्स रियल टाइम में दिखाता है। + स्क्रॉबल इंजन + सुनने का इतिहास Last.fm या ListenBrainz पर दर्ज करता है। + सक्रिय + बिल्ट-इन + तैयार + वैकल्पिक + एक इंजन चुनें + इंस्टॉल किए गए इंजन + डिफ़ॉल्ट बनाएं + NewPipe YouTube और LRCLib लिरिक्स बिल्ट-इन हैं और शुरू से ही स्ट्रीम करने के लिए तैयार हैं। + सेटिंग → प्लगइन में कभी भी कम्युनिटी प्लगइन मैनेज करें या नए जोड़ें। + + + रेट्रो-मॉडर्न आर्किटेक्चर + शुद्ध Hi-Fi, कोई बिचौलिया नहीं + देखिए Spotube कैसे आपकी मशीन पर सीधे डीसेंट्रलाइज़्ड प्लेबैक चलाता है। + सीधा पीयर-टू-पीयर • 256 KBPS OPUS + ब्लू आवर + Spotube · प्लेयर डेमो + बीच के पलों के लिए थोड़ा सा संगीत। + 1. डीसेंट्रलाइज़्ड खोज + सर्च सीधे आपके चुने हुए कैटलॉग प्लगइन तक जाती है। Spotube कभी आपकी पसंद की प्रोफ़ाइल नहीं बनाता। + 2. सीधा स्ट्रीम रिज़ॉल्यूशन + ऑडियो सीधे कंटेंट सर्वर से आपकी मशीन तक स्ट्रीम होता है। न कोई प्रॉक्सी, न रिमोट डेटा संग्रह। + 3. लोकल DSP और प्लेबैक + इक्वलाइज़र कंट्रोल और वैकल्पिक ऑफ़लाइन डाउनलोड के साथ लोकल रूप से डिकोड और कैश होता है। + 4. सिंक्ड लिरिक्स और जैम + कराओके लिरिक्स प्लेबैक के समय के साथ चलते हैं, साथ में LAN रिमोट कंट्रोल और ग्रुप जैम। + कोई बिचौलिया सर्वर नहीं। प्लेबैक सीधे आपके डिवाइस पर होता है — शुरू से ही निजी। + + + डिवाइस चुनें + %1$s: “%2$s” + चलाएं + कतार में जोड़ें + अगला चलाएं + यह डिवाइस + इस डिवाइस पर + रिमोट डिवाइस + रिमोट डिवाइस (%1$s) + कनेक्ट किए गए डिवाइस पर + + + कनेक्शन विफल + कनेक्शन टूट गया + भेजना विफल + खोज विफल: %1$s + कनेक्ट नहीं हो सका: %1$s + + + डिवाइस + रीफ़्रेश करें + डिस्कनेक्ट करें + कनेक्ट हो रहा है... + नेटवर्क पर डिवाइस खोजे जा रहे हैं... + कोई डिवाइस नहीं मिला + पक्का करें कि दूसरे डिवाइस की सेटिंग में \"रिमोट कंट्रोल की अनुमति दें\" चालू है। + अज्ञात डिवाइस + पता लगाया जा रहा है... + + + रिमोट कंट्रोल + डिस्कनेक्ट हो गया + कनेक्शन में गड़बड़ी: %1$s + अज्ञात ट्रैक + अज्ञात कलाकार + वॉल्यूम + कतार से हटाएं + और विकल्प + + + कहां चलाएं? + आपका एक जैम सेशन चालू है। चुनें कि यह कलेक्शन कहां चलाना है। + यहां चलाएं + जैम में सुझाएं + + + कोई जैम ब्रोकर सेट नहीं है + ब्रोकर होस्ट खाली है + कनेक्शन विफल + कनेक्शन टूट गया + + + ब्रोकर + anonymous + + + जैम कतार में जोड़ा गया + + %1$d ट्रैक जैम कतार में जोड़ा गया + %1$d ट्रैक जैम कतार में जोड़े गए + + + + रिमोट कंट्रोल अनुरोध + \"%1$s\" इस डिवाइस पर प्लेबैक कंट्रोल करना चाहता है। + मना करें + अनुमति दें + हमेशा अनुमति दें + + + खोज के नतीजे + सभी + ट्रैक + यूज़र + कोई खोज प्रकार उपलब्ध नहीं + खोजने के लिए टाइप करें + ट्रैक खोजने के लिए टाइप करें + कोई नतीजा नहीं मिला + सभी देखें + खोज रहे हैं + ट्रैक लोड हो रहे हैं... + एल्बम लोड हो रहे हैं... + कलाकार लोड हो रहे हैं... + प्लेलिस्ट लोड हो रही हैं... + यूज़र लोड हो रहे हैं... + कोई एल्बम नहीं मिला + कोई कलाकार नहीं मिला + कोई प्लेलिस्ट नहीं मिली + कोई यूज़र नहीं मिला + रिकॉर्ड के ढेर खंगाले जा रहे हैं... + ब्रह्मांड में खोज रहे हैं... + स्पीकर गर्म हो रहे हैं... + विनाइल की धूल झाड़ रहे हैं... + संगीत के देवताओं से सलाह ले रहे हैं... + एंटीना ट्यून कर रहे हैं... + रिकॉर्ड पलट रहे हैं... + खोज की सुविधाएं लोड नहीं हो सकीं + खोज नहीं हो सकी + ट्रैक नहीं खोजे जा सके + एल्बम नहीं खोजे जा सके + कलाकार नहीं खोजे जा सके + प्लेलिस्ट नहीं खोजी जा सकीं + यूज़र नहीं खोजे जा सके + + + ब्राउज़ करें + + + सिंक्ड + सादा + सिंक्ड लिरिक्स उपलब्ध नहीं हैं + सादे लिरिक्स उपलब्ध नहीं हैं + + + %2$s में %1$s + %1$d kbps + %1$s kHz • %2$d ch + कोई मेटाडेटा प्लगइन नहीं चुना गया + + + वापस + फिर से कोशिश करें + डायलॉग बंद करें + लोड हो रहा है... + अज्ञात + अज्ञात गड़बड़ी + + + यूज़र + प्लेलिस्ट + + + हमें सच में खेद है + अभी कुछ ठीक से काम नहीं कर रहा है। आपका अनुभव हमारे लिए मायने रखता है, और हम इसे ठीक करने की पूरी कोशिश कर रहे हैं। कृपया फिर से कोशिश करें या नीचे गड़बड़ी की जानकारी देखें। + गड़बड़ी की जानकारी + क्लिपबोर्ड पर कॉपी करें + पूरा लॉग देखें + पूरा एरर लॉग + + + शफ़ल + शफ़ल करके चलाएं + फ़ॉलो करें + अनफ़ॉलो करें + प्लेलिस्ट एडिट करें + %1$s द्वारा + + + ट्रैक विकल्प + जैम में जोड़ें + रेडियो शुरू करें + अगला चलाएं + अगले पर ले जाएं + कतार से हटाएं + पसंदीदा से हटाएं + पसंदीदा में सेव करें + प्लेलिस्ट में जोड़ें + डाउनलोड करें + शेयर करें + + + मूल क्रम + नाम + कलाकार + एल्बम + अवधि + क्रम से लगाएं + कोई ट्रैक नहीं मिला + फ़िल्टर + एक साथ कार्रवाई + सभी डाउनलोड करें + %1$d डाउनलोड करें + सभी को कतार में जोड़ें + %1$d को कतार में जोड़ें + सभी को अगला चलाएं + %1$d को अगला चलाएं + सभी को प्लेलिस्ट में जोड़ें + %1$d को प्लेलिस्ट में जोड़ें + सभी को जैम में जोड़ें + %1$d को जैम में जोड़ें + +%1$d और + + + लोड करने के लिए कोई URL नहीं + ब्राउज़र इतिहास में पीछे जाएं + ब्राउज़र इतिहास में आगे जाएं + + + ब्लैकलिस्ट फ़िल्टर करें... + ब्लैकलिस्ट में कुछ नहीं है + \'%1$s\' से मेल खाता कोई ब्लैकलिस्ट आइटम नहीं + ट्रैक + ट्रैक + ब्लैकलिस्ट में जोड़ें + ब्लैकलिस्ट से हटाएं + + + ट्रैक का मेटाडेटा नहीं है + स्ट्रीम URL नहीं मिल सका + स्ट्रीम URL नहीं मिल सका। पहले ट्रैक चलाकर देखें। + + + सेव किए गए ट्रैक + आप + + + फ़ॉलो कर रहे हैं + टॉप ट्रैक + टॉप ट्रैक चलाएं + टॉप ट्रैक कतार में जोड़ें + मिलते-जुलते कलाकार + फ़ीचर्ड प्लेलिस्ट + कलाकार की जानकारी लोड नहीं हो सकी + फ़ॉलोअर उपलब्ध नहीं + %1$sK फ़ॉलोअर + %1$sM फ़ॉलोअर + %1$sB फ़ॉलोअर + + %1$d फ़ॉलोअर + %1$d फ़ॉलोअर + + + + पसंद करें + पसंद से हटाएं + No-op ट्रैक का नाम + No-op कलाकार + अज्ञात एल्बम + आगे-पीछे करें + वॉल्यूम + शफ़ल चालू करें + शफ़ल बंद करें + पिछला + अगला + चलाएं या रोकें + रिपीट बंद + एक को रिपीट करें + सभी को रिपीट करें + म्यूट करें + अनम्यूट करें + और विकल्प + प्लेयर विकल्प + प्लेयर छोटा करें + लिरिक्स बड़ा करें + अभी चल रहा है + लिरिक्स + वैकल्पिक सोर्स + वैकल्पिक सोर्स + डाउनलोड करें + डाउनलोड हो गया + डाउनलोड हो रहा है... + पहले से डाउनलोड है + डाउनलोड फिर से शुरू हो रहा है... + %1$s डाउनलोड हो रहा है + प्लेबैक %1$s में रुक जाएगा। + चुने गए मिनटों के बाद प्लेबैक रोकें। + मिनट + 1 से 600 मिनट के बीच चुनें + टाइमर रद्द करें + शुरू करें + प्लेबैक URL अलग स्ट्रीमिंग सेटिंग के साथ तय किया गया था। हाइलाइट की गई स्ट्रीम आपकी मौजूदा पसंद से मेल खाती है; सक्रिय URL अगली स्ट्रीम रिक्वेस्ट पर रीफ़्रेश होगा। + मेल खाता ऑडियो सोर्स + मेल खाता सोर्स लोड हो रहा है… + लोकल कैश फ़ाइल चलाते समय मेल खाते रिमोट सोर्स की ज़रूरत नहीं है। + कोई मेल खाता सोर्स उपलब्ध नहीं है। + इस ट्रैक के लिए कोई मेल खाता ऑडियो सोर्स कैश नहीं है। + स्ट्रीम की जानकारी अभी कैश नहीं है। उसे लोड करने के लिए पहले ट्रैक चलाएं। + ट्रैक की जानकारी लोड नहीं हो सकी। + उपलब्ध स्ट्रीम + यह ट्रैक लोकल कैश से चल रहा है। इस प्लेबैक के लिए स्ट्रीम वेरिएंट इस्तेमाल नहीं होते। + स्ट्रीम लोड हो रही हैं… + इस सोर्स के लिए कोई स्ट्रीम उपलब्ध नहीं है। + अभी चुनी गई स्ट्रीम + आपकी स्ट्रीमिंग फ़ॉर्मैट और क्वालिटी पसंद से मेल खाती है + सक्रिय प्लेबैक URL + लोकल कैश से चल रहा है + मेटाडेटा ट्रैक + नाम + ID + एल्बम + विश्वसनीयता + बाहरी URI + एल्बम ID + एल्बम प्रकार + एल्बम रिलीज़ की तारीख + एल्बम में ट्रैक की संख्या + एल्बम की शैलियां + अवधि + ट्रैक नंबर + डिस्क नंबर + एक्सप्लिसिट + लोकप्रियता + ISRC + थंबनेल URL + + + साइडबार दिखाएं/छिपाएं + + + वैकल्पिक सोर्स + अभी कोई ट्रैक नहीं चल रहा + वैकल्पिक सोर्स खोजे जा रहे हैं... + कोई वैकल्पिक सोर्स नहीं मिला + विश्वसनीयता: %1$d% + सक्रिय सोर्स + + + निकालें + उन्हें सेशन से हटाएं + निकालें और दोबारा जुड़ने से रोकें + सुझाव हटाएं + उनके कतार में जोड़े गए सभी ट्रैक हटाएं + क्रम बदलें + कतार से हटाएं + %1$s • %2$s ने जोड़ा + + + अज्ञात + प्लेलिस्ट लोड हो रही है... + ट्रैक जोड़ें + प्लेलिस्ट में ट्रैक जोड़ें + ट्रैक खोजें... + खोजें + कोई ट्रैक नहीं मिला + जोड़ने के लिए ट्रैक खोजें + हो गया + जोड़ें + जोड़ा गया + + + एल्बम लोड हो रहा है... + + + अपनी प्लेलिस्ट में जोड़ें + खोजें + अपनी प्लेलिस्ट खोजें... + आपकी प्लेलिस्ट लोड हो रही हैं... + अभी तक आपकी कोई प्लेलिस्ट नहीं है।\nट्रैक जोड़ने के लिए एक प्लेलिस्ट बनाएं। + प्लेलिस्ट बनाएं + प्लेलिस्ट एडिट करें + बनाएं + नाम + प्लेलिस्ट का नाम + विवरण + विवरण (वैकल्पिक) + सार्वजनिक + कोई भी यह प्लेलिस्ट देख सकता है + साझा + दूसरे लोग इस प्लेलिस्ट में ट्रैक जोड़ सकते हैं + कवर इमेज + प्लेलिस्ट कवर + कोई इमेज नहीं + इमेज बदलें + इमेज चुनें + + + इंस्टॉल करें + बदलें + zip फ़ाइल में plugin.json नहीं मिला + zip फ़ाइल में manifest.zipline.json नहीं मिला + plugin.json का फ़ॉर्मैट अमान्य है: %1$s + प्लगइन पढ़ा नहीं जा सका: %1$s + प्लगइन इंस्टॉल नहीं हो सका: %1$s + %1$s का नया वर्शन उपलब्ध है। इंस्टॉल करने से पहले अपडेट की पुष्टि करें। + %1$s पहले से इंस्टॉल है। इंस्टॉल करने से पहले उसे बदलने की पुष्टि करें। + बिल्ट-इन प्लगइन पहले से शामिल हैं और दोबारा नहीं जोड़े जा सकते। + बिल्ट-इन प्लगइन हटाए नहीं जा सकते। + %1$s ने अमान्य apiVersion (%2$s) बताया है। %3$s के साथ संगत सिमैंटिक वर्शनिंग अपेक्षित है। + Spotube प्लगइन API वर्शन %1$s अमान्य है। + %1$s प्लगइन API %2$s के लिए बना है, लेकिन Spotube ब्रेकिंग प्लगइन API %3$s सपोर्ट करता है। उसी ब्रेकिंग API वर्शन के लिए बना प्लगइन इंस्टॉल करें। + + + प्लगइन API संगत नहीं है + प्लगइन इंस्टॉल करें? + %1$s आपके इंस्टॉल किए गए प्लगइन में जोड़ा जाएगा। + प्लगइन अपडेट करें? + एक पुराना वर्शन (%1$s) इंस्टॉल है। %2$s पर अपडेट करें? + इंस्टॉल किया गया प्लगइन बदलें? + %1$s अमान्य सिमैंटिक वर्शन (%2$s) इस्तेमाल करता है और अपडेट के रूप में इसकी तुलना नहीं हो सकती। फिर भी इंस्टॉल किया गया प्लगइन बदलें? + इंस्टॉल किए गए वर्शन (%1$s) की सिमैंटिक वर्शनिंग से तुलना नहीं हो सकती। इसे %2$s से बदलें? + वर्शन %1$s पहले से इंस्टॉल है। मौजूदा प्लगइन को दी गई कॉपी से बदलें? + इंस्टॉल किया गया वर्शन %1$s, दिए गए वर्शन %2$s से नया है। फिर भी बदलें? + इंस्टॉल किए गए प्लगइन को दी गई कॉपी से बदलें? + + + %1$s: + वर्शन + API वर्शन + लेखक + लाइसेंस + क्षमताएं + सुविधाएं + रिपॉज़िटरी + संपर्क + बग रिपोर्ट करें + %1$s को सहयोग दें + + + आधिकारिक + सत्यापित + GitHub स्टार + GitHub रिपॉज़िटरी URL + + + कोई रिलीज़ नहीं मिली + रिलीज़ चुनें + एक रिलीज़ चुनें + प्री-रिलीज़ + रिलीज़ नोट्स + + + होम + खोजें + डिवाइस पर + कनेक्ट + डिवाइस + + + विंडो छिपाएं + विंडो दिखाएं + अगला ट्रैक + पिछला ट्रैक + शफ़ल + रिपीट + रिपीट: बंद + रिपीट: एक + रिपीट: सभी + वॉल्यूम: %1$d% + वॉल्यूम + + वॉल्यूम - + म्यूट करें + अनम्यूट करें + ट्रैक पसंद करें + ट्रैक को पसंद से हटाएं + बाहर निकलें + + + छोटा करें + बड़ा करें + पहले जैसा करें + बंद करें + + + प्लेबैक + मीडिया प्लेबैक कंट्रोल + + + ब्राउज़ करें + अभी चल रहा है + फ़ीचर्ड + सेव किए गए ट्रैक + टॉप ट्रैक + diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml new file mode 100644 index 00000000..f3e1ca6a --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -0,0 +1,920 @@ + + + + Antrean + Kosongkan Antrean + Filter antrean... + Antrean kosong + + Cari lagu, artis, album... + Hapus semua riwayat + Cari... + + + Koleksi Kamu + Koleksi + Playlist + Album + Artis + Lagu Lokal + Unduhan + Cari playlist tersimpan… + Cari album tersimpan… + Cari artis tersimpan… + Cari folder atau lagu… + Filter koleksi + Tutup filter + Buat playlist + Muat ulang lagu lokal + Beralih ke tampilan petak + Beralih ke tampilan daftar + Belum ada playlist tersimpan + Tidak ada playlist yang cocok dengan “%1$s” + Belum ada album tersimpan + Tidak ada album yang cocok dengan “%1$s” + Belum ada artis tersimpan + Tidak ada artis yang cocok dengan “%1$s” + + + Folder + Folder + %1$s • %2$s + + %1$d lagu + + + %1$d folder + + Kembali ke folder + Putar folder + Akses audio diperlukan + Izinkan akses audio untuk menjelajahi musik di perangkatmu. + Beri akses + Tidak ada folder lokal + Tidak ada lagu di folder ini + Tidak ada folder atau lagu yang cocok + Gagal memuat ulang media lokal + + + %1$d aktif • %2$d selesai + Hapus yang selesai + Belum ada unduhan + Mengunduh %1$d% + Dalam antrean + Selesai + Dibatalkan + Gagal + Gagal: %1$s + Batalkan unduhan + Coba unduh lagi + Hapus unduhan + + + Artis + Diblokir + Lagu yang Disukai + Lagu tersimpanmu + Artis tidak dikenal + Judul lagu + Nama artis + + Dipilih + + Pengaturan + + Plugin + Bahasa dan Wilayah + Tampilan + Pemutaran + Unduhan + Cache + Desktop + Pembaruan + Dukungan + Tentang + + Tutup + Simpan + Batal + + Bahasa + Bahasa antarmuka aplikasi. Saat ini: %1$s + Bawaan sistem + Negara + Negara untuk konten dan ketersediaan. Saat ini: %1$s + %1$s (%2$s) + + Tema + Pilih cara Spotube menyesuaikan dengan tampilan sistem. Saat ini: %1$s + Terang + Gelap + Sistem + + Permukaan + Material latar belakang aplikasi. Saat ini: %1$s + Air + Es + Krom + Akrilik + Kaca + + Warna aksen + Palet saat ini: %1$s + Warna aksen + Pilih palet aksen dan pratinjau dalam tema terang maupun gelap. + Terang + Gelap + Pratinjau + Tombol + Green Goblin + Ungu Elektrik + Sian Samudra + Jingga Senja + Taman Mawar + Biru Tengah Malam + Batu Metalik + + Tampilan & nuansa + Material permukaan dan aksen. Saat ini: %1$s · %2$s + Tampilan & nuansa + Pilih material permukaan, lalu aksen yang serasi. Setiap opsi adalah kombinasi pilihan. + Permukaan + Aksen + Sekunder + + Palet + Palet aksen Millennium. Saat ini: %1$s + Palet + Pilih palet aksen Millennium dan pratinjau dalam mode terang dan gelap. + Palet + Palet dan aksen Millennium. Saat ini: %1$s + Biru + Jingga + Hijau Limau + + Ganti folder unduhan + Folder saat ini: %1$s + Biarkan kosong untuk memakai folder musik bawaan. + C:\Music\Spotube + Folder media lokal + Folder kustom untuk dipindai di desktop. Ditambahkan: %1$d + Belum ada folder kustom + Folder kustom untuk koleksi musik lokalmu. + Kelola + Tambah folder + Hapus folder + Belum ada folder kustom yang ditambahkan. + Hapus %1$s dari daftar pindai + Folder unduhan dan cache musik disertakan secara otomatis. + Folder unduhanmu disertakan secara otomatis. + Folder cache musik disertakan secara otomatis. + Menyimpan… + Gagal menyimpan folder. Perubahanmu masih ada; coba lagi. + + %1$d folder + + Format unduhan musik + Codec dan kontainer pilihan untuk unduhan. Saat ini: %1$s + Kualitas unduhan musik + + Format streaming musik + Codec dan kontainer pilihan untuk streaming musik. Saat ini: %1$s + Kualitas streaming musik + Saat ini: %1$s + Aktifkan cache musik + Simpan audio yang di-streaming secara lokal agar pemutaran ulang lebih cepat. + Folder cache + Saat ini: %1$s + Saat ini: cache aplikasi bawaan + Batas ukuran cache + Tanpa batas + Batas saat ini: %1$d MB + Atur ukuran cache maksimum dalam MB. Masukkan 0 atau kosongkan untuk tanpa batas. + 500 + Ukuran harus 0 atau bilangan positif. + Aktifkan pemutaran tanpa henti + Lanjutkan pemutaran dengan lagu rekomendasi saat antrean habis. + Port server proxy pemutaran + Izinkan kontrol jarak jauh + Izinkan perangkat lain di jaringan yang sama mengontrol pemutaran saat terhubung. + Nama perangkat kontrol jarak jauh + Nama yang dilihat perangkat lain saat menemukan perangkat ini. Saat ini: %1$s + Pilih nama yang mudah dikenali untuk ditampilkan ke perangkat lain di jaringanmu saat mereka menemukan Spotube ini. + mis. Spotube Ruang Tamu + nama host perangkat + Port yang dipakai server proxy pemutaran. Saat ini: %1$d + Pilih port antara 1 dan 65535. + 14769 + Masukkan bilangan bulat yang valid. + Port harus antara 1 dan 65535. + + Daftar blokir + Kelola lagu dan artis yang diblokir + + Minimalkan ke tray + Biarkan Spotube tetap berjalan di system tray saat jendela diminimalkan. + Discord Rich Presence + Tampilkan status pemutaran saat ini di Discord pada desktop. + + Periksa pembaruan otomatis + Cari rilis Spotube baru di latar belakang. + + Kelola Plugin + Kelola plugin kamu + Plugin %1$s Bawaan + Metadata + Audio + Lirik + Scrobble + Belum ada plugin dipilih + Ubah + Pilih + Dipilih + Hapus pilihan + Tidak ada plugin tersedia + Plugin %1$s + Hapus + + Konfigurasi + Pengelola Plugin + Masukkan URL + URL harus diawali http:// atau https:// + Gagal mengunduh plugin + Belum ada plugin terpasang + Tempel URL unduhan atau pilih file di atas. + + %1$d plugin terpasang + + Pasang Plugin + https://example.com/plugin.smplug + Unduh + Pasang dari file (.smplug) + Aktif + v%1$s + Bawaan + Hapus plugin + Masuk + Keluar + Unduh dari URL + Pasang dari file + Pasang + + • %1$s + Izin yang diminta + Plugin ini tidak memerlukan izin khusus. + Terpasang vs diberikan + Terpasang + Diberikan + API %1$s → %2$s + Penyimpanan Persisten + Membaca dan menulis data ke disk + Permintaan Jaringan + Mengirim dan menerima data melalui internet + WebView + Menampilkan konten web di dalam aplikasi + Jam Grup + Broker MQTT + Host broker + %1$s:%2$d + Port broker + Gunakan TLS + Nama pengguna (opsional) + Kata sandi (opsional) + Prefiks ID klien + Dipakai untuk mengidentifikasi Spotube ini + Enkripsi koneksi broker + Periksa pengaturan broker saat ini sebelum memulai ruang. + Uji koneksi + Menguji… + Terhubung dalam %1$d ms + Gagal: %1$s + Broker sementara — konfigurasikan milikmu sendiri untuk self-host + Koneksi + Kredensial + Identitas + Diagnostik + + + Jam Grup + Buat + Gabung + Buat ruang, lalu bagikan kodenya ke teman-temanmu. + Buat ruang + Gabung ke ruang + Menghubungkan… + Kode ruang + ABC123 + Broker: %1$s + Atur broker di Pengaturan untuk membuat atau bergabung ke ruang. + Pengaturan broker + Terhubung + Menghubungkan ulang… + Salin kode + Disalin! + Bagikan + Peserta (%1$d) + Host + Pendengar + Menunggu pendengar… + Keluarkan + Blokir + Kelola + Tinggalkan ruang + Gagal terhubung + Tutup + Gagal membuat sesi Jam + Gagal bergabung ke sesi Jam + Masukkan kode ruang enam karakter berisi huruf dan angka. + Gabung ke Jam Spotube-ku dengan kode: %1$s + + Buka album + Timer tidur + Detail lagu + Putar + Jeda + Tidak ada yang diputar + Tambahkan ke antrean + + Abaikan + Perbarui + Spotube %1$s telah tersedia + Lihat yang baru di rilis ini pada halaman unduhan Spotube. + %1$s %2$s telah tersedia + Terpasang saat ini: %1$s + Lihat yang baru di pembaruan ini pada halaman plugin. + + Lewati + Berikutnya + Kembali + Mulai + Spotube + Musik, tanpa ikatan. + Pemutar open source untuk dunia musik.\nBebas kamu jelajahi. Bebas kamu jadikan milikmu. + Bantu proyek ini tetap hidup. + Spotube dibuat oleh orang-orang, bukan bisnis langganan. Jika Spotube menjadi bagian dari keseharianmu, sedikit dukungan membantu kami merawatnya sebagaimana mestinya.\n\nTerima kasih telah memberi ruang bagi perangkat lunak independen. + Mulailah dengan plugin katalog untuk menemukan lagu dan playlist. Pilih plugin audio untuk memutarnya. Lirik dan riwayat dengar bersifat opsional. Temukan plugin di bawah, tinjau izinnya, pasang, lalu pilih di sini. + Temukan plugin + Sumbermu + Coba lagi + Plugin lainnya + Tidak ada plugin baru. Kamu juga bisa memasang plugin dari tautan atau file .smplug di atas. + Penemuan plugin sedang tidak tersedia. Coba lagi, atau pasang dari tautan atau file. + Selamat datang + Dukungan + Plugin + Panduan + + Cara menggunakan Spotube + Empat langkah singkat dan kamu siap mendengarkan. + Masuk + Hubungkan akun Spotify-mu, atau pilih plugin katalogmu sendiri. + Cari dan putar + Temukan lagu, album, atau artis apa pun, lalu tekan putar. + Dengarkan offline + Unduh lagu agar tetap bisa diputar tanpa koneksi. + Ikuti liriknya + Buka lirik tersinkron dari pemutar saat lagu diputar. + + + Musikmu. Sumbermu. Aturanmu. + Pemutar musik gratis dan open source yang dibuat oleh komunitas pencinta musik. + Open source + Tanpa iklan + Tanpa telemetri + Berbasis plugin + Ayo Mulai + + + Pendukung membuat kami terus berjalan + Dibuat dengan semangat. Dihidupkan olehmu. + Spotube 100% gratis, bebas, dan open source — tanpa iklan, tanpa langganan, tanpa pelacakan. Setiap fitur ada karena orang sepertimu memilih untuk membantu kode terus berkembang. + Patreon + Sponsor Bulanan + Dukung pengembangan dan infrastruktur secara rutin + Buy Me a Coffee + Kopi Sekali Saja + Beri semangat sesi ngoding larut malam dengan tip singkat + Open Collective + Dana Komunitas + Dukungan finansial yang transparan dan dikelola komunitas + Terima kasih dari lubuk hati kami karena telah percaya pada musik terbuka. + Dukungan sepenuhnya opsional — Spotube tetap gratis apa pun pilihanmu. + Sponsori + Buka %1$s + + + Dukung Spotube + Dukung Spotube + Spotube gratis, bebas, dan open source — tanpa iklan, tanpa langganan, tanpa pelacakan. Jika Spotube membuatmu senang, pertimbangkan untuk mendukung proyek ini. + Kamu bisa mematikan pengingat ini di Pengaturan → Dukungan. + Nanti saja + Pengingat dukungan + Tampilkan pengingat ringan untuk mendukung proyek setiap 7 hari. + + + Arsitektur Modular + Pilih dari mana musikmu berasal. + Spotube memisahkan penemuan katalog, streaming audio, dan lirik tersinkron. Atur mesinmu di bawah atau ganti kapan saja. + Mesin Metadata + Mendukung penemuan musik, pencarian, daftar lagu, dan playlist. + Mesin Audio + Mengambil dan mendekode stream audio yang diputar. + Mesin Lirik + Menampilkan lirik karaoke tersinkron secara real-time. + Mesin Scrobble + Mencatat riwayat dengar ke Last.fm atau ListenBrainz. + Aktif + Bawaan + Siap + Opsional + Pilih mesin + Mesin Terpasang + Jadikan bawaan + NewPipe YouTube dan LRCLib Lyrics sudah bawaan dan siap diputar langsung. + Kelola atau tambahkan plugin komunitas kapan saja di Pengaturan → Plugin. + + + Arsitektur Retro-Modern + Hi-Fi murni, tanpa perantara + Begini cara Spotube mengatur pemutaran terdesentralisasi langsung di perangkatmu. + PEER-TO-PEER LANGSUNG • 256 KBPS OPUS + Blue hour + Spotube · demonstrasi pemutar + Sedikit musik untuk momen-momen di antaranya. + 1. Penemuan Terdesentralisasi + Pencarian langsung dikirim ke plugin katalog pilihanmu. Spotube tidak pernah memprofilkan selera musikmu. + 2. Resolusi Stream Langsung + Audio di-streaming langsung dari server konten ke perangkatmu. Tanpa lompatan proxy atau pengumpulan data jarak jauh. + 3. DSP & Pemutaran Lokal + Didekode dan di-cache secara lokal dengan kontrol equalizer dan unduhan offline opsional. + 4. Lirik Tersinkron & Jam + Lirik karaoke mengikuti waktu pemutaran, dengan kontrol jarak jauh via LAN dan Jam Grup. + Tanpa server perantara. Pemutaran terjadi langsung di perangkatmu — privat sejak awal. + + + Pilih perangkat + %1$s: “%2$s” + Putar + Tambahkan ke antrean + Putar berikutnya + Perangkat Ini + Di perangkat ini + Perangkat Jarak Jauh + Perangkat Jarak Jauh (%1$s) + Di perangkat yang terhubung + + + Koneksi gagal + Koneksi terputus + Gagal mengirim + Pencarian gagal: %1$s + Gagal terhubung: %1$s + + + Perangkat + Muat ulang + Putuskan + Menghubungkan... + Mencari perangkat di jaringan... + Tidak ada perangkat ditemukan + Pastikan perangkat lain telah mengaktifkan \"Izinkan kontrol jarak jauh\" di pengaturan. + Perangkat Tidak Dikenal + Menyelesaikan... + + + Kontrol Jarak Jauh + Terputus + Kesalahan koneksi: %1$s + Lagu Tidak Dikenal + Artis Tidak Dikenal + Volume + Hapus dari antrean + Opsi lainnya + + + Putar di Mana? + Kamu sedang dalam sesi Jam. Pilih tempat untuk memutar koleksi ini. + Putar di sini + Sarankan ke Jam + + + Belum ada broker Jam yang dikonfigurasi + Host broker kosong + Koneksi gagal + Koneksi terputus + + + Broker + anonim + + + Ditambahkan ke antrean Jam + + %1$d ditambahkan ke antrean Jam + + + + Permintaan Kontrol Jarak Jauh + \"%1$s\" ingin mengontrol pemutaran di perangkat ini. + Tolak + Izinkan + Selalu Izinkan + + + Hasil pencarian + Semua + Lagu + Pengguna + Tidak ada jenis pencarian tersedia + Mulai mengetik untuk mencari + Mulai mengetik untuk mencari lagu + Tidak ada hasil + Lihat semua + Mencari + Memuat lagu... + Memuat album... + Memuat artis... + Memuat playlist... + Memuat pengguna... + Tidak ada album ditemukan + Tidak ada artis ditemukan + Tidak ada playlist ditemukan + Tidak ada pengguna ditemukan + Membongkar tumpukan piringan... + Menjelajahi alam semesta... + Memanaskan speaker... + Membersihkan debu piringan hitam... + Bertanya pada dewa musik... + Menyetel antena... + Membolak-balik koleksi piringan... + Gagal memuat kemampuan pencarian + Gagal mencari + Gagal mencari lagu + Gagal mencari album + Gagal mencari artis + Gagal mencari playlist + Gagal mencari pengguna + + + Jelajahi + + + Tersinkron + Biasa + Lirik tersinkron tidak tersedia + Lirik biasa tidak tersedia + + + %1$s dalam %2$s + %1$d kbps + %1$s kHz • %2$d ch + Belum ada plugin metadata dipilih + + + Kembali + Coba lagi + Tutup dialog + Memuat... + Tidak diketahui + Kesalahan tidak diketahui + + + Pengguna + Playlist + + + Kami Mohon Maaf + Ada yang tidak berfungsi saat ini. Pengalamanmu penting bagi kami, dan kami berusaha sebaik mungkin untuk memperbaikinya. Coba lagi atau periksa detail kesalahan di bawah. + Detail Kesalahan + Salin ke papan klip + Lihat log lengkap + Log Kesalahan Lengkap + + + Acak + Putar acak + Ikuti + Berhenti mengikuti + Edit playlist + Oleh %1$s + + + Opsi lagu + Tambahkan ke Jam + Mulai radio + Putar berikutnya + Pindahkan ke berikutnya + Hapus dari antrean + Hapus dari favorit + Simpan sebagai favorit + Tambahkan ke playlist + Unduh + Bagikan + + + Asli + Judul + Artis + Album + Durasi + Urutkan + Tidak ada lagu ditemukan + Filter + Tindakan massal + Unduh Semua + Unduh %1$d + Tambahkan Semua ke Antrean + Tambahkan %1$d ke Antrean + Putar Semua Berikutnya + Putar %1$d Berikutnya + Tambahkan Semua ke Playlist + Tambahkan %1$d ke Playlist + Tambahkan Semua ke Jam + Tambahkan %1$d ke Jam + +%1$d lainnya + + + Tidak ada URL untuk dimuat + Kembali di riwayat browser + Maju di riwayat browser + + + Filter daftar blokir... + Tidak ada item yang diblokir + Tidak ada item diblokir yang cocok dengan \'%1$s\' + Lagu + Lagu + Tambahkan ke daftar blokir + Hapus dari daftar blokir + + + Tidak ada metadata lagu + Tidak dapat menemukan URL stream + Tidak dapat menemukan URL stream. Coba putar lagunya terlebih dahulu. + + + Lagu Tersimpan + Kamu + + + Mengikuti + Lagu Teratas + Putar lagu teratas + Tambahkan lagu teratas ke antrean + Artis Terkait + Playlist Unggulan + Gagal memuat ringkasan artis + Jumlah pengikut tidak tersedia + %1$s rb pengikut + %1$s jt pengikut + %1$s M pengikut + + %1$d pengikut + + + + Suka + Batal suka + Judul lagu kosong + Artis kosong + Album Tidak Dikenal + Cari posisi + Volume + Aktifkan acak + Nonaktifkan acak + Sebelumnya + Berikutnya + Putar atau jeda + Mode ulang nonaktif + Ulang satu lagu + Ulang semua + Bisukan + Bunyikan + Opsi lainnya + Opsi pemutar + Ciutkan pemutar + Perluas lirik + Sedang Diputar + Lirik + Sumber Alternatif + Sumber alternatif + Unduh + Diunduh + Mengunduh... + Sudah diunduh + Mencoba mengunduh lagi... + Mengunduh %1$s + Pemutaran akan dijeda dalam %1$s. + Jeda pemutaran setelah jumlah menit yang dipilih. + Menit + Pilih antara 1 dan 600 menit + Batalkan timer + Mulai + URL pemutaran ditentukan dengan pengaturan streaming yang berbeda. Stream yang disorot sesuai dengan preferensimu saat ini; URL aktif akan diperbarui pada permintaan stream berikutnya. + Sumber audio yang cocok + Memuat sumber yang cocok… + Sumber jarak jauh yang cocok tidak diperlukan saat memutar file cache lokal. + Tidak ada sumber yang cocok. + Tidak ada sumber audio cocok yang di-cache untuk lagu ini. + Detail stream belum di-cache. Putar lagunya terlebih dahulu untuk memuatnya. + Gagal memuat detail lagu. + Stream yang tersedia + Lagu ini diputar dari cache lokal. Varian stream tidak digunakan untuk pemutaran ini. + Memuat stream… + Tidak ada stream tersedia untuk sumber ini. + Stream yang sedang dipilih + Sesuai dengan preferensi format & kualitas streaming-mu + URL pemutaran aktif + Diputar dari cache lokal + Metadata lagu + Judul + ID + Album + Tingkat kecocokan + URI Eksternal + ID Album + Jenis album + Tanggal rilis album + Jumlah lagu album + Genre album + Durasi + Nomor lagu + Nomor disk + Eksplisit + Popularitas + ISRC + URL thumbnail + + + Tampilkan/Sembunyikan Sidebar + + + Sumber Alternatif + Tidak ada lagu yang sedang diputar + Mencari sumber alternatif... + Tidak ada sumber alternatif ditemukan + Kecocokan: %1$d% + Sumber aktif + + + Keluarkan + Keluarkan mereka dari sesi + Keluarkan dan cegah mereka bergabung kembali + Hapus saran + Hapus semua lagu yang mereka tambahkan ke antrean + Ubah urutan + Hapus dari antrean + %1$s • Ditambahkan oleh %2$s + + + Tidak diketahui + Memuat playlist... + Tambah Lagu + Tambahkan Lagu ke Playlist + Cari lagu... + Cari + Tidak ada lagu ditemukan + Cari lagu untuk ditambahkan + Selesai + Tambah + Ditambahkan + + + Memuat album... + + + Tambahkan ke Playlist Kamu + Cari + Cari playlist kamu... + Memuat playlist kamu... + Kamu belum punya playlist.\nBuat satu untuk menambahkan lagu. + Buat Playlist + Edit Playlist + Buat + Nama + Nama playlist + Deskripsi + Deskripsi (opsional) + Publik + Siapa saja dapat melihat playlist ini + Kolaboratif + Orang lain dapat menambahkan lagu ke playlist ini + Gambar Sampul + Sampul playlist + Tidak ada gambar + Ganti Gambar + Pilih Gambar + + + Pasang + Ganti + plugin.json tidak ditemukan di file zip + manifest.zipline.json tidak ditemukan di file zip + Format plugin.json tidak valid: %1$s + Gagal membaca plugin: %1$s + Gagal memasang plugin: %1$s + Versi lebih baru dari %1$s tersedia. Konfirmasi pembaruan sebelum memasang. + %1$s sudah terpasang. Konfirmasi penggantian sebelum memasang. + Plugin bawaan sudah disertakan dan tidak dapat ditambahkan lagi. + Plugin bawaan tidak dapat dihapus. + %1$s mendeklarasikan apiVersion yang tidak valid (%2$s). Diharapkan semantic versioning yang kompatibel dengan %3$s. + Versi API plugin Spotube %1$s tidak valid. + %1$s menargetkan API plugin %2$s, tetapi Spotube mendukung API plugin breaking %3$s. Pasang plugin yang dibuat untuk versi API breaking yang sama. + + + API plugin tidak kompatibel + Pasang plugin? + %1$s akan ditambahkan ke plugin terpasang. + Perbarui plugin? + Versi lama terpasang (%1$s). Perbarui ke %2$s? + Ganti plugin terpasang? + %1$s menggunakan versi semantik yang tidak valid (%2$s) dan tidak dapat dibandingkan sebagai pembaruan. Tetap ganti plugin terpasang? + Versi terpasang (%1$s) tidak dapat dibandingkan menggunakan semantic versioning. Ganti dengan %2$s? + Versi %1$s sudah terpasang. Ganti plugin yang ada dengan salinan yang diberikan? + Versi terpasang %1$s lebih baru daripada versi yang diberikan %2$s. Tetap ganti? + Ganti plugin terpasang dengan salinan yang diberikan? + + + %1$s: + Versi + Versi API + Pembuat + Lisensi + Kapabilitas + Kemampuan + Repositori + Kontak + Laporkan Bug + Dukung %1$s + + + Resmi + Terverifikasi + Bintang GitHub + URL repositori GitHub + + + Tidak ada rilis ditemukan + Pilih Rilis + Pilih rilis + Pra-rilis + Catatan Rilis + + + Beranda + Cari + Di Perangkat + Connect + Perangkat + + + Sembunyikan Jendela + Tampilkan Jendela + Lagu Berikutnya + Lagu Sebelumnya + Acak + Ulang + Ulang: MATI + Ulang: SATU + Ulang: SEMUA + Volume: %1$d% + Volume + + Volume - + Bisukan + Bunyikan + Sukai Lagu + Batal Sukai Lagu + Keluar + + + Minimalkan + Maksimalkan + Pulihkan + Tutup + + + Pemutaran + Kontrol pemutaran media + + + Jelajahi + Sedang Diputar + Unggulan + Lagu Tersimpan + Lagu Teratas + diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml new file mode 100644 index 00000000..b6925383 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -0,0 +1,932 @@ + + + + Coda + Svuota coda + Filtra coda... + La coda è vuota + + Cerca brani, artisti, album... + Cancella tutta la cronologia + Cerca... + + + La tua libreria + Libreria + Playlist + Album + Artisti + Brani locali + Download + Cerca nelle playlist salvate… + Cerca negli album salvati… + Cerca negli artisti salvati… + Cerca cartelle o brani… + Filtra libreria + Chiudi filtro + Crea playlist + Aggiorna brani locali + Passa alla vista a griglia + Passa alla vista a elenco + Nessuna playlist salvata + Nessuna playlist corrisponde a “%1$s” + Nessun album salvato + Nessun album corrisponde a “%1$s” + Nessun artista salvato + Nessun artista corrisponde a “%1$s” + + + Cartelle + Cartella + %1$s • %2$s + + %1$d brano + %1$d brani + %1$d brani + + + %1$d cartella + %1$d cartelle + %1$d cartelle + + Torna alle cartelle + Riproduci cartella + Accesso all\'audio necessario + Consenti l\'accesso all\'audio per sfogliare la musica sul tuo dispositivo. + Consenti accesso + Nessuna cartella locale trovata + Nessun brano in questa cartella + Nessuna cartella o brano corrispondente + Impossibile aggiornare i contenuti locali + + + %1$d attivi • %2$d completati + Rimuovi completati + Ancora nessun download + Download in corso %1$d% + In coda + Completato + Annullato + Non riuscito + Non riuscito: %1$s + Annulla download + Riprova download + Rimuovi download + + + Artista + In blacklist + Brani che ti piacciono + I tuoi brani salvati + Artista sconosciuto + Titolo del brano + Nome dell\'artista + + Selezionato + + Impostazioni + + Plugin + Lingua e area geografica + Aspetto + Riproduzione + Download + Cache + Desktop + Aggiornamenti + Supporto + Informazioni + + Chiudi + Salva + Annulla + + Lingua + Lingua dell\'interfaccia dell\'app. Attuale: %1$s + Predefinita di sistema + Paese + Paese per contenuti e disponibilità. Attuale: %1$s + %1$s (%2$s) + + Tema + Scegli come Spotube si adatta all\'aspetto del sistema. Attuale: %1$s + Chiaro + Scuro + Sistema + + Superficie + Materiale di sfondo dell\'app. Attuale: %1$s + Acqua + Ghiaccio + Cromo + Acrilico + Vetro + + Colore di accento + Palette attuale: %1$s + Colore di accento + Scegli una palette di accento e visualizzane l\'anteprima nei temi chiaro e scuro. + Chiaro + Scuro + Anteprima + Pulsante + Verde Goblin + Viola elettrico + Ciano oceano + Arancio tramonto + Giardino di rose + Blu notte + Ardesia metallica + + Aspetto e stile + Materiale della superficie e accento. Attuale: %1$s · %2$s + Aspetto e stile + Scegli un materiale per la superficie, poi un accento abbinato. Ogni opzione è una combinazione curata. + Superficie + Accento + Secondario + + Palette + Palette di accento Millennium. Attuale: %1$s + Palette + Scegli una palette di accento Millennium e visualizzane l\'anteprima in chiaro e scuro. + Palette + Palette e accento Millennium. Attuale: %1$s + Blu + Arancione + Lime + + Cartella di download personalizzata + Cartella attuale: %1$s + Lascia vuoto per usare la cartella Musica predefinita. + C:\Music\Spotube + Cartelle multimediali locali + Cartelle personalizzate da analizzare sul desktop. Aggiunte: %1$d + Nessuna cartella personalizzata aggiunta + Cartelle personalizzate per la tua libreria musicale locale. + Gestisci + Aggiungi cartella + Rimuovi cartella + Nessuna cartella personalizzata aggiunta. + Rimuovi %1$s dall\'elenco di analisi + Le cartelle di download e della cache musicale sono incluse automaticamente. + La tua cartella di download è inclusa automaticamente. + La cartella della cache musicale è inclusa automaticamente. + Salvataggio… + Impossibile salvare le cartelle. Le modifiche sono ancora qui; riprova. + + %1$d cartella + %1$d cartelle + %1$d cartelle + + Formato musica scaricata + Codec e contenitore preferiti per i download. Attuale: %1$s + Qualità musica scaricata + + Formato musica in streaming + Codec e contenitore preferiti per lo streaming musicale. Attuale: %1$s + Qualità musica in streaming + Attuale: %1$s + Attiva cache musicale + Salva l\'audio in streaming in locale per velocizzare gli ascolti successivi. + Cartella cache + Attuale: %1$s + Attuale: cache predefinita dell\'app + Limite dimensione cache + Nessun limite + Limite attuale: %1$d MB + Imposta la dimensione massima della cache in MB. Inserisci 0 o lascia vuoto per nessun limite. + 500 + La dimensione deve essere 0 o un numero positivo. + Attiva riproduzione continua + Continua la riproduzione con brani consigliati quando la coda finisce. + Porta del server proxy di riproduzione + Consenti controllo remoto + Consenti agli altri dispositivi sulla stessa rete di controllare la riproduzione quando si connettono. + Nome del dispositivo per il controllo remoto + Il nome che gli altri dispositivi vedono quando trovano questo. Attuale: %1$s + Scegli un nome riconoscibile da mostrare agli altri dispositivi della tua rete quando trovano questa istanza di Spotube. + es. Spotube del soggiorno + nome host del dispositivo + Porta usata dal server proxy di riproduzione. Attuale: %1$d + Scegli una porta tra 1 e 65535. + 14769 + Inserisci un numero intero valido. + La porta deve essere compresa tra 1 e 65535. + + Blacklist + Gestisci brani e artisti in blacklist + + Riduci nell\'area di notifica + Mantieni Spotube in esecuzione nell\'area di notifica quando la finestra è ridotta a icona. + Rich Presence di Discord + Mostra lo stato di riproduzione attuale su Discord dal desktop. + + Controlla automaticamente gli aggiornamenti + Cerca nuove versioni di Spotube in background. + + Gestisci plugin + Gestisci i tuoi plugin + Plugin %1$s predefinito + Metadati + Audio + Testi + Scrobbling + Nessun plugin selezionato + Cambia + Seleziona + Selezionato + Cancella selezione + Nessun plugin disponibile + Plugin %1$s + Cancella + + Configura + Gestione plugin + Inserisci un URL + L\'URL deve iniziare con http:// o https:// + Impossibile scaricare il plugin + Nessun plugin installato + Incolla un URL di download o scegli un file qui sopra. + + %1$d plugin installato + %1$d di plugin installati + %1$d plugin installati + + Installa un plugin + https://example.com/plugin.smplug + Scarica + Installa da file (.smplug) + Attivo + v%1$s + Integrato + Rimuovi plugin + Accedi + Esci + Scarica da URL + Installa da file + Installa + + • %1$s + Autorizzazioni richieste + Questo plugin non richiede autorizzazioni speciali. + Installato e fornito + Installato + Fornito + API %1$s → %2$s + Archiviazione permanente + Leggere e scrivere dati su disco + Richieste di rete + Inviare e ricevere dati tramite Internet + WebView + Mostrare contenuti web all\'interno dell\'app + Jam di gruppo + Broker MQTT + Host del broker + %1$s:%2$d + Porta del broker + Usa TLS + Nome utente (facoltativo) + Password (facoltativa) + Prefisso ID client + Usato per identificare questa istanza di Spotube + Crittografa la connessione al broker + Verifica le impostazioni attuali del broker prima di avviare una stanza. + Verifica connessione + Verifica in corso… + Connesso in %1$d ms + Non riuscito: %1$s + Broker di esempio: configurane uno tuo per l\'hosting autonomo + Connessione + Credenziali + Identità + Diagnostica + + + Jam di gruppo + Crea + Partecipa + Crea una stanza, poi condividi il codice con i tuoi amici. + Crea stanza + Entra nella stanza + Connessione… + Codice stanza + ABC123 + Broker: %1$s + Configura un broker nelle Impostazioni per ospitare una stanza o parteciparvi. + Impostazioni broker + Connesso + Riconnessione… + Copia codice + Copiato! + Condividi + Partecipanti (%1$d) + Host + Ascoltatore + In attesa di ascoltatori… + Rimuovi + Banna + Gestisci + Esci dalla stanza + Impossibile connettersi + Ignora + Impossibile creare la sessione Jam + Impossibile partecipare alla sessione Jam + Inserisci un codice stanza di sei caratteri composto da lettere e cifre. + Unisciti alla mia Jam su Spotube con il codice: %1$s + + Vai all\'album + Timer di spegnimento + Dettagli brano + Riproduci + Pausa + Nessuna riproduzione in corso + Aggiungi alla coda + + Ignora + Aggiorna + Spotube %1$s è disponibile + Scopri le novità di questa versione nella pagina dei download di Spotube. + %1$s %2$s è disponibile + Attualmente installata: %1$s + Scopri le novità di questo aggiornamento nella pagina del plugin. + + Salta + Avanti + Indietro + Inizia + Spotube + Musica, senza vincoli. + Un player open source per un mondo di musica.\nTutto da esplorare. Tutto da rendere tuo. + Aiutaci a mantenere vivo questo progetto. + Spotube è creato da persone, non da un\'azienda in abbonamento. Se diventa parte della tua quotidianità, un piccolo contributo ci aiuta a dargli la cura che merita.\n\nGrazie per fare spazio al software indipendente. + Inizia con un plugin di catalogo per trovare brani e playlist. Scegli un plugin audio per riprodurli. Testi e cronologia di ascolto sono facoltativi. Scopri un plugin qui sotto, controlla le sue autorizzazioni, installalo e poi selezionalo qui. + Scopri plugin + Le tue fonti + Riprova + Altri plugin + Nessun nuovo plugin trovato. Puoi anche installarne uno da un link o da un file .smplug qui sopra. + La ricerca dei plugin non è disponibile al momento. Riprova oppure installa da un link o da un file. + Benvenuto + Supporto + Plugin + Guida + + Come usare Spotube + Quattro rapidi passaggi e sei pronto ad ascoltare. + Accedi + Collega il tuo account Spotify o scegli il tuo plugin di catalogo. + Cerca e riproduci + Trova qualsiasi brano, album o artista, poi premi play. + Ascolta offline + Scarica i brani per continuare ad ascoltarli senza connessione. + Segui il testo + Apri i testi sincronizzati dal player mentre ascolti un brano. + + + La tua musica. Le tue fonti. Le tue regole. + Un player musicale gratuito e open source, creato da una community che ama semplicemente la musica. + Open source + Nessuna pubblicità + Nessuna telemetria + Basato su plugin + Iniziamo + + + I sostenitori ci fanno andare avanti + Creato con passione. Tenuto in vita da te. + Spotube è gratuito al 100%, libero e open source: niente pubblicità, niente abbonamenti, nessun tracciamento. Ogni funzione esiste perché persone come te hanno scelto di contribuire allo sviluppo. + Patreon + Sostenitore mensile + Sostieni lo sviluppo continuo e l\'infrastruttura + Buy Me a Coffee + Un caffè una tantum + Alimenta una sessione di programmazione notturna con una piccola mancia + Open Collective + Fondo della community + Sostegno finanziario trasparente e gestito dalla community + Grazie di cuore per credere nella musica libera. + Il sostegno è del tutto facoltativo: Spotube resta comunque gratuito. + Sostieni + Apri %1$s + + + Sostieni Spotube + Sostieni Spotube + Spotube è gratuito, libero e open source: niente pubblicità, niente abbonamenti, nessun tracciamento. Se ti rende felice, valuta di sostenere il progetto. + Puoi disattivare questi promemoria in Impostazioni → Supporto. + Forse più tardi + Promemoria di sostegno + Mostra un discreto promemoria per sostenere il progetto ogni 7 giorni. + + + Architettura modulare + Scegli da dove arriva la tua musica. + Spotube separa la ricerca nel catalogo, lo streaming audio e i testi sincronizzati. Configura i tuoi motori qui sotto o cambiali in qualsiasi momento. + Motore metadati + Gestisce scoperta musicale, ricerca, elenchi di brani e playlist. + Motore audio + Recupera e decodifica i flussi audio da riprodurre. + Motore testi + Trasmette testi karaoke sincronizzati in tempo reale. + Motore scrobbling + Registra la cronologia di ascolto su Last.fm o ListenBrainz. + Attivo + Integrato + Pronto + Facoltativo + Seleziona un motore + Motori installati + Imposta come predefinito + NewPipe YouTube e LRCLib Lyrics sono integrati e pronti per lo streaming fin da subito. + Gestisci o aggiungi nuovi plugin della community in qualsiasi momento in Impostazioni → Plugin. + + + Architettura retro-moderna + Puro Hi-Fi, zero intermediari + Ecco come Spotube gestisce la riproduzione decentralizzata direttamente sul tuo dispositivo. + PEER-TO-PEER DIRETTO • OPUS 256 KBPS + Ora blu + Spotube · dimostrazione del player + Un po\' di musica per i momenti di mezzo. + 1. Ricerca decentralizzata + Le ricerche vanno direttamente al plugin di catalogo scelto. Spotube non profila mai i tuoi gusti. + 2. Risoluzione diretta degli stream + L\'audio arriva direttamente dai server dei contenuti al tuo dispositivo. Nessun proxy intermedio né raccolta remota di dati. + 3. DSP e riproduzione in locale + Decodificato e salvato in cache in locale, con equalizzatore e download offline facoltativo. + 4. Testi sincronizzati e Jam + I testi karaoke seguono il tempo di riproduzione, con controllo remoto via LAN e Jam di gruppo. + Nessun server intermediario. La riproduzione avviene direttamente sul tuo dispositivo: privacy by design. + + + Scegli un dispositivo + %1$s: «%2$s» + Riproduci + Aggiungi alla coda + Riproduci dopo + Questo dispositivo + Su questo dispositivo + Dispositivo remoto + Dispositivo remoto (%1$s) + Sul dispositivo connesso + + + Connessione non riuscita + Connessione persa + Invio non riuscito + Ricerca non riuscita: %1$s + Impossibile connettersi: %1$s + + + Dispositivi + Aggiorna + Disconnetti + Connessione... + Ricerca di dispositivi sulla rete... + Nessun dispositivo trovato + Assicurati che sull\'altro dispositivo sia attivo \"Consenti controllo remoto\" nelle impostazioni. + Dispositivo sconosciuto + Risoluzione... + + + Controllo remoto + Disconnesso + Errore di connessione: %1$s + Brano sconosciuto + Artista sconosciuto + Volume + Rimuovi dalla coda + Altre opzioni + + + Dove riprodurre? + Hai una sessione Jam attiva. Scegli dove riprodurre questa raccolta. + Riproduci qui + Suggerisci alla Jam + + + Nessun broker Jam configurato + L\'host del broker è vuoto + Connessione non riuscita + Connessione persa + + + Broker + anonimo + + + Aggiunto alla coda della Jam + + %1$d brano aggiunto alla coda della Jam + %1$d brani aggiunti alla coda della Jam + %1$d brani aggiunti alla coda della Jam + + + + Richiesta di controllo remoto + \"%1$s\" vuole controllare la riproduzione su questo dispositivo. + Rifiuta + Consenti + Consenti sempre + + + Risultati della ricerca + Tutto + Brani + Utenti + Nessun tipo di ricerca disponibile + Inizia a digitare per cercare + Inizia a digitare per cercare brani + Nessun risultato trovato + Mostra tutto + Ricerca in corso + Caricamento brani... + Caricamento album... + Caricamento artisti... + Caricamento playlist... + Caricamento utenti... + Nessun album trovato + Nessun artista trovato + Nessuna playlist trovata + Nessun utente trovato + Frugando tra le casse di dischi... + Esplorando il cosmo... + Scaldando le casse... + Spolverando i vinili... + Consultando gli dei della musica... + Sintonizzando le antenne... + Sfogliando i dischi... + Impossibile caricare le funzionalità di ricerca + Ricerca non riuscita + Impossibile cercare i brani + Impossibile cercare gli album + Impossibile cercare gli artisti + Impossibile cercare le playlist + Impossibile cercare gli utenti + + + Sfoglia + + + Sincronizzati + Semplici + Nessun testo sincronizzato disponibile + Nessun testo semplice disponibile + + + %1$s in %2$s + %1$d kbps + %1$s kHz • %2$d can. + Nessun plugin di metadati selezionato + + + Indietro + Riprova + Chiudi finestra di dialogo + Caricamento... + Sconosciuto + Errore sconosciuto + + + Utente + Playlist + + + Ci dispiace davvero + Qualcosa non sta funzionando in questo momento. La tua esperienza è importante per noi e stiamo facendo del nostro meglio per risolvere il problema. Riprova o controlla i dettagli dell\'errore qui sotto. + Dettagli dell\'errore + Copia negli appunti + Visualizza log completo + Log completo dell\'errore + + + Riproduzione casuale + Riproduci in ordine casuale + Segui + Non seguire più + Modifica playlist + Di %1$s + + + Opzioni brano + Aggiungi alla Jam + Avvia radio + Riproduci dopo + Sposta come successivo + Rimuovi dalla coda + Rimuovi dai preferiti + Salva nei preferiti + Aggiungi alla playlist + Scarica + Condividi + + + Originale + Titolo + Artista + Album + Durata + Ordina + Nessun brano trovato + Filtra + Azioni multiple + Scarica tutto + Scarica %1$d + Aggiungi tutto alla coda + Aggiungi %1$d alla coda + Riproduci tutto dopo + Riproduci %1$d dopo + Aggiungi tutto alla playlist + Aggiungi %1$d alla playlist + Aggiungi tutto alla Jam + Aggiungi %1$d alla Jam + +%1$d altri + + + Nessun URL da caricare + Torna indietro nella cronologia del browser + Vai avanti nella cronologia del browser + + + Filtra blacklist... + Nessun elemento in blacklist + Nessun elemento in blacklist corrisponde a \'%1$s\' + Brani + Brano + Aggiungi alla blacklist + Rimuovi dalla blacklist + + + Nessun metadato del brano + Impossibile risolvere l\'URL dello stream + Impossibile risolvere l\'URL dello stream. Prova prima a riprodurre il brano. + + + Brani salvati + Tu + + + Segui già + Brani più popolari + Riproduci i brani più popolari + Aggiungi i brani più popolari alla coda + Artisti simili + Playlist in evidenza + Impossibile caricare la panoramica dell\'artista + Follower non disponibili + %1$s K follower + %1$s Mln follower + %1$s Mld follower + + %1$d follower + %1$d follower + %1$d follower + + + + Mi piace + Non mi piace più + Titolo brano segnaposto + Artisti segnaposto + Album sconosciuto + Posizione + Volume + Attiva riproduzione casuale + Disattiva riproduzione casuale + Precedente + Successivo + Riproduci o metti in pausa + Ripetizione disattivata + Ripeti brano + Ripeti tutto + Disattiva audio + Riattiva audio + Altre opzioni + Opzioni del player + Comprimi player + Espandi testo + In riproduzione + Testo + Fonte alternativa + Fonte alternativa + Scarica + Scaricato + Download in corso... + Già scaricato + Nuovo tentativo di download... + Download di %1$s + La riproduzione si interromperà tra %1$s. + Metti in pausa la riproduzione dopo il numero di minuti selezionato. + Minuti + Scegli tra 1 e 600 minuti + Annulla timer + Avvia + L\'URL di riproduzione è stato risolto con impostazioni di streaming diverse. Lo stream evidenziato corrisponde alle tue preferenze attuali; l\'URL attivo verrà aggiornato alla prossima richiesta di stream. + Fonte audio abbinata + Caricamento della fonte abbinata… + Una fonte remota abbinata non è necessaria durante la riproduzione del file locale in cache. + Nessuna fonte abbinata disponibile. + Nessuna fonte audio abbinata in cache per questo brano. + I dettagli dello stream non sono ancora in cache. Riproduci prima il brano per caricarli. + Impossibile caricare i dettagli del brano. + Stream disponibili + Questo brano viene riprodotto dalla cache locale. Le varianti di stream non vengono usate per questa riproduzione. + Caricamento degli stream… + Nessuno stream disponibile per questa fonte. + Stream attualmente selezionato + Corrisponde alle tue preferenze di formato e qualità dello streaming + URL di riproduzione attivo + Riprodotto dalla cache locale + Brano dei metadati + Titolo + ID + Album + Affidabilità + URI esterno + ID album + Tipo di album + Data di uscita dell\'album + Numero di brani dell\'album + Generi dell\'album + Durata + Numero del brano + Numero del disco + Esplicito + Popolarità + ISRC + URL delle miniature + + + Mostra/nascondi barra laterale + + + Fonti alternative + Nessun brano in riproduzione + Ricerca di fonti alternative... + Nessuna fonte alternativa trovata + Affidabilità: %1$d% + Fonte attiva + + + Espelli + Rimuovilo dalla sessione + Espellilo e impediscigli di rientrare + Rimuovi suggerimenti + Rimuovi tutti i brani che ha aggiunto alla coda + Riordina + Rimuovi dalla coda + %1$s • Aggiunto da %2$s + + + Sconosciuto + Caricamento playlist... + Aggiungi brani + Aggiungi brani alla playlist + Cerca brani... + Cerca + Nessun brano trovato + Cerca brani da aggiungere + Fine + Aggiungi + Aggiunto + + + Caricamento album... + + + Aggiungi a una tua playlist + Cerca + Cerca nelle tue playlist... + Caricamento delle tue playlist... + Non hai ancora nessuna playlist.\nCreane una per aggiungere brani. + Crea playlist + Modifica playlist + Crea + Nome + Nome della playlist + Descrizione + Descrizione (facoltativa) + Pubblica + Chiunque può vedere questa playlist + Collaborativa + Altre persone possono aggiungere brani a questa playlist + Immagine di copertina + Copertina della playlist + Nessuna immagine + Cambia immagine + Scegli immagine + + + Installa + Sostituisci + plugin.json non trovato nel file zip + manifest.zipline.json non trovato nel file zip + Formato di plugin.json non valido: %1$s + Impossibile leggere il plugin: %1$s + Impossibile installare il plugin: %1$s + È disponibile una versione più recente di %1$s. Conferma l\'aggiornamento prima di installarla. + %1$s è già installato. Conferma la sostituzione prima di installarlo. + I plugin integrati sono già inclusi e non possono essere aggiunti di nuovo. + I plugin integrati non possono essere rimossi. + %1$s dichiara un apiVersion non valido (%2$s). È previsto un versionamento semantico compatibile con %3$s. + La versione API dei plugin di Spotube %1$s non è valida. + %1$s è destinato all\'API plugin %2$s, ma Spotube supporta l\'API plugin %3$s con modifiche incompatibili. Installa un plugin creato per la stessa versione principale dell\'API. + + + API del plugin non compatibile + Installare il plugin? + %1$s verrà aggiunto ai plugin installati. + Aggiornare il plugin? + È installata una versione precedente (%1$s). Aggiornare a %2$s? + Sostituire il plugin installato? + %1$s usa una versione semantica non valida (%2$s) e non può essere confrontato come aggiornamento. Sostituire comunque il plugin installato? + La versione installata (%1$s) non può essere confrontata tramite versionamento semantico. Sostituirla con %2$s? + La versione %1$s è già installata. Sostituire il plugin esistente con la copia fornita? + La versione installata %1$s è più recente della versione fornita %2$s. Sostituirla comunque? + Sostituire il plugin installato con la copia fornita? + + + %1$s: + Versione + Versione API + Autore + Licenza + Funzionalità + Abilità + Repository + Contatto + Segnala bug + Sostieni %1$s + + + Ufficiale + Verificato + Stelle su GitHub + URL del repository GitHub + + + Nessuna versione trovata + Seleziona versione + Seleziona una versione + Pre-release + Note di rilascio + + + Home + Cerca + Sul dispositivo + Connect + Dispositivi + + + Nascondi finestra + Mostra finestra + Brano successivo + Brano precedente + Riproduzione casuale + Ripetizione + Ripetizione: NO + Ripetizione: UNO + Ripetizione: TUTTI + Volume: %1$d% + Volume + + Volume - + Disattiva audio + Riattiva audio + Metti Mi piace al brano + Togli Mi piace al brano + Esci + + + Riduci a icona + Ingrandisci + Ripristina + Chiudi + + + Riproduzione + Controlli di riproduzione multimediale + + + Sfoglia + In riproduzione + In evidenza + Brani salvati + Brani più popolari + diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml new file mode 100644 index 00000000..a915d409 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -0,0 +1,920 @@ + + + + 再生キュー + キューをクリア + キューを絞り込み... + キューに曲がありません + + 曲、アーティスト、アルバムを検索... + 履歴をすべて消去 + 検索... + + + ライブラリ + ライブラリ + プレイリスト + アルバム + アーティスト + ローカルの曲 + ダウンロード + 保存したプレイリストを検索… + 保存したアルバムを検索… + 保存したアーティストを検索… + フォルダや曲を検索… + ライブラリを絞り込み + 絞り込みを閉じる + プレイリストを作成 + ローカルの曲を更新 + グリッド表示に切り替え + リスト表示に切り替え + 保存したプレイリストはありません + 「%1$s」に一致するプレイリストはありません + 保存したアルバムはありません + 「%1$s」に一致するアルバムはありません + 保存したアーティストはありません + 「%1$s」に一致するアーティストはありません + + + フォルダ + フォルダ + %1$s • %2$s + + %1$d曲 + + + %1$d個のフォルダ + + フォルダ一覧に戻る + フォルダを再生 + オーディオへのアクセスが必要です + デバイス内の音楽を表示するには、オーディオへのアクセスを許可してください。 + アクセスを許可 + ローカルフォルダが見つかりません + このフォルダには曲がありません + 一致するフォルダや曲はありません + ローカルメディアを更新できませんでした + + + %1$d件進行中 • %2$d件完了 + 完了済みを消去 + ダウンロードはまだありません + ダウンロード中 %1$d% + 待機中 + 完了 + キャンセル済み + 失敗 + 失敗: %1$s + ダウンロードをキャンセル + ダウンロードを再試行 + ダウンロードを削除 + + + アーティスト + ブラックリスト登録済み + お気に入りの曲 + 保存した曲 + 不明なアーティスト + 曲名 + アーティスト名 + + 選択済み + + 設定 + + プラグイン + 言語と地域 + 外観 + 再生 + ダウンロード + キャッシュ + デスクトップ + アップデート + サポート + このアプリについて + + 閉じる + 保存 + キャンセル + + 言語 + アプリの表示言語。現在: %1$s + システムのデフォルト + 国 + コンテンツと利用可否に使用する国・地域。現在: %1$s + %1$s (%2$s) + + テーマ + Spotubeをシステムの外観にどう合わせるかを選択します。現在: %1$s + ライト + ダーク + システム + + サーフェス + アプリの背景素材。現在: %1$s + ウォーター + アイス + クローム + アクリル + ガラス + + アクセントカラー + 現在のパレット: %1$s + アクセントカラー + アクセントパレットを選び、ライトとダークの両テーマでプレビューできます。 + ライト + ダーク + プレビュー + ボタン + グリーンゴブリン + エレクトリックバイオレット + オーシャンシアン + サンセットオレンジ + ローズガーデン + ミッドナイトブルー + メタリックスレート + + ルック&フィール + サーフェス素材とアクセント。現在: %1$s · %2$s + ルック&フィール + サーフェス素材を選び、それに合うアクセントを選択してください。どの選択肢も厳選された組み合わせです。 + サーフェス + アクセント + セカンダリ + + パレット + Millenniumアクセントパレット。現在: %1$s + パレット + Millenniumアクセントパレットを選び、ライトとダークでプレビューできます。 + パレット + Millenniumパレットとアクセント。現在: %1$s + ブルー + オレンジ + ライム + + ダウンロードフォルダの変更 + 現在のフォルダ: %1$s + 空欄にするとデフォルトの音楽フォルダを使用します。 + C:\Music\Spotube + ローカルメディアフォルダ + デスクトップでスキャンするカスタムフォルダ。追加済み: %1$d + カスタムフォルダはまだ追加されていません + ローカル音楽ライブラリ用のカスタムフォルダ。 + 管理 + フォルダを追加 + フォルダを削除 + カスタムフォルダは追加されていません。 + %1$sをスキャン対象から削除 + ダウンロードフォルダと音楽キャッシュフォルダは自動的に含まれます。 + ダウンロードフォルダは自動的に含まれます。 + 音楽キャッシュフォルダは自動的に含まれます。 + 保存中… + フォルダを保存できませんでした。変更内容は保持されていますので、もう一度お試しください。 + + %1$d個のフォルダ + + ダウンロードの音楽フォーマット + ダウンロードに使用するコーデックとコンテナ。現在: %1$s + ダウンロードの音質 + + ストリーミングの音楽フォーマット + ストリーミング再生に使用するコーデックとコンテナ。現在: %1$s + ストリーミングの音質 + 現在: %1$s + 音楽のキャッシュを有効化 + ストリーミングした音声をローカルに保存し、再度の再生を高速化します。 + キャッシュフォルダ + 現在: %1$s + 現在: アプリのデフォルトキャッシュ + キャッシュサイズの上限 + 上限なし + 現在の上限: %1$d MB + キャッシュの最大サイズをMB単位で設定します。0を入力するか空欄にすると上限なしになります。 + 500 + サイズは0または正の数で指定してください。 + エンドレス再生を有効化 + キューが終わったら、おすすめの曲で再生を続けます。 + 再生プロキシサーバーのポート + リモート操作を許可 + 同じネットワーク上の他のデバイスが接続したときに再生を操作できるようにします。 + リモート操作用のデバイス名 + 他のデバイスがこのデバイスを検出したときに表示される名前。現在: %1$s + ネットワーク上の他のデバイスがこのSpotubeを検出したときに表示される、わかりやすい名前を付けてください。 + 例: リビングのSpotube + デバイスのホスト名 + 再生プロキシサーバーが使用するポート。現在: %1$d + 1〜65535のポートを選択してください。 + 14769 + 有効な整数を入力してください。 + ポートは1〜65535の範囲で指定してください。 + + ブラックリスト + ブラックリストに登録した曲とアーティストを管理 + + トレイに最小化 + ウィンドウを最小化したとき、Spotubeをシステムトレイで実行し続けます。 + Discordリッチプレゼンス + デスクトップで現在の再生状況をDiscordに表示します。 + + アップデートを自動確認 + Spotubeの新しいリリースをバックグラウンドで確認します。 + + プラグインを管理 + プラグインを管理します + デフォルトの%1$sプラグイン + メタデータ + オーディオ + 歌詞 + Scrobble + プラグインが選択されていません + 変更 + 選択 + 選択済み + 選択を解除 + 利用できるプラグインはありません + %1$sプラグイン + クリア + + 構成 + プラグインマネージャー + URLを入力してください + URLは http:// または https:// で始まる必要があります + プラグインをダウンロードできませんでした + プラグインはまだインストールされていません + 上でダウンロードURLを貼り付けるか、ファイルを選択してください。 + + %1$d個のプラグインをインストール済み + + プラグインをインストール + https://example.com/plugin.smplug + ダウンロード + ファイルからインストール (.smplug) + 有効 + v%1$s + 内蔵 + プラグインを削除 + ログイン + ログアウト + URLからダウンロード + ファイルからインストール + インストール + + • %1$s + 要求される権限 + このプラグインに特別な権限は必要ありません。 + インストール済み vs 提供版 + インストール済み + 提供版 + API %1$s → %2$s + 永続ストレージ + ディスクへのデータの読み書き + ネットワークリクエスト + インターネット経由でのデータの送受信 + WebView + アプリ内でのWebコンテンツの表示 + グループジャム + MQTTブローカー + ブローカーのホスト + %1$s:%2$d + ブローカーのポート + TLSを使用 + ユーザー名 (任意) + パスワード (任意) + クライアントIDのプレフィックス + このSpotubeを識別するために使用されます + ブローカーとの接続を暗号化します + ルームを開始する前に、現在のブローカー設定を確認します。 + 接続をテスト + テスト中… + %1$d msで接続しました + 失敗: %1$s + 仮のブローカーです。セルフホストするには独自のブローカーを設定してください + 接続 + 認証情報 + 識別情報 + 診断 + + + グループジャム + 作成 + 参加 + ルームを作成して、コードを友達に共有しましょう。 + ルームを作成 + ルームに参加 + 接続中… + ルームコード + ABC123 + ブローカー: %1$s + ルームを主催または参加するには、設定でブローカーを設定してください。 + ブローカー設定 + 接続済み + 再接続中… + コードをコピー + コピーしました! + 共有 + 参加者 (%1$d) + ホスト + リスナー + リスナーを待っています… + 削除 + BAN + 管理 + ルームから退出 + 接続できませんでした + 閉じる + ジャムセッションを作成できませんでした + ジャムセッションに参加できませんでした + 英数字6文字のルームコードを入力してください。 + コード %1$s で私のSpotubeジャムに参加しよう + + アルバムへ移動 + スリープタイマー + 曲の詳細 + 再生 + 一時停止 + 再生中の曲はありません + キューに追加 + + 無視 + アップデート + Spotube %1$s が利用可能です + このリリースの新機能はSpotubeのダウンロードページでご確認ください。 + %1$s %2$s が利用可能です + インストール済み: %1$s + このアップデートの新機能はプラグインのページでご確認ください。 + + スキップ + 次へ + 戻る + はじめる + Spotube + 音楽を、しがらみなく。 + 音楽の世界へ広がる、オープンソースのプレーヤー。\n自由に探して、自分らしく楽しもう。 + このプロジェクトを支えてください。 + Spotubeはサブスクリプションビジネスではなく、人の手でつくられています。日々の暮らしの一部になったなら、少しのご支援が、このアプリにふさわしい手入れを続ける力になります。\n\n独立したソフトウェアに居場所をつくってくださり、ありがとうございます。 + まずはカタログプラグインで曲やプレイリストを探せるようにしましょう。次に、再生用のオーディオプラグインを選びます。歌詞と再生履歴は任意です。下からプラグインを見つけて権限を確認し、インストールしてからここで選択してください。 + プラグインを探す + ソース + 再試行 + その他のプラグイン + 新しいプラグインは見つかりませんでした。上のリンクや.smplugファイルからインストールすることもできます。 + 現在プラグインを検索できません。再試行するか、リンクまたはファイルからインストールしてください。 + ようこそ + サポート + プラグイン + ガイド + + Spotubeの使い方 + 簡単な4ステップで、すぐに聴けます。 + サインイン + Spotifyアカウントを連携するか、お好みのカタログプラグインを選んでください。 + 検索して再生 + 曲、アルバム、アーティストを探して再生しましょう。 + オフラインで聴く + 曲をダウンロードすれば、接続がなくても再生できます。 + 歌詞を表示 + 曲の再生中に、プレーヤーから同期歌詞を開けます。 + + + あなたの音楽。あなたのソース。あなたのルール。 + 音楽を愛するコミュニティがつくった、無料でオープンソースの音楽プレーヤー。 + オープンソース + 広告なし + テレメトリなし + プラグインで拡張 + はじめよう + + + サポーターの皆さんに支えられています + 情熱でつくり、あなたが支える。 + Spotubeは100%無料の自由なオープンソースソフトウェアです。広告も、サブスクリプションも、トラッキングもありません。すべての機能は、あなたのような人たちが開発を支えてくれたからこそ存在しています。 + Patreon + 毎月のスポンサー + 継続的な開発とインフラを支援 + Buy Me a Coffee + 1回限りのコーヒー + ちょっとしたチップで深夜のコーディングを応援 + Open Collective + コミュニティ基金 + 透明性が高く、コミュニティが運営する資金支援 + オープンな音楽を信じてくださり、心から感謝します。 + 支援はまったくの任意です。どちらでもSpotubeは無料のままです。 + 支援する + %1$sを開く + + + Spotubeを支援 + Spotubeを支援 + Spotubeは無料の自由なオープンソースソフトウェアです。広告も、サブスクリプションも、トラッキングもありません。気に入っていただけたら、プロジェクトへのご支援をご検討ください。 + このリマインダーは「設定 → サポート」でオフにできます。 + あとで + 支援のリマインダー + 7日ごとに、プロジェクトへの支援をさりげなくお知らせします。 + + + モジュール式アーキテクチャ + 音楽をどこから届けるか、選びましょう。 + Spotubeはカタログの検索、オーディオのストリーミング、同期歌詞をそれぞれ分離しています。下でエンジンを設定でき、いつでも切り替えられます。 + メタデータエンジン + 音楽の発見、検索、曲リスト、プレイリストを担います。 + オーディオエンジン + 再生するオーディオストリームを取得してデコードします。 + 歌詞エンジン + 時間同期されたカラオケ歌詞をリアルタイムで表示します。 + Scrobbleエンジン + 再生履歴をLast.fmやListenBrainzに記録します。 + 有効 + 内蔵 + 準備完了 + 任意 + エンジンを選択 + インストール済みのエンジン + デフォルトに設定 + NewPipe YouTubeとLRCLib Lyricsは内蔵されており、すぐにストリーミングできます。 + コミュニティプラグインの管理や追加は、いつでも「設定 → プラグイン」から行えます。 + + + レトロモダンなアーキテクチャ + 純粋なHi-Fi、仲介者ゼロ + Spotubeがあなたのマシン上で分散型の再生を直接制御する仕組みをご紹介します。 + ダイレクトP2P • 256 KBPS OPUS + ブルーアワー + Spotube · プレーヤーのデモ + 合間のひとときに、少しの音楽を。 + 1. 分散型の検索 + 検索クエリは選択したカタログプラグインに直接送られます。Spotubeがあなたの好みをプロファイリングすることはありません。 + 2. ストリームを直接解決 + オーディオはコンテンツサーバーからあなたのマシンへ直接ストリーミングされます。プロキシの経由やリモートでのデータ収集はありません。 + 3. ローカルDSPと再生 + ローカルでデコード・キャッシュされ、イコライザーやオフライン用ダウンロード (任意) も使えます。 + 4. 同期歌詞とジャム + カラオケ歌詞が再生位置に合わせて流れ、LANでのリモート操作やグループジャムにも対応します。 + 仲介サーバーはありません。再生はすべてあなたのデバイス上で行われ、設計段階からプライバシーを守ります。 + + + デバイスを選択 + %1$s:「%2$s」 + 再生 + キューに追加 + 次に再生 + このデバイス + このデバイスで + リモートデバイス + リモートデバイス (%1$s) + 接続中のデバイスで + + + 接続に失敗しました + 接続が切断されました + 送信に失敗しました + 検出に失敗しました: %1$s + 接続できませんでした: %1$s + + + デバイス + 更新 + 切断 + 接続中... + ネットワーク上のデバイスを検索中... + デバイスが見つかりません + 相手のデバイスの設定で「リモート操作を許可」が有効になっていることを確認してください。 + 不明なデバイス + 解決中... + + + リモート操作 + 切断されました + 接続エラー: %1$s + 不明な曲 + 不明なアーティスト + 音量 + キューから削除 + その他のオプション + + + どこで再生しますか? + ジャムセッションが進行中です。このコレクションを再生する場所を選んでください。 + ここで再生 + ジャムに提案 + + + ジャム用ブローカーが設定されていません + ブローカーのホストが空です + 接続に失敗しました + 接続が切断されました + + + ブローカー + anonymous + + + ジャムのキューに追加しました + + %1$d曲をジャムのキューに追加しました + + + + リモート操作のリクエスト + 「%1$s」がこのデバイスの再生を操作しようとしています。 + 拒否 + 許可 + 常に許可 + + + 検索結果 + すべて + 曲 + ユーザー + 利用できる検索タイプはありません + 入力して検索 + 入力して曲を検索 + 結果が見つかりません + すべて表示 + 検索中 + 曲を読み込み中... + アルバムを読み込み中... + アーティストを読み込み中... + プレイリストを読み込み中... + ユーザーを読み込み中... + アルバムが見つかりません + アーティストが見つかりません + プレイリストが見つかりません + ユーザーが見つかりません + レコード棚を掘り返しています... + 宇宙を探索しています... + スピーカーをウォームアップしています... + レコードのほこりを払っています... + 音楽の神様にお伺いを立てています... + アンテナを調整しています... + レコードをめくっています... + 検索機能を読み込めませんでした + 検索できませんでした + 曲を検索できませんでした + アルバムを検索できませんでした + アーティストを検索できませんでした + プレイリストを検索できませんでした + ユーザーを検索できませんでした + + + ブラウズ + + + 同期 + テキスト + 同期歌詞はありません + テキスト歌詞はありません + + + %1$s (%2$s) + %1$d kbps + %1$s kHz • %2$d ch + メタデータプラグインが選択されていません + + + 戻る + 再試行 + ダイアログを閉じる + 読み込み中... + 不明 + 不明なエラー + + + ユーザー + プレイリスト + + + 申し訳ありません + 現在、一部の機能が正しく動作していません。ご不便をおかけして申し訳ありません。解決に全力で取り組んでいます。もう一度お試しいただくか、下のエラー詳細をご確認ください。 + エラーの詳細 + クリップボードにコピー + ログ全体を表示 + エラーログ全体 + + + シャッフル + シャッフル再生 + フォロー + フォロー解除 + プレイリストを編集 + 作成者: %1$s + + + 曲のオプション + ジャムに追加 + ラジオを開始 + 次に再生 + 次に移動 + キューから削除 + お気に入りから削除 + お気に入りに保存 + プレイリストに追加 + ダウンロード + 共有 + + + 元の順序 + タイトル + アーティスト + アルバム + 再生時間 + 並べ替え + 曲が見つかりません + 絞り込み + 一括操作 + すべてダウンロード + %1$d曲をダウンロード + すべてキューに追加 + %1$d曲をキューに追加 + すべて次に再生 + %1$d曲を次に再生 + すべてプレイリストに追加 + %1$d曲をプレイリストに追加 + すべてジャムに追加 + %1$d曲をジャムに追加 + 他%1$d組 + + + 読み込むURLがありません + ブラウザの履歴を戻る + ブラウザの履歴を進む + + + ブラックリストを絞り込み... + ブラックリストに登録された項目はありません + 「%1$s」に一致するブラックリスト項目はありません + 曲 + 曲 + ブラックリストに追加 + ブラックリストから削除 + + + 曲のメタデータがありません + ストリームURLを解決できませんでした + ストリームURLを解決できませんでした。先に曲を再生してみてください。 + + + 保存した曲 + あなた + + + フォロー中 + 人気曲 + 人気曲を再生 + 人気曲をキューに追加 + 関連アーティスト + 注目のプレイリスト + アーティストの概要を読み込めませんでした + フォロワー数を取得できません + フォロワー %1$sK人 + フォロワー %1$sM人 + フォロワー %1$sB人 + + フォロワー %1$d人 + + + + お気に入りに追加 + お気に入りから削除 + No-op の曲名 + No-op のアーティスト + 不明なアルバム + シーク + 音量 + シャッフルをオン + シャッフルをオフ + 前へ + 次へ + 再生/一時停止 + リピート: オフ + リピート: 1曲 + リピート: すべて + ミュート + ミュート解除 + その他のオプション + プレーヤーのオプション + プレーヤーを折りたたむ + 歌詞を展開 + 再生中 + 歌詞 + 代替ソース + 代替ソース + ダウンロード + ダウンロード済み + ダウンロード中... + ダウンロード済みです + ダウンロードを再試行中... + %1$sをダウンロード中 + %1$s後に再生を一時停止します。 + 選択した時間 (分) が経過したら再生を一時停止します。 + 分 + 1〜600分の間で選択 + タイマーをキャンセル + 開始 + 再生URLは異なるストリーミング設定で解決されました。ハイライトされたストリームが現在の設定に一致しています。有効なURLは次回のストリームリクエスト時に更新されます。 + 一致したオーディオソース + 一致したソースを読み込み中… + ローカルのキャッシュファイルを再生中は、一致するリモートソースは不要です。 + 一致するソースはありません。 + この曲に一致するオーディオソースはキャッシュされていません。 + ストリームの詳細はまだキャッシュされていません。読み込むには先に曲を再生してください。 + 曲の詳細を読み込めませんでした。 + 利用可能なストリーム + この曲はローカルキャッシュから再生されています。この再生ではストリームのバリエーションは使用されません。 + ストリームを読み込み中… + このソースで利用できるストリームはありません。 + 現在選択中のストリーム + ストリーミングのフォーマットと音質の設定に一致 + 有効な再生URL + ローカルキャッシュから再生 + メタデータの曲 + タイトル + ID + アルバム + 一致度 + 外部URI + アルバムID + アルバムの種類 + アルバムのリリース日 + アルバムの曲数 + アルバムのジャンル + 再生時間 + トラック番号 + ディスク番号 + Explicit + 人気度 + ISRC + サムネイルURL + + + サイドバーの表示切り替え + + + 代替ソース + 再生中の曲はありません + 代替ソースを検索中... + 代替ソースが見つかりません + 一致度: %1$d% + 使用中のソース + + + キック + セッションから退出させます + キックして再参加できないようにします + 提案を削除 + このユーザーがキューに追加した曲をすべて削除します + 並べ替え + キューから削除 + %1$s • %2$sが追加 + + + 不明 + プレイリストを読み込み中... + 曲を追加 + プレイリストに曲を追加 + 曲を検索... + 検索 + 曲が見つかりません + 追加する曲を検索 + 完了 + 追加 + 追加済み + + + アルバムを読み込み中... + + + プレイリストに追加 + 検索 + プレイリストを検索... + プレイリストを読み込み中... + 所有しているプレイリストはまだありません。\nプレイリストを作成して曲を追加しましょう。 + プレイリストを作成 + プレイリストを編集 + 作成 + 名前 + プレイリスト名 + 説明 + 説明 (任意) + 公開 + 誰でもこのプレイリストを見られます + 共同編集 + 他のユーザーもこのプレイリストに曲を追加できます + カバー画像 + プレイリストのカバー + 画像なし + 画像を変更 + 画像を選択 + + + インストール + 置き換え + zipファイル内にplugin.jsonが見つかりません + zipファイル内にmanifest.zipline.jsonが見つかりません + plugin.jsonの形式が無効です: %1$s + プラグインを読み込めませんでした: %1$s + プラグインをインストールできませんでした: %1$s + %1$sの新しいバージョンがあります。インストールする前にアップデートを確認してください。 + %1$sはすでにインストールされています。インストールする前に置き換えを確認してください。 + 内蔵プラグインはすでに含まれているため、再度追加することはできません。 + 内蔵プラグインは削除できません。 + %1$sで宣言されたapiVersion (%2$s) が無効です。%3$sと互換性のあるセマンティックバージョニングが必要です。 + SpotubeのプラグインAPIバージョン %1$s が無効です。 + %1$sはプラグインAPI %2$s を対象としていますが、SpotubeがサポートするのはプラグインAPI %3$s (破壊的変更を含むバージョン) です。同じメジャーAPIバージョン向けにビルドされたプラグインをインストールしてください。 + + + プラグインAPIに互換性がありません + プラグインをインストールしますか? + %1$sがインストール済みプラグインに追加されます。 + プラグインをアップデートしますか? + 古いバージョン (%1$s) がインストールされています。%2$sにアップデートしますか? + インストール済みのプラグインを置き換えますか? + %1$sは無効なセマンティックバージョン (%2$s) を使用しているため、アップデートとして比較できません。それでもインストール済みのプラグインを置き換えますか? + インストール済みのバージョン (%1$s) はセマンティックバージョニングで比較できません。%2$sで置き換えますか? + バージョン %1$s はすでにインストールされています。既存のプラグインを提供されたものに置き換えますか? + インストール済みのバージョン %1$s は提供されたバージョン %2$s より新しいです。それでも置き換えますか? + インストール済みのプラグインを提供されたものに置き換えますか? + + + %1$s: + バージョン + APIバージョン + 作者 + ライセンス + 権限 + 機能 + リポジトリ + 連絡先 + バグを報告 + %1$sを支援 + + + 公式 + 認証済み + GitHubのスター + GitHubリポジトリのURL + + + リリースが見つかりません + リリースを選択 + リリースを選択してください + プレリリース + リリースノート + + + ホーム + 検索 + デバイス内 + Connect + デバイス + + + ウィンドウを隠す + ウィンドウを表示 + 次の曲 + 前の曲 + シャッフル + リピート + リピート: オフ + リピート: 1曲 + リピート: すべて + 音量: %1$d% + 音量 + + 音量 - + ミュート + ミュート解除 + お気に入りに追加 + お気に入りから削除 + 終了 + + + 最小化 + 最大化 + 元に戻す + 閉じる + + + 再生 + メディア再生コントロール + + + ブラウズ + 再生中 + 注目 + 保存した曲 + 人気曲 + diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml new file mode 100644 index 00000000..5464dc07 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -0,0 +1,920 @@ + + + + 대기열 + 대기열 비우기 + 대기열 필터... + 대기열이 비어 있어요 + + 노래, 아티스트, 앨범 검색... + 기록 모두 지우기 + 검색... + + + 내 라이브러리 + 라이브러리 + 플레이리스트 + 앨범 + 아티스트 + 로컬 트랙 + 다운로드 + 저장한 플레이리스트 검색… + 저장한 앨범 검색… + 저장한 아티스트 검색… + 폴더 또는 트랙 검색… + 라이브러리 필터 + 필터 닫기 + 플레이리스트 만들기 + 로컬 트랙 새로고침 + 그리드 보기로 전환 + 목록 보기로 전환 + 저장한 플레이리스트가 없어요 + “%1$s”와(과) 일치하는 플레이리스트가 없어요 + 저장한 앨범이 없어요 + “%1$s”와(과) 일치하는 앨범이 없어요 + 저장한 아티스트가 없어요 + “%1$s”와(과) 일치하는 아티스트가 없어요 + + + 폴더 + 폴더 + %1$s • %2$s + + 트랙 %1$d개 + + + 폴더 %1$d개 + + 폴더로 돌아가기 + 폴더 재생 + 오디오 접근 권한 필요 + 기기의 음악을 둘러보려면 오디오 접근을 허용하세요. + 접근 허용 + 로컬 폴더를 찾을 수 없어요 + 이 폴더에 트랙이 없어요 + 일치하는 폴더나 트랙이 없어요 + 로컬 미디어를 새로고침하지 못했어요 + + + 진행 중 %1$d개 • 완료 %2$d개 + 완료된 항목 지우기 + 아직 다운로드한 항목이 없어요 + 다운로드 중 %1$d% + 대기 중 + 완료됨 + 취소됨 + 실패 + 실패: %1$s + 다운로드 취소 + 다운로드 다시 시도 + 다운로드 삭제 + + + 아티스트 + 차단됨 + 좋아요 표시한 곡 + 저장한 노래 + 알 수 없는 아티스트 + 트랙 제목 + 아티스트 이름 + + 선택됨 + + 설정 + + 플러그인 + 언어 및 지역 + 화면 스타일 + 재생 + 다운로드 + 캐시 + 데스크톱 + 업데이트 + 후원 + 정보 + + 닫기 + 저장 + 취소 + + 언어 + 앱 인터페이스 언어입니다. 현재: %1$s + 시스템 기본값 + 국가 + 콘텐츠 및 이용 가능 여부에 적용되는 국가입니다. 현재: %1$s + %1$s (%2$s) + + 테마 + Spotube가 시스템 화면 모드를 따르는 방식을 선택하세요. 현재: %1$s + 라이트 + 다크 + 시스템 + + 표면 + 앱의 배경 재질입니다. 현재: %1$s + 물결 + 얼음 + 크롬 + 아크릴 + 유리 + + 강조 색상 + 현재 팔레트: %1$s + 강조 색상 + 강조 팔레트를 선택하고 라이트 및 다크 테마에서 미리 확인하세요. + 라이트 + 다크 + 미리보기 + 버튼 + 그린 고블린 + 일렉트릭 바이올렛 + 오셔닉 시안 + 선셋 오렌지 + 로즈 가든 + 미드나이트 블루 + 메탈릭 슬레이트 + + 디자인 & 스타일 + 표면 재질과 강조 색상. 현재: %1$s · %2$s + 디자인 & 스타일 + 표면 재질을 고른 다음 어울리는 강조 색상을 선택하세요. 모든 옵션은 엄선된 조합이에요. + 표면 + 강조 색상 + 보조 + + 팔레트 + Millennium 강조 팔레트. 현재: %1$s + 팔레트 + Millennium 강조 팔레트를 선택하고 라이트 및 다크 모드에서 미리 확인하세요. + 팔레트 + Millennium 팔레트 및 강조 색상. 현재: %1$s + 블루 + 오렌지 + 라임 + + 다운로드 폴더 지정 + 현재 폴더: %1$s + 기본 음악 폴더를 사용하려면 비워 두세요. + C:\Music\Spotube + 로컬 미디어 폴더 + 데스크톱에서 검색할 사용자 지정 폴더. 추가됨: %1$d + 아직 추가한 사용자 지정 폴더가 없어요 + 로컬 음악 라이브러리용 사용자 지정 폴더입니다. + 관리 + 폴더 추가 + 폴더 삭제 + 추가한 사용자 지정 폴더가 없습니다. + 검색 목록에서 %1$s 삭제 + 다운로드 폴더와 음악 캐시 폴더는 자동으로 포함돼요. + 다운로드 폴더는 자동으로 포함돼요. + 음악 캐시 폴더는 자동으로 포함돼요. + 저장 중… + 폴더를 저장하지 못했어요. 변경 사항은 그대로 남아 있으니 다시 시도하세요. + + 폴더 %1$d개 + + 다운로드 음악 형식 + 다운로드에 사용할 코덱 및 컨테이너. 현재: %1$s + 다운로드 음질 + + 스트리밍 음악 형식 + 음악 스트리밍에 사용할 코덱 및 컨테이너. 현재: %1$s + 스트리밍 음질 + 현재: %1$s + 음악 캐시 사용 + 스트리밍한 오디오를 기기에 저장해 다시 재생할 때 더 빠르게 불러와요. + 캐시 폴더 + 현재: %1$s + 현재: 기본 앱 캐시 + 캐시 용량 제한 + 제한 없음 + 현재 제한: %1$d MB + 최대 캐시 용량을 MB 단위로 설정하세요. 제한을 두지 않으려면 0을 입력하거나 비워 두세요. + 500 + 용량은 0 또는 양수여야 해요. + 끝없는 재생 사용 + 대기열이 끝나면 추천 트랙으로 계속 재생해요. + 재생 프록시 서버 포트 + 원격 제어 허용 + 같은 네트워크의 다른 기기가 연결되면 재생을 제어할 수 있도록 허용해요. + 원격 제어 기기 이름 + 다른 기기가 이 기기를 찾을 때 표시되는 이름입니다. 현재: %1$s + 네트워크의 다른 기기가 이 Spotube를 찾을 때 표시할 이름을 정하세요. + 예: 거실 Spotube + 기기 호스트 이름 + 재생 프록시 서버에서 사용하는 포트입니다. 현재: %1$d + 1에서 65535 사이의 포트를 선택하세요. + 14769 + 올바른 정수를 입력하세요. + 포트는 1에서 65535 사이여야 해요. + + 차단 목록 + 차단한 트랙 및 아티스트 관리 + + 트레이로 최소화 + 창을 최소화하면 Spotube를 시스템 트레이에서 계속 실행해요. + Discord Rich Presence + 데스크톱에서 현재 재생 상태를 Discord에 표시해요. + + 자동으로 업데이트 확인 + 백그라운드에서 Spotube 새 버전을 확인해요. + + 플러그인 관리 + 플러그인을 관리하세요 + 기본 %1$s 플러그인 + 메타데이터 + 오디오 + 가사 + 스크로블 + 선택한 플러그인 없음 + 변경 + 선택 + 선택됨 + 선택 해제 + 사용 가능한 플러그인이 없어요 + %1$s 플러그인 + 지우기 + + 구성 + 플러그인 관리자 + URL을 입력하세요 + URL은 http:// 또는 https://로 시작해야 해요 + 플러그인을 다운로드하지 못했어요 + 아직 설치한 플러그인이 없어요 + 위에 다운로드 URL을 붙여넣거나 파일을 선택하세요. + + 플러그인 %1$d개 설치됨 + + 플러그인 설치 + https://example.com/plugin.smplug + 다운로드 + 파일에서 설치(.smplug) + 사용 중 + v%1$s + 기본 제공 + 플러그인 삭제 + 로그인 + 로그아웃 + URL에서 다운로드 + 파일에서 설치 + 설치 + + • %1$s + 요청된 권한 + 이 플러그인에는 특별한 권한이 필요하지 않아요. + 설치된 버전과 제공된 버전 비교 + 설치됨 + 제공됨 + API %1$s → %2$s + 영구 저장소 + 디스크에 데이터 읽기 및 쓰기 + 네트워크 요청 + 인터넷으로 데이터 송수신 + WebView + 앱 안에 웹 콘텐츠 표시 + 그룹 잼 + MQTT 브로커 + 브로커 호스트 + %1$s:%2$d + 브로커 포트 + TLS 사용 + 사용자 이름(선택 사항) + 비밀번호(선택 사항) + 클라이언트 ID 접두사 + 이 Spotube 인스턴스를 식별하는 데 사용돼요 + 브로커 연결 암호화 + 방을 시작하기 전에 현재 브로커 설정을 확인하세요. + 연결 테스트 + 테스트 중… + %1$d ms 만에 연결됨 + 실패: %1$s + 임시 브로커입니다 — 직접 호스팅하려면 브로커를 설정하세요 + 연결 + 인증 정보 + 식별 정보 + 진단 + + + 그룹 잼 + 만들기 + 참여 + 방을 만든 다음 친구들에게 코드를 공유하세요. + 방 만들기 + 방 참여 + 연결 중… + 방 코드 + ABC123 + 브로커: %1$s + 방을 만들거나 참여하려면 설정에서 브로커를 설정하세요. + 브로커 설정 + 연결됨 + 다시 연결 중… + 코드 복사 + 복사됨! + 공유 + 참여자(%1$d) + 호스트 + 리스너 + 리스너를 기다리는 중… + 내보내기 + 차단 + 관리 + 방 나가기 + 연결할 수 없어요 + 닫기 + 잼 세션을 만들지 못했어요 + 잼 세션에 참여하지 못했어요 + 영문자와 숫자로 된 6자리 방 코드를 입력하세요. + 내 Spotube 잼에 참여하세요. 코드: %1$s + + 앨범으로 이동 + 취침 타이머 + 트랙 세부정보 + 재생 + 일시정지 + 재생 중인 항목 없음 + 대기열에 추가 + + 무시 + 업데이트 + Spotube %1$s 버전을 사용할 수 있어요 + 이번 릴리스의 새로운 기능은 Spotube 다운로드 페이지에서 확인하세요. + %1$s %2$s 버전을 사용할 수 있어요 + 현재 설치된 버전: %1$s + 이번 업데이트의 새로운 기능은 플러그인 페이지에서 확인하세요. + + 건너뛰기 + 다음 + 뒤로 + 시작하기 + Spotube + 아무런 조건 없는 음악. + 음악의 세계를 위한 오픈 소스 플레이어.\n마음껏 탐색하고, 나만의 방식으로 꾸며 보세요. + 이 프로젝트가 계속될 수 있도록 도와주세요. + Spotube는 구독 비즈니스가 아닌 사람들이 만들어요. Spotube가 일상의 일부가 되었다면, 작은 후원이 Spotube를 더 정성껏 가꾸는 데 큰 힘이 돼요.\n\n독립 소프트웨어에 자리를 내어 주셔서 감사합니다. + 먼저 카탈로그 플러그인으로 노래와 플레이리스트를 찾으세요. 재생하려면 오디오 플러그인을 선택하세요. 가사와 감상 기록은 선택 사항이에요. 아래에서 플러그인을 찾아 권한을 확인하고 설치한 다음 여기서 선택하세요. + 플러그인 둘러보기 + 내 소스 + 다시 시도 + 더 많은 플러그인 + 새 플러그인을 찾지 못했어요. 위에서 링크나 .smplug 파일로 설치할 수도 있어요. + 지금은 플러그인 둘러보기를 사용할 수 없어요. 다시 시도하거나 링크 또는 파일로 설치하세요. + 환영합니다 + 후원 + 플러그인 + 가이드 + + Spotube 사용 방법 + 간단한 네 단계면 바로 감상할 수 있어요. + 로그인 + Spotify 계정을 연결하거나 원하는 카탈로그 플러그인을 선택하세요. + 검색하고 재생하기 + 노래, 앨범, 아티스트를 찾은 다음 재생을 누르세요. + 오프라인으로 감상하기 + 트랙을 다운로드하면 인터넷 연결 없이도 계속 재생할 수 있어요. + 가사 따라가기 + 트랙이 재생되는 동안 플레이어에서 싱크 가사를 열어 보세요. + + + 나의 음악. 나의 소스. 나의 규칙. + 음악을 사랑하는 커뮤니티가 만든 무료 오픈 소스 음악 플레이어. + 오픈 소스 + 광고 없음 + 원격 측정 없음 + 플러그인 기반 + 시작해요 + + + 후원자 덕분에 계속할 수 있어요 + 열정으로 만들고, 여러분 덕분에 이어 갑니다. + Spotube는 100% 무료이자 자유 오픈 소스 소프트웨어예요 — 광고도, 구독도, 추적도 없어요. 모든 기능은 여러분 같은 분들이 코드가 계속 이어지도록 도와주신 덕분에 존재해요. + Patreon + 월간 후원 + 지속적인 개발과 인프라를 지원해 주세요 + Buy Me a Coffee + 커피 한 잔 후원 + 간단한 팁으로 늦은 밤 코딩에 힘을 보태 주세요 + Open Collective + 커뮤니티 기금 + 투명하게 커뮤니티가 운영하는 재정 후원 + 열린 음악을 믿어 주셔서 진심으로 감사합니다. + 후원은 전적으로 선택 사항이에요 — 어떤 경우든 Spotube는 계속 무료예요. + 후원하기 + %1$s 열기 + + + Spotube 후원하기 + Spotube 후원하기 + Spotube는 무료이자 자유 오픈 소스 소프트웨어예요 — 광고도, 구독도, 추적도 없어요. Spotube가 즐거움을 준다면 프로젝트 후원을 고려해 주세요. + 이 알림은 설정 → 후원에서 끌 수 있어요. + 나중에 + 후원 알림 + 7일마다 프로젝트 후원 알림을 가볍게 표시해요. + + + 모듈식 구조 + 음악을 어디서 가져올지 선택하세요. + Spotube는 카탈로그 탐색, 오디오 스트리밍, 싱크 가사를 분리해서 처리해요. 아래에서 엔진을 구성하거나 언제든지 바꿀 수 있어요. + 메타데이터 엔진 + 음악 탐색, 검색, 트랙 목록, 플레이리스트를 담당해요. + 오디오 엔진 + 재생할 오디오 스트림을 가져오고 디코딩해요. + 가사 엔진 + 시간에 맞춘 노래방 가사를 실시간으로 보여 줘요. + 스크로블 엔진 + 감상 기록을 Last.fm 또는 ListenBrainz에 기록해요. + 사용 중 + 기본 제공 + 준비됨 + 선택 사항 + 엔진 선택 + 설치된 엔진 + 기본값으로 설정 + NewPipe YouTube와 LRCLib Lyrics는 기본 제공되며 바로 스트리밍할 수 있어요. + 설정 → 플러그인에서 언제든지 커뮤니티 플러그인을 관리하거나 추가할 수 있어요. + + + 레트로 모던 아키텍처 + 순수한 하이파이, 중개자는 없어요 + Spotube가 기기에서 직접 분산형 재생을 처리하는 방법은 다음과 같아요. + P2P 직접 연결 • 256 KBPS OPUS + 블루 아워 + Spotube · 플레이어 데모 + 그 사이의 순간들을 위한 작은 음악. + 1. 분산형 탐색 + 검색어는 선택한 카탈로그 플러그인으로 바로 전달돼요. Spotube는 취향을 분석하지 않아요. + 2. 직접 스트림 확인 + 오디오는 콘텐츠 서버에서 기기로 바로 스트리밍돼요. 프록시 경유나 원격 데이터 수집은 없어요. + 3. 로컬 DSP & 재생 + 이퀄라이저 컨트롤과 선택적 오프라인 다운로드를 지원하며 기기에서 디코딩하고 캐시해요. + 4. 싱크 가사 & 잼 + 노래방 가사가 재생 시간에 맞춰 흐르고, LAN 원격 제어와 그룹 잼도 지원해요. + 중개 서버가 없어요. 재생은 기기에서 직접 이루어져요 — 설계부터 개인정보를 보호해요. + + + 기기 선택 + %1$s: “%2$s” + 재생 + 대기열에 추가 + 다음에 재생 + 이 기기 + 이 기기에서 + 원격 기기 + 원격 기기(%1$s) + 연결된 기기에서 + + + 연결 실패 + 연결 끊김 + 전송 실패 + 기기 검색 실패: %1$s + 연결하지 못했어요: %1$s + + + 기기 + 새로고침 + 연결 해제 + 연결 중... + 네트워크에서 기기를 찾는 중... + 기기를 찾을 수 없어요 + 상대 기기의 설정에서 “원격 제어 허용”이 켜져 있는지 확인하세요. + 알 수 없는 기기 + 확인 중... + + + 원격 제어 + 연결 끊김 + 연결 오류: %1$s + 알 수 없는 트랙 + 알 수 없는 아티스트 + 볼륨 + 대기열에서 삭제 + 옵션 더보기 + + + 어디에서 재생할까요? + 진행 중인 잼 세션이 있어요. 이 컬렉션을 재생할 위치를 선택하세요. + 여기에서 재생 + 잼에 추천하기 + + + 구성된 잼 브로커가 없어요 + 브로커 호스트가 비어 있어요 + 연결 실패 + 연결 끊김 + + + 브로커 + 익명 + + + 잼 대기열에 추가했어요 + + 잼 대기열에 %1$d곡을 추가했어요 + + + + 원격 제어 요청 + “%1$s”에서 이 기기의 재생을 제어하려고 해요. + 거부 + 허용 + 항상 허용 + + + 검색 결과 + 전체 + 트랙 + 사용자 + 사용 가능한 검색 유형이 없어요 + 입력하여 검색하세요 + 입력하여 트랙을 검색하세요 + 검색 결과가 없어요 + 모두 보기 + 검색 중 + 트랙 불러오는 중... + 앨범 불러오는 중... + 아티스트 불러오는 중... + 플레이리스트 불러오는 중... + 사용자 불러오는 중... + 앨범을 찾을 수 없어요 + 아티스트를 찾을 수 없어요 + 플레이리스트를 찾을 수 없어요 + 사용자를 찾을 수 없어요 + 레코드 상자를 뒤지는 중... + 우주를 탐색하는 중... + 스피커 예열하는 중... + 바이닐 먼지 터는 중... + 음악의 신께 여쭤보는 중... + 안테나 맞추는 중... + 레코드를 넘겨보는 중... + 검색 기능을 불러오지 못했어요 + 검색하지 못했어요 + 트랙을 검색하지 못했어요 + 앨범을 검색하지 못했어요 + 아티스트를 검색하지 못했어요 + 플레이리스트를 검색하지 못했어요 + 사용자를 검색하지 못했어요 + + + 둘러보기 + + + 싱크 + 일반 + 싱크 가사가 없어요 + 일반 가사가 없어요 + + + %2$s 컨테이너의 %1$s + %1$d kbps + %1$s kHz • %2$d ch + 선택한 메타데이터 플러그인이 없어요 + + + 뒤로 + 다시 시도 + 대화상자 닫기 + 불러오는 중... + 알 수 없음 + 알 수 없는 오류 + + + 사용자 + 플레이리스트 + + + 정말 죄송합니다 + 지금 문제가 발생했어요. 여러분의 경험은 저희에게 소중하며, 문제를 해결하기 위해 최선을 다하고 있어요. 다시 시도하거나 아래 오류 세부정보를 확인하세요. + 오류 세부정보 + 클립보드에 복사 + 전체 로그 보기 + 전체 오류 로그 + + + 셔플 + 셔플 재생 + 팔로우 + 팔로우 취소 + 플레이리스트 편집 + 만든 사람: %1$s + + + 트랙 옵션 + 잼에 추가 + 라디오 시작 + 다음에 재생 + 다음 곡으로 이동 + 대기열에서 삭제 + 즐겨찾기에서 삭제 + 즐겨찾기에 저장 + 플레이리스트에 추가 + 다운로드 + 공유 + + + 기본 순서 + 제목 + 아티스트 + 앨범 + 재생 시간 + 정렬 + 트랙을 찾을 수 없어요 + 필터 + 일괄 작업 + 모두 다운로드 + %1$d곡 다운로드 + 모두 대기열에 추가 + %1$d곡 대기열에 추가 + 모두 다음에 재생 + %1$d곡 다음에 재생 + 모두 플레이리스트에 추가 + %1$d곡 플레이리스트에 추가 + 모두 잼에 추가 + %1$d곡 잼에 추가 + 외 %1$d명 + + + 불러올 URL이 없어요 + 브라우저 기록에서 뒤로 이동 + 브라우저 기록에서 앞으로 이동 + + + 차단 목록 필터... + 차단한 항목이 없어요 + ‘%1$s’와(과) 일치하는 차단 항목이 없어요 + 트랙 + 트랙 + 차단 목록에 추가 + 차단 목록에서 삭제 + + + 트랙 메타데이터 없음 + 스트림 URL을 확인할 수 없어요 + 스트림 URL을 확인할 수 없어요. 먼저 트랙을 재생해 보세요. + + + 저장한 트랙 + 나 + + + 팔로잉 + 인기 트랙 + 인기 트랙 재생 + 인기 트랙을 대기열에 추가 + 관련 아티스트 + 추천 플레이리스트 + 아티스트 개요를 불러오지 못했어요 + 팔로워 정보 없음 + 팔로워 %1$sK명 + 팔로워 %1$sM명 + 팔로워 %1$sB명 + + 팔로워 %1$d명 + + + + 좋아요 + 좋아요 취소 + 트랙 제목 없음 + 아티스트 없음 + 알 수 없는 앨범 + 탐색 + 볼륨 + 셔플 사용 + 셔플 사용 안 함 + 이전 + 다음 + 재생 또는 일시정지 + 반복 끄기 + 한 곡 반복 + 전체 반복 + 음소거 + 음소거 해제 + 옵션 더보기 + 플레이어 옵션 + 플레이어 접기 + 가사 펼치기 + 지금 재생 중 + 가사 + 대체 소스 + 대체 소스 + 다운로드 + 다운로드 완료 + 다운로드 중... + 이미 다운로드됨 + 다운로드 다시 시도 중... + %1$s 다운로드 중 + %1$s 후 재생이 일시정지돼요. + 선택한 시간(분)이 지나면 재생을 일시정지해요. + 분 + 1~600분 사이에서 선택하세요 + 타이머 취소 + 시작 + 재생 URL이 다른 스트리밍 설정으로 확인되었어요. 강조 표시된 스트림이 현재 설정과 일치하며, 사용 중인 URL은 다음 스트림 요청 시 새로고침돼요. + 일치하는 오디오 소스 + 일치하는 소스 불러오는 중… + 로컬 캐시 파일을 재생하는 동안에는 일치하는 원격 소스가 필요하지 않아요. + 일치하는 소스가 없어요. + 이 트랙에 캐시된 일치 오디오 소스가 없어요. + 스트림 정보가 아직 캐시되지 않았어요. 먼저 트랙을 재생해 불러오세요. + 트랙 세부정보를 불러오지 못했어요. + 사용 가능한 스트림 + 이 트랙은 로컬 캐시에서 재생 중이에요. 이번 재생에는 스트림 변형이 사용되지 않아요. + 스트림 불러오는 중… + 이 소스에 사용 가능한 스트림이 없어요. + 현재 선택된 스트림 + 스트리밍 형식 & 음질 설정과 일치 + 현재 재생 URL + 로컬 캐시에서 재생 중 + 메타데이터 트랙 + 제목 + ID + 앨범 + 일치도 + 외부 URI + 앨범 ID + 앨범 유형 + 앨범 발매일 + 앨범 트랙 수 + 앨범 장르 + 재생 시간 + 트랙 번호 + 디스크 번호 + 청소년 이용불가 + 인기도 + ISRC + 썸네일 URL + + + 사이드바 전환 + + + 대체 소스 + 현재 재생 중인 트랙이 없어요 + 대체 소스를 찾는 중... + 대체 소스를 찾을 수 없어요 + 일치도: %1$d% + 현재 소스 + + + 내보내기 + 세션에서 내보내요 + 내보내고 다시 참여하지 못하게 해요 + 추천곡 삭제 + 이 참여자가 대기열에 추가한 모든 트랙을 삭제해요 + 순서 변경 + 대기열에서 삭제 + %1$s • %2$s님이 추가함 + + + 알 수 없음 + 플레이리스트 불러오는 중... + 트랙 추가 + 플레이리스트에 트랙 추가 + 트랙 검색... + 검색 + 트랙을 찾을 수 없어요 + 추가할 트랙을 검색하세요 + 완료 + 추가 + 추가됨 + + + 앨범 불러오는 중... + + + 내 플레이리스트에 추가 + 검색 + 내 플레이리스트 검색... + 내 플레이리스트 불러오는 중... + 아직 소유한 플레이리스트가 없어요.\n플레이리스트를 만들어 트랙을 추가하세요. + 플레이리스트 만들기 + 플레이리스트 편집 + 만들기 + 이름 + 플레이리스트 이름 + 설명 + 설명(선택 사항) + 공개 + 누구나 이 플레이리스트를 볼 수 있어요 + 공동 작업 + 다른 사람도 이 플레이리스트에 트랙을 추가할 수 있어요 + 커버 이미지 + 플레이리스트 커버 + 이미지 없음 + 이미지 변경 + 이미지 선택 + + + 설치 + 교체 + zip 파일에서 plugin.json을 찾을 수 없어요 + zip 파일에서 manifest.zipline.json을 찾을 수 없어요 + plugin.json 형식이 올바르지 않아요: %1$s + 플러그인을 읽지 못했어요: %1$s + 플러그인을 설치하지 못했어요: %1$s + %1$s의 새 버전을 사용할 수 있어요. 설치하기 전에 업데이트를 확인하세요. + %1$s이(가) 이미 설치되어 있어요. 설치하기 전에 교체를 확인하세요. + 기본 제공 플러그인은 이미 포함되어 있어 다시 추가할 수 없어요. + 기본 제공 플러그인은 삭제할 수 없어요. + %1$s에 잘못된 apiVersion(%2$s)이 선언되어 있어요. %3$s과(와) 호환되는 유의적 버전이 필요해요. + Spotube 플러그인 API 버전 %1$s이(가) 올바르지 않아요. + %1$s은(는) 플러그인 API %2$s용이지만, Spotube는 호환성이 깨지는 플러그인 API %3$s을(를) 지원해요. 같은 주 API 버전용으로 빌드된 플러그인을 설치하세요. + + + 호환되지 않는 플러그인 API + 플러그인을 설치할까요? + %1$s이(가) 설치된 플러그인에 추가돼요. + 플러그인을 업데이트할까요? + 이전 버전(%1$s)이 설치되어 있어요. %2$s(으)로 업데이트할까요? + 설치된 플러그인을 교체할까요? + %1$s은(는) 잘못된 유의적 버전(%2$s)을 사용하므로 업데이트로 비교할 수 없어요. 그래도 설치된 플러그인을 교체할까요? + 설치된 버전(%1$s)은 유의적 버전으로 비교할 수 없어요. %2$s(으)로 교체할까요? + 버전 %1$s이(가) 이미 설치되어 있어요. 기존 플러그인을 제공된 사본으로 교체할까요? + 설치된 버전 %1$s이(가) 제공된 버전 %2$s보다 최신이에요. 그래도 교체할까요? + 설치된 플러그인을 제공된 사본으로 교체할까요? + + + %1$s: + 버전 + API 버전 + 제작자 + 라이선스 + 권한 + 기능 + 저장소 + 연락처 + 버그 신고 + %1$s 후원하기 + + + 공식 + 인증됨 + GitHub 스타 + GitHub 저장소 URL + + + 릴리스를 찾을 수 없어요 + 릴리스 선택 + 릴리스를 선택하세요 + 사전 릴리스 + 릴리스 노트 + + + 홈 + 검색 + 이 기기 + 연결 + 기기 + + + 창 숨기기 + 창 표시 + 다음 트랙 + 이전 트랙 + 셔플 + 반복 + 반복: 끔 + 반복: 한 곡 + 반복: 전체 + 볼륨: %1$d% + 볼륨 + + 볼륨 - + 음소거 + 음소거 해제 + 트랙 좋아요 + 트랙 좋아요 취소 + 종료 + + + 최소화 + 최대화 + 이전 크기로 복원 + 닫기 + + + 재생 + 미디어 재생 컨트롤 + + + 둘러보기 + 지금 재생 중 + 추천 + 저장한 트랙 + 인기 트랙 + diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml new file mode 100644 index 00000000..337ad03c --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -0,0 +1,926 @@ + + + + Wachtrij + Wachtrij wissen + Wachtrij filteren... + Wachtrij is leeg + + Zoek nummers, artiesten, albums... + Alle geschiedenis wissen + Zoeken... + + + Jouw bibliotheek + Bibliotheek + Playlists + Albums + Artiesten + Lokale nummers + Downloads + Zoek in opgeslagen playlists… + Zoek in opgeslagen albums… + Zoek in opgeslagen artiesten… + Zoek mappen of nummers… + Bibliotheek filteren + Filter sluiten + Playlist maken + Lokale nummers vernieuwen + Rasterweergave + Lijstweergave + Geen opgeslagen playlists + Geen playlists gevonden voor “%1$s” + Geen opgeslagen albums + Geen albums gevonden voor “%1$s” + Geen opgeslagen artiesten + Geen artiesten gevonden voor “%1$s” + + + Mappen + Map + %1$s • %2$s + + %1$d nummer + %1$d nummers + + + %1$d map + %1$d mappen + + Terug naar mappen + Map afspelen + Toegang tot audio vereist + Geef toegang tot audio om muziek op je apparaat te bekijken. + Toegang geven + Geen lokale mappen gevonden + Geen nummers in deze map + Geen overeenkomende mappen of nummers + Lokale media vernieuwen mislukt + + + %1$d actief • %2$d voltooid + Voltooide wissen + Nog geen downloads + Downloaden %1$d% + In wachtrij + Voltooid + Geannuleerd + Mislukt + Mislukt: %1$s + Download annuleren + Download opnieuw proberen + Download verwijderen + + + Artiest + Op zwarte lijst + Gelikete nummers + Je opgeslagen nummers + Onbekende artiest + Titel van nummer + Naam van artiest + + Geselecteerd + + Instellingen + + Plug-ins + Taal en regio + Weergave + Afspelen + Downloads + Cache + Desktop + Updates + Steunen + Over + + Sluiten + Opslaan + Annuleren + + Taal + Taal van de app. Huidig: %1$s + Systeemstandaard + Land + Land voor content en beschikbaarheid. Huidig: %1$s + %1$s (%2$s) + + Thema + Kies hoe Spotube aansluit op de weergave van je systeem. Huidig: %1$s + Licht + Donker + Systeem + + Oppervlak + Achtergrondmateriaal van de app. Huidig: %1$s + Waterig + IJzig + Chroom + Acryl + Glas + + Accentkleur + Huidig palet: %1$s + Accentkleur + Kies een accentpalet en bekijk het in zowel het lichte als het donkere thema. + Licht + Donker + Voorbeeld + Knop + Groene kobold + Elektrisch violet + Oceaancyaan + Zonsondergangoranje + Rozentuin + Middernachtblauw + Metaalgrijs + + Uiterlijk + Oppervlaktemateriaal en accent. Huidig: %1$s · %2$s + Uiterlijk + Kies een oppervlaktemateriaal en daarna een accent dat erbij past. Elke optie is een zorgvuldig gekozen combinatie. + Oppervlak + Accent + Secundair + + Palet + Millennium-accentpalet. Huidig: %1$s + Palet + Kies een Millennium-accentpalet en bekijk het in licht en donker. + Palet + Millennium-palet en accent. Huidig: %1$s + Blauw + Oranje + Limoen + + Andere downloadmap + Huidige map: %1$s + Laat dit leeg om de standaard muziekmap te gebruiken. + C:\Music\Spotube + Lokale mediamappen + Eigen mappen om te scannen op desktop. Toegevoegd: %1$d + Nog geen eigen map toegevoegd + Eigen mappen voor je lokale muziekbibliotheek. + Beheren + Map toevoegen + Map verwijderen + Geen eigen mappen toegevoegd. + %1$s verwijderen uit de scanlijst + De download- en muziekcachemappen worden automatisch meegenomen. + Je downloadmap wordt automatisch meegenomen. + De muziekcachemap wordt automatisch meegenomen. + Opslaan… + Kan mappen niet opslaan. Je wijzigingen zijn bewaard; probeer het opnieuw. + + %1$d map + %1$d mappen + + Downloadformaat + Voorkeurscodec en -container voor downloads. Huidig: %1$s + Downloadkwaliteit + + Streamingformaat + Voorkeurscodec en -container voor het streamen van muziek. Huidig: %1$s + Streamingkwaliteit + Huidig: %1$s + Muziekcache inschakelen + Gestreamde audio lokaal opslaan zodat herhaald afspelen sneller gaat. + Cachemap + Huidig: %1$s + Huidig: standaard app-cache + Maximale cachegrootte + Geen limiet + Huidige limiet: %1$d MB + Stel de maximale cachegrootte in MB in. Vul 0 in of laat leeg voor geen limiet. + 500 + Grootte moet 0 of een positief getal zijn. + Eindeloos afspelen inschakelen + Blijf afspelen met aanbevolen nummers als je wachtrij leeg is. + Poort van afspeelproxyserver + Bediening op afstand toestaan + Laat andere apparaten op hetzelfde netwerk het afspelen bedienen als ze verbinding maken. + Apparaatnaam voor bediening op afstand + De naam die andere apparaten zien als ze dit apparaat vinden. Huidig: %1$s + Kies een herkenbare naam die andere apparaten op je netwerk te zien krijgen als ze deze Spotube-installatie vinden. + bijv. Spotube woonkamer + hostnaam van apparaat + Poort die de afspeelproxyserver gebruikt. Huidig: %1$d + Kies een poort tussen 1 en 65535. + 14769 + Voer een geldig geheel getal in. + Poort moet tussen 1 en 65535 liggen. + + Zwarte lijst + Nummers en artiesten op de zwarte lijst beheren + + Minimaliseren naar systeemvak + Houd Spotube actief in het systeemvak als het venster wordt geminimaliseerd. + Discord Rich Presence + Toon wat je afspeelt in Discord op desktop. + + Automatisch controleren op updates + Op de achtergrond zoeken naar nieuwe Spotube-versies. + + Plug-ins beheren + Beheer je plug-ins + Standaardplug-in voor %1$s + Metadata + Audio + Songteksten + Scrobbelen + Geen plug-in geselecteerd + Wijzigen + Selecteren + Geselecteerd + Selectie wissen + Geen plug-ins beschikbaar + Plug-in %1$s + Wissen + + Configureren + Plug-inbeheer + Voer een URL in + URL moet beginnen met http:// of https:// + Plug-in downloaden mislukt + Nog geen plug-ins geïnstalleerd + Plak hierboven een download-URL of kies een bestand. + + %1$d plug-in geïnstalleerd + %1$d plug-ins geïnstalleerd + + Plug-in installeren + https://example.com/plugin.smplug + Downloaden + Installeren uit bestand (.smplug) + Actief + v%1$s + Ingebouwd + Plug-in verwijderen + Inloggen + Uitloggen + Downloaden via URL + Installeren uit bestand + Installeren + + • %1$s + Gevraagde machtigingen + Deze plug-in heeft geen speciale machtigingen nodig. + Geïnstalleerd vs. aangeboden + Geïnstalleerd + Aangeboden + API %1$s → %2$s + Permanente opslag + Gegevens lezen van en schrijven naar schijf + Netwerkverzoeken + Gegevens verzenden en ontvangen via internet + WebView + Webcontent weergeven in de app + Group Jam + MQTT-broker + Broker-host + %1$s:%2$d + Broker-poort + TLS gebruiken + Gebruikersnaam (optioneel) + Wachtwoord (optioneel) + Voorvoegsel client-ID + Wordt gebruikt om deze Spotube-installatie te herkennen + De verbinding met de broker versleutelen + Controleer de huidige broker-instellingen voordat je een Jam start. + Verbinding testen + Testen… + Verbonden in %1$d ms + Mislukt: %1$s + Tijdelijke broker — stel je eigen broker in om zelf te hosten + Verbinding + Inloggegevens + Identiteit + Diagnose + + + Group Jam + Maken + Deelnemen + Maak een sessie en deel de code met je vrienden. + Sessie maken + Deelnemen aan sessie + Verbinden… + Sessiecode + ABC123 + Broker: %1$s + Stel een broker in bij Instellingen om een sessie te hosten of eraan deel te nemen. + Broker-instellingen + Verbonden + Opnieuw verbinden… + Code kopiëren + Gekopieerd! + Delen + Deelnemers (%1$d) + Host + Luisteraar + Wachten op luisteraars… + Verwijderen + Blokkeren + Beheren + Sessie verlaten + Kan geen verbinding maken + Sluiten + Jam-sessie maken mislukt + Deelnemen aan Jam-sessie mislukt + Voer een sessiecode van zes tekens in met letters en cijfers. + Doe mee met mijn Spotube Jam met code: %1$s + + Naar album + Slaaptimer + Details van nummer + Afspelen + Pauzeren + Er wordt niets afgespeeld + Toevoegen aan wachtrij + + Negeren + Bijwerken + Spotube %1$s is beschikbaar + Bekijk wat er nieuw is in deze versie op de downloadpagina van Spotube. + %1$s %2$s is beschikbaar + Momenteel geïnstalleerd: %1$s + Bekijk wat er nieuw is in deze update op de pagina van de plug-in. + + Overslaan + Volgende + Terug + Aan de slag + Spotube + Muziek, zonder verplichtingen. + Een opensource-speler voor een wereld vol muziek.\nOm te ontdekken. Om helemaal van jou te maken. + Houd dit project in leven. + Spotube wordt gemaakt door mensen, niet door een abonnementsbedrijf. Als het onderdeel wordt van je dagelijks leven, helpt een beetje steun ons om het de zorg te geven die het verdient.\n\nBedankt dat je ruimte maakt voor onafhankelijke software. + Begin met een catalogusplug-in om nummers en playlists te vinden. Kies een audioplug-in om ze af te spelen. Songteksten en luistergeschiedenis zijn optioneel. Ontdek hieronder een plug-in, bekijk de machtigingen, installeer hem en selecteer hem hier. + Plug-ins ontdekken + Je bronnen + Opnieuw proberen + Meer plug-ins + Geen nieuwe plug-ins gevonden. Je kunt er ook hierboven een installeren via een link of een .smplug-bestand. + Plug-ins ontdekken is nu niet beschikbaar. Probeer het opnieuw of installeer via een link of bestand. + Welkom + Steunen + Plug-ins + Uitleg + + Zo gebruik je Spotube + Vier snelle stappen en je luistert al. + Inloggen + Koppel je Spotify-account of kies je eigen catalogusplug-in. + Zoeken en afspelen + Vind elk nummer, album of elke artiest en druk op afspelen. + Offline luisteren + Download nummers om ook zonder verbinding te luisteren. + Volg de songtekst + Open gesynchroniseerde songteksten in de speler terwijl een nummer speelt. + + + Jouw muziek. Jouw bronnen. Jouw regels. + Een gratis opensource-muziekspeler, gemaakt door een community die gewoon van muziek houdt. + Opensource + Geen advertenties + Geen telemetrie + Werkt met plug-ins + Aan de slag + + + Supporters houden ons op gang + Gemaakt met passie. In leven gehouden door jou. + Spotube is 100% gratis, vrij en opensource — geen advertenties, geen abonnementen, geen tracking. Elke functie bestaat omdat mensen zoals jij hebben geholpen de code te laten stromen. + Patreon + Maandelijkse sponsor + Steun doorlopende ontwikkeling en infrastructuur + Buy Me a Coffee + Eenmalig koffie + Geef een nachtelijke codeersessie een boost met een kleine fooi + Open Collective + Communityfonds + Transparante financiële steun, beheerd door de community + Uit de grond van ons hart bedankt dat je gelooft in open muziek. + Steunen is helemaal optioneel — Spotube blijft hoe dan ook gratis. + Sponsoren + %1$s openen + + + Spotube steunen + Spotube steunen + Spotube is gratis, vrij en opensource — geen advertenties, geen abonnementen, geen tracking. Als je er blij van wordt, overweeg dan het project te steunen. + Je kunt deze herinneringen uitzetten via Instellingen → Steunen. + Misschien later + Herinneringen om te steunen + Toon elke 7 dagen een vriendelijke herinnering om het project te steunen. + + + Modulaire architectuur + Kies waar je muziek vandaan komt. + Spotube scheidt het ontdekken van de catalogus, het streamen van audio en gesynchroniseerde songteksten. Stel je engines hieronder in of wissel ze op elk moment. + Metadata-engine + Zorgt voor muziek ontdekken, zoeken, tracklijsten en playlists. + Audio-engine + Haalt de audiostreams op die worden afgespeeld en decodeert ze. + Songtekst-engine + Streamt tijdgesynchroniseerde karaokesongteksten in realtime. + Scrobble-engine + Legt je luistergeschiedenis vast op Last.fm of ListenBrainz. + Actief + Ingebouwd + Klaar + Optioneel + Kies een engine + Geïnstalleerde engines + Instellen als standaard + NewPipe YouTube en LRCLib Lyrics zijn ingebouwd en direct klaar om te streamen. + Beheer of voeg op elk moment communityplug-ins toe via Instellingen → Plug-ins. + + + Retromoderne architectuur + Pure hi-fi, geen tussenpersonen + Zo regelt Spotube gedecentraliseerd afspelen, rechtstreeks op je apparaat. + RECHTSTREEKS PEER-TO-PEER • 256 KBPS OPUS + Blauw uur + Spotube · demonstratie van de speler + Een beetje muziek voor de momenten tussendoor. + 1. Gedecentraliseerd ontdekken + Zoekopdrachten gaan rechtstreeks naar je gekozen catalogusplug-in. Spotube maakt nooit een profiel van je smaak. + 2. Directe streams + Audio wordt rechtstreeks van de contentservers naar je apparaat gestreamd. Geen proxy\'s ertussen en geen gegevensverzameling op afstand. + 3. Lokale DSP & afspelen + Lokaal gedecodeerd en gecachet, met equalizer en optioneel offline downloaden. + 4. Gesynchroniseerde songteksten & Jam + Karaokesongteksten volgen de afspeelpositie, met bediening op afstand via LAN en Group Jam. + Geen tussenservers. Afspelen gebeurt gewoon op je apparaat — privé vanaf het ontwerp. + + + Kies een apparaat + %1$s: ‘%2$s’ + Afspelen + Toevoegen aan wachtrij + Hierna afspelen + Dit apparaat + Op dit apparaat + Extern apparaat + Extern apparaat (%1$s) + Op het verbonden apparaat + + + Verbinding mislukt + Verbinding verbroken + Verzenden mislukt + Zoeken mislukt: %1$s + Verbinden mislukt: %1$s + + + Apparaten + Vernieuwen + Verbinding verbreken + Verbinden... + Zoeken naar apparaten op het netwerk... + Geen apparaten gevonden + Zorg dat \"Bediening op afstand toestaan\" is ingeschakeld in de instellingen van het andere apparaat. + Onbekend apparaat + Bezig met ophalen... + + + Bediening op afstand + Niet verbonden + Verbindingsfout: %1$s + Onbekend nummer + Onbekende artiest + Volume + Verwijderen uit wachtrij + Meer opties + + + Waar afspelen? + Je hebt een actieve Jam-sessie. Kies waar je deze collectie wilt afspelen. + Hier afspelen + Voorstellen aan Jam + + + Geen Jam-broker ingesteld + Broker-host is leeg + Verbinding mislukt + Verbinding verbroken + + + Broker + anoniem + + + Toegevoegd aan de Jam-wachtrij + + %1$d nummer toegevoegd aan de Jam-wachtrij + %1$d nummers toegevoegd aan de Jam-wachtrij + + + + Verzoek voor bediening op afstand + \"%1$s\" wil het afspelen op dit apparaat bedienen. + Weigeren + Toestaan + Altijd toestaan + + + Zoekresultaten + Alles + Nummers + Gebruikers + Geen zoektypen beschikbaar + Begin met typen om te zoeken + Begin met typen om nummers te zoeken + Geen resultaten gevonden + Alles weergeven + Zoeken + Nummers laden... + Albums laden... + Artiesten laden... + Playlists laden... + Gebruikers laden... + Geen albums gevonden + Geen artiesten gevonden + Geen playlists gevonden + Geen gebruikers gevonden + Door de platenbakken aan het spitten... + De kosmos aan het doorzoeken... + De speakers aan het opwarmen... + Het vinyl aan het afstoffen... + De muziekgoden aan het raadplegen... + De antennes aan het afstemmen... + Door de platen aan het bladeren... + Zoekmogelijkheden laden mislukt + Zoeken mislukt + Nummers zoeken mislukt + Albums zoeken mislukt + Artiesten zoeken mislukt + Playlists zoeken mislukt + Gebruikers zoeken mislukt + + + Ontdekken + + + Gesynchroniseerd + Platte tekst + Geen gesynchroniseerde songtekst beschikbaar + Geen songtekst beschikbaar + + + %1$s in %2$s + %1$d kbps + %1$s kHz • %2$d ch + Geen metadataplug-in geselecteerd + + + Terug + Opnieuw proberen + Dialoogvenster sluiten + Laden... + Onbekend + Onbekende fout + + + Gebruiker + Playlist + + + Het spijt ons echt + Er werkt momenteel iets niet goed. Jouw ervaring is belangrijk voor ons en we doen ons best om dit op te lossen. Probeer het opnieuw of bekijk de foutdetails hieronder. + Foutdetails + Kopiëren naar klembord + Volledig logboek bekijken + Volledig foutenlogboek + + + Shuffle + Shuffle afspelen + Volgen + Ontvolgen + Playlist bewerken + Van %1$s + + + Opties voor nummer + Toevoegen aan Jam + Radio starten + Hierna afspelen + Naar volgende verplaatsen + Verwijderen uit wachtrij + Verwijderen uit favorieten + Opslaan als favoriet + Toevoegen aan playlist + Downloaden + Delen + + + Oorspronkelijk + Titel + Artiest + Album + Duur + Sorteren + Geen nummers gevonden + Filteren + Bulkacties + Alles downloaden + %1$d downloaden + Alles toevoegen aan wachtrij + %1$d toevoegen aan wachtrij + Alles hierna afspelen + %1$d hierna afspelen + Alles toevoegen aan playlist + %1$d toevoegen aan playlist + Alles toevoegen aan Jam + %1$d toevoegen aan Jam + +%1$d meer + + + Geen URL om te laden + Terug in browsergeschiedenis + Vooruit in browsergeschiedenis + + + Zwarte lijst filteren... + Niets op de zwarte lijst + Niets op de zwarte lijst komt overeen met \'%1$s\' + Nummers + Nummer + Toevoegen aan zwarte lijst + Verwijderen van zwarte lijst + + + Geen metadata voor nummer + Kan stream-URL niet ophalen + Kan stream-URL niet ophalen. Speel het nummer eerst af. + + + Opgeslagen nummers + Jij + + + Volgend + Populairste nummers + Populairste nummers afspelen + Populairste nummers toevoegen aan wachtrij + Vergelijkbare artiesten + Uitgelichte playlists + Artiestoverzicht laden mislukt + Volgers niet beschikbaar + %1$sK volgers + %1$s mln. volgers + %1$s mld. volgers + + %1$d volger + %1$d volgers + + + + Liken + Niet meer liken + Titel van no-op-nummer + No-op-artiesten + Onbekend album + Zoeken in nummer + Volume + Shuffle aanzetten + Shuffle uitzetten + Vorige + Volgende + Afspelen of pauzeren + Herhalen uit + Eén nummer herhalen + Alles herhalen + Dempen + Dempen opheffen + Meer opties + Speleropties + Speler inklappen + Songtekst uitvouwen + Nu aan het afspelen + Songtekst + Alternatieve bron + Alternatieve bron + Downloaden + Gedownload + Downloaden... + Al gedownload + Download opnieuw proberen... + %1$s downloaden + Afspelen pauzeert over %1$s. + Pauzeer het afspelen na het gekozen aantal minuten. + Minuten + Kies tussen 1 en 600 minuten + Timer annuleren + Starten + De afspeel-URL is opgehaald met andere streaminginstellingen. De gemarkeerde stream komt overeen met je huidige voorkeur; de actieve URL wordt vernieuwd bij het volgende streamverzoek. + Gekoppelde audiobron + Gekoppelde bron laden… + Een gekoppelde externe bron is niet nodig zolang het lokale cachebestand wordt afgespeeld. + Er is geen gekoppelde bron beschikbaar. + Er is geen gekoppelde audiobron gecachet voor dit nummer. + Streamdetails zijn nog niet gecachet. Speel het nummer eerst af om ze te laden. + Details van nummer laden mislukt. + Beschikbare streams + Dit nummer wordt afgespeeld vanuit de lokale cache. Streamvarianten worden voor deze weergave niet gebruikt. + Streams laden… + Er zijn geen streams beschikbaar voor deze bron. + Momenteel geselecteerde stream + Komt overeen met je voorkeur voor streamingformaat & -kwaliteit + Actieve afspeel-URL + Afgespeeld vanuit lokale cache + Metadata van nummer + Titel + ID + Album + Betrouwbaarheid + Externe URI + Album-ID + Albumtype + Releasedatum album + Aantal nummers op album + Albumgenres + Duur + Nummer op album + Schijfnummer + Expliciet + Populariteit + ISRC + Miniatuur-URL\'s + + + Zijbalk tonen/verbergen + + + Alternatieve bronnen + Er wordt nu geen nummer afgespeeld + Zoeken naar alternatieve bronnen... + Geen alternatieve bronnen gevonden + Betrouwbaarheid: %1$d% + Actieve bron + + + Verwijderen + Verwijder deze persoon uit de sessie + Verwijderen en voorkomen dat deze persoon opnieuw deelneemt + Suggesties verwijderen + Verwijder alle nummers die deze persoon aan de wachtrij heeft toegevoegd + Volgorde wijzigen + Verwijderen uit wachtrij + %1$s • Toegevoegd door %2$s + + + Onbekend + Playlist laden... + Nummers toevoegen + Nummers toevoegen aan playlist + Nummers zoeken... + Zoeken + Geen nummers gevonden + Zoek nummers om toe te voegen + Klaar + Toevoegen + Toegevoegd + + + Album laden... + + + Toevoegen aan je playlist + Zoeken + Zoek in je playlists... + Je playlists laden... + Je hebt nog geen eigen playlists.\nMaak er een om nummers toe te voegen. + Playlist maken + Playlist bewerken + Maken + Naam + Naam van playlist + Beschrijving + Beschrijving (optioneel) + Openbaar + Iedereen kan deze playlist zien + Gezamenlijk + Anderen kunnen nummers aan deze playlist toevoegen + Coverafbeelding + Playlistcover + Geen afbeelding + Afbeelding wijzigen + Afbeelding kiezen + + + Installeren + Vervangen + plugin.json niet gevonden in het zip-bestand + manifest.zipline.json niet gevonden in het zip-bestand + Ongeldige indeling van plugin.json: %1$s + Plug-in lezen mislukt: %1$s + Plug-in installeren mislukt: %1$s + Er is een nieuwere versie van %1$s beschikbaar. Bevestig de update voordat je installeert. + %1$s is al geïnstalleerd. Bevestig het vervangen voordat je installeert. + Ingebouwde plug-ins zijn al inbegrepen en kunnen niet opnieuw worden toegevoegd. + Ingebouwde plug-ins kunnen niet worden verwijderd. + %1$s geeft een ongeldige apiVersion op (%2$s). Verwacht: semantische versie compatibel met %3$s. + Plug-in-API-versie %1$s van Spotube is ongeldig. + %1$s is gemaakt voor plug-in-API %2$s, maar Spotube ondersteunt de breaking plug-in-API %3$s. Installeer een plug-in die is gemaakt voor dezelfde breaking API-versie. + + + Plug-in-API niet compatibel + Plug-in installeren? + %1$s wordt toegevoegd aan je geïnstalleerde plug-ins. + Plug-in bijwerken? + Er is een oudere versie geïnstalleerd (%1$s). Bijwerken naar %2$s? + Geïnstalleerde plug-in vervangen? + %1$s gebruikt een ongeldige semantische versie (%2$s) en kan niet als update worden vergeleken. De geïnstalleerde plug-in toch vervangen? + De geïnstalleerde versie (%1$s) kan niet worden vergeleken met semantische versies. Vervangen door %2$s? + Versie %1$s is al geïnstalleerd. De bestaande plug-in vervangen door de aangeboden kopie? + De geïnstalleerde versie %1$s is nieuwer dan de aangeboden versie %2$s. Toch vervangen? + De geïnstalleerde plug-in vervangen door de aangeboden kopie? + + + %1$s: + Versie + API-versie + Auteur + Licentie + Mogelijkheden + Functies + Repository + Contact + Bugs melden + %1$s steunen + + + Officieel + Geverifieerd + GitHub-sterren + URL van GitHub-repository + + + Geen releases gevonden + Release kiezen + Kies een release + Pre-release + Release-opmerkingen + + + Home + Zoeken + Op apparaat + Verbinden + Apparaten + + + Venster verbergen + Venster tonen + Volgend nummer + Vorig nummer + Shuffle + Herhalen + Herhalen: UIT + Herhalen: ÉÉN + Herhalen: ALLES + Volume: %1$d% + Volume + + Volume - + Dempen + Dempen opheffen + Nummer liken + Nummer niet meer liken + Afsluiten + + + Minimaliseren + Maximaliseren + Herstellen + Sluiten + + + Afspelen + Bediening voor media-afspelen + + + Ontdekken + Nu aan het afspelen + Uitgelicht + Opgeslagen nummers + Populairste nummers + diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml new file mode 100644 index 00000000..f5b66965 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -0,0 +1,938 @@ + + + + Kolejka + Wyczyść kolejkę + Filtruj kolejkę... + Kolejka jest pusta + + Szukaj utworów, wykonawców, albumów... + Wyczyść całą historię + Szukaj... + + + Twoja biblioteka + Biblioteka + Playlisty + Albumy + Wykonawcy + Lokalne utwory + Pobrane + Szukaj w zapisanych playlistach… + Szukaj w zapisanych albumach… + Szukaj wśród zapisanych wykonawców… + Szukaj folderów lub utworów… + Filtruj bibliotekę + Zamknij filtr + Utwórz playlistę + Odśwież lokalne utwory + Przełącz na widok siatki + Przełącz na widok listy + Brak zapisanych playlist + Brak playlist pasujących do „%1$s” + Brak zapisanych albumów + Brak albumów pasujących do „%1$s” + Brak zapisanych wykonawców + Brak wykonawców pasujących do „%1$s” + + + Foldery + Folder + %1$s • %2$s + + %1$d utwór + %1$d utwory + %1$d utworów + %1$d utworu + + + %1$d folder + %1$d foldery + %1$d folderów + %1$d folderu + + Wróć do folderów + Odtwórz folder + Wymagany dostęp do audio + Zezwól na dostęp do audio, aby przeglądać muzykę na urządzeniu. + Zezwól na dostęp + Nie znaleziono lokalnych folderów + Brak utworów w tym folderze + Brak pasujących folderów lub utworów + Nie udało się odświeżyć lokalnych multimediów + + + Aktywne: %1$d • Ukończone: %2$d + Wyczyść ukończone + Nic jeszcze nie pobrano + Pobieranie %1$d% + W kolejce + Ukończono + Anulowano + Niepowodzenie + Niepowodzenie: %1$s + Anuluj pobieranie + Ponów pobieranie + Usuń pobrany plik + + + Wykonawca + Na czarnej liście + Polubione utwory + Twoje zapisane utwory + Nieznany wykonawca + Tytuł utworu + Nazwa wykonawcy + + Zaznaczono + + Ustawienia + + Wtyczki + Język i region + Wygląd + Odtwarzanie + Pobieranie + Pamięć podręczna + Komputer + Aktualizacje + Wsparcie + Informacje + + Zamknij + Zapisz + Anuluj + + Język + Język interfejsu aplikacji. Obecnie: %1$s + Domyślny systemowy + Kraj + Kraj regionu dla treści i dostępności. Obecnie: %1$s + %1$s (%2$s) + + Motyw + Wybierz, jak Spotube dopasowuje się do wyglądu systemu. Obecnie: %1$s + Jasny + Ciemny + Systemowy + + Powierzchnia + Materiał tła aplikacji. Obecnie: %1$s + Wodny + Lodowy + Chrom + Akryl + Szkło + + Kolor akcentu + Obecna paleta: %1$s + Kolor akcentu + Wybierz paletę akcentu i zobacz jej podgląd w jasnym i ciemnym motywie. + Jasny + Ciemny + Podgląd + Przycisk + Zielony goblin + Elektryczny fiolet + Oceaniczny cyjan + Pomarańcz zachodu + Różany ogród + Granat północy + Metaliczny łupek + + Wygląd i styl + Materiał powierzchni i akcent. Obecnie: %1$s · %2$s + Wygląd i styl + Wybierz materiał powierzchni, a następnie pasujący do niego akcent. Każda opcja to starannie dobrane połączenie. + Powierzchnia + Akcent + Dodatkowy + + Paleta + Paleta akcentów Millennium. Obecnie: %1$s + Paleta + Wybierz paletę akcentów Millennium i zobacz jej podgląd w jasnym i ciemnym motywie. + Paleta + Paleta i akcent Millennium. Obecnie: %1$s + Niebieska + Pomarańczowa + Limonkowa + + Własny folder pobierania + Obecny folder: %1$s + Pozostaw puste, aby używać domyślnego folderu muzyki. + C:\Music\Spotube + Foldery lokalnych multimediów + Własne foldery skanowane na komputerze. Dodano: %1$d + Nie dodano jeszcze własnego folderu + Własne foldery dla lokalnej biblioteki muzyki. + Zarządzaj + Dodaj folder + Usuń folder + Nie dodano własnych folderów. + Usuń %1$s z listy skanowania + Foldery pobierania i pamięci podręcznej muzyki są uwzględniane automatycznie. + Folder pobierania jest uwzględniany automatycznie. + Folder pamięci podręcznej muzyki jest uwzględniany automatycznie. + Zapisywanie… + Nie udało się zapisać folderów. Twoje zmiany nadal tu są – spróbuj ponownie. + + %1$d folder + %1$d foldery + %1$d folderów + %1$d folderu + + Format pobieranej muzyki + Preferowany kodek i kontener pobierania. Obecnie: %1$s + Jakość pobieranej muzyki + + Format strumieniowanej muzyki + Preferowany kodek i kontener strumieniowania muzyki. Obecnie: %1$s + Jakość strumieniowanej muzyki + Obecnie: %1$s + Włącz pamięć podręczną muzyki + Zapisuj strumieniowany dźwięk lokalnie, aby przyspieszyć ponowne odtwarzanie. + Folder pamięci podręcznej + Obecnie: %1$s + Obecnie: domyślna pamięć podręczna aplikacji + Limit pamięci podręcznej + Bez limitu + Obecny limit: %1$d MB + Ustaw maksymalny rozmiar pamięci podręcznej w MB. Wpisz 0 lub pozostaw puste, aby nie ustawiać limitu. + 500 + Rozmiar musi wynosić 0 lub być liczbą dodatnią. + Włącz nieskończone odtwarzanie + Gdy kolejka się skończy, odtwarzaj dalej polecane utwory. + Port serwera proxy odtwarzania + Zezwól na zdalne sterowanie + Pozwól innym urządzeniom w tej samej sieci sterować odtwarzaniem po połączeniu. + Nazwa urządzenia do zdalnego sterowania + Nazwa widoczna dla innych urządzeń podczas wykrywania. Obecnie: %1$s + Wybierz przyjazną nazwę, którą zobaczą inne urządzenia w Twojej sieci, gdy wykryją tę instancję Spotube. + np. Spotube w salonie + nazwa hosta urządzenia + Port używany przez serwer proxy odtwarzania. Obecnie: %1$d + Wybierz port od 1 do 65535. + 14769 + Wpisz poprawną liczbę całkowitą. + Port musi mieścić się w zakresie od 1 do 65535. + + Czarna lista + Zarządzaj utworami i wykonawcami na czarnej liście + + Minimalizuj do zasobnika + Po zminimalizowaniu okna Spotube działa dalej w zasobniku systemowym. + Status Discord (Rich Presence) + Pokazuj w Discordzie, czego teraz słuchasz (na komputerze). + + Automatycznie sprawdzaj aktualizacje + Wyszukuj nowe wersje Spotube w tle. + + Zarządzaj wtyczkami + Zarządzaj swoimi wtyczkami + Domyślna wtyczka: %1$s + Metadane + Audio + Teksty + Scrobbling + Nie wybrano wtyczki + Zmień + Wybierz + Wybrano + Wyczyść wybór + Brak dostępnych wtyczek + Wtyczka %1$s + Wyczyść + + Konfiguruj + Menedżer wtyczek + Wpisz adres URL + Adres URL musi zaczynać się od http:// lub https:// + Nie udało się pobrać wtyczki + Nie zainstalowano jeszcze żadnych wtyczek + Wklej adres URL pobierania lub wybierz plik powyżej. + + %1$d wtyczka zainstalowana + %1$d wtyczki zainstalowane + %1$d wtyczek zainstalowanych + %1$d wtyczki zainstalowanej + + Zainstaluj wtyczkę + https://example.com/plugin.smplug + Pobierz + Zainstaluj z pliku (.smplug) + Aktywna + v%1$s + Wbudowana + Usuń wtyczkę + Zaloguj się + Wyloguj się + Pobierz z adresu URL + Zainstaluj z pliku + Zainstaluj + + • %1$s + Wymagane uprawnienia + Ta wtyczka nie wymaga specjalnych uprawnień. + Zainstalowana a dostarczona + Zainstalowana + Dostarczona + API %1$s → %2$s + Trwała pamięć + Odczyt i zapis danych na dysku + Żądania sieciowe + Wysyłanie i odbieranie danych przez internet + WebView + Wyświetlanie treści internetowych w aplikacji + Grupowy Jam + Broker MQTT + Host brokera + %1$s:%2$d + Port brokera + Użyj TLS + Nazwa użytkownika (opcjonalnie) + Hasło (opcjonalnie) + Prefiks ID klienta + Służy do identyfikacji tej instancji Spotube + Szyfruj połączenie z brokerem + Sprawdź bieżące ustawienia brokera przed utworzeniem pokoju. + Testuj połączenie + Testowanie… + Połączono w %1$d ms + Niepowodzenie: %1$s + Broker zastępczy – skonfiguruj własny, aby hostować samodzielnie + Połączenie + Dane logowania + Tożsamość + Diagnostyka + + + Grupowy Jam + Utwórz + Dołącz + Utwórz pokój, a potem udostępnij jego kod znajomym. + Utwórz pokój + Dołącz do pokoju + Łączenie… + Kod pokoju + ABC123 + Broker: %1$s + Skonfiguruj brokera w Ustawieniach, aby utworzyć pokój lub do niego dołączyć. + Ustawienia brokera + Połączono + Ponowne łączenie… + Kopiuj kod + Skopiowano! + Udostępnij + Uczestnicy (%1$d) + Gospodarz + Słuchacz + Czekamy na słuchaczy… + Usuń + Zablokuj + Zarządzaj + Opuść pokój + Nie udało się połączyć + Zamknij + Nie udało się utworzyć sesji Jam + Nie udało się dołączyć do sesji Jam + Wpisz sześcioznakowy kod pokoju złożony z liter i cyfr. + Dołącz do mojego Jamu w Spotube, kod: %1$s + + Przejdź do albumu + Wyłącznik czasowy + Szczegóły utworu + Odtwórz + Wstrzymaj + Nic nie jest odtwarzane + Dodaj do kolejki + + Ignoruj + Aktualizuj + Dostępna jest wersja Spotube %1$s + Sprawdź nowości w tej wersji na stronie pobierania Spotube. + Dostępna jest wersja %2$s wtyczki %1$s + Obecnie zainstalowana: %1$s + Sprawdź nowości w tej aktualizacji na stronie wtyczki. + + Pomiń + Dalej + Wstecz + Zaczynamy + Spotube + Muzyka bez żadnych warunków. + Otwartoźródłowy odtwarzacz dla całego świata muzyki.\nOdkrywaj go. Zrób go po swojemu. + Pomóż utrzymać ten projekt. + Spotube tworzą ludzie, a nie biznes oparty na subskrypcjach. Jeśli stanie się częścią Twojej codzienności, niewielkie wsparcie pomoże nam dbać o niego tak, jak na to zasługuje.\n\nDziękujemy, że dajesz miejsce niezależnemu oprogramowaniu. + Zacznij od wtyczki katalogu, aby wyszukiwać utwory i playlisty. Wybierz wtyczkę audio, aby je odtwarzać. Teksty i historia słuchania są opcjonalne. Znajdź wtyczkę poniżej, sprawdź jej uprawnienia, zainstaluj ją, a potem wybierz ją tutaj. + Odkrywaj wtyczki + Twoje źródła + Spróbuj ponownie + Więcej wtyczek + Nie znaleziono nowych wtyczek. Możesz też zainstalować wtyczkę z linku lub pliku .smplug powyżej. + Wyszukiwanie wtyczek jest teraz niedostępne. Spróbuj ponownie lub zainstaluj z linku albo pliku. + Witaj + Wsparcie + Wtyczki + Przewodnik + + Jak korzystać ze Spotube + Cztery szybkie kroki i już słuchasz. + Zaloguj się + Połącz konto Spotify lub wybierz własną wtyczkę katalogu. + Szukaj i odtwarzaj + Znajdź dowolny utwór, album lub wykonawcę i naciśnij Odtwórz. + Słuchaj offline + Pobieraj utwory, aby słuchać ich bez połączenia. + Śledź tekst + Otwórz zsynchronizowany tekst w odtwarzaczu podczas słuchania utworu. + + + Twoja muzyka. Twoje źródła. Twoje zasady. + Darmowy, otwartoźródłowy odtwarzacz muzyki tworzony przez społeczność, która po prostu kocha muzykę. + Open source + Bez reklam + Bez telemetrii + Oparty na wtyczkach + Zaczynamy + + + Wspierający dodają nam sił + Stworzony z pasją. Utrzymywany dzięki Tobie. + Spotube jest w 100% darmowy, wolny i otwartoźródłowy — bez reklam, subskrypcji i śledzenia. Każda funkcja istnieje dzięki osobom takim jak Ty, które zdecydowały się wspierać rozwój kodu. + Patreon + Miesięczny sponsor + Wspieraj stały rozwój i infrastrukturę + Buy Me a Coffee + Jednorazowa kawa + Dodaj energii nocnej sesji kodowania drobnym napiwkiem + Open Collective + Fundusz społeczności + Przejrzyste wsparcie finansowe zarządzane przez społeczność + Dziękujemy z całego serca, że wierzysz w otwartą muzykę. + Wsparcie jest całkowicie dobrowolne — Spotube i tak pozostanie darmowy. + Wesprzyj + Otwórz %1$s + + + Wesprzyj Spotube + Wesprzyj Spotube + Spotube jest darmowy, wolny i otwartoźródłowy — bez reklam, subskrypcji i śledzenia. Jeśli sprawia Ci radość, rozważ wsparcie projektu. + Te przypomnienia możesz wyłączyć w Ustawienia → Wsparcie. + Może później + Przypomnienia o wsparciu + Co 7 dni pokazuj delikatne przypomnienie o wsparciu projektu. + + + Modułowa architektura + Wybierz, skąd pochodzi Twoja muzyka. + Spotube rozdziela odkrywanie katalogu, strumieniowanie dźwięku i zsynchronizowane teksty. Skonfiguruj silniki poniżej lub zmień je w dowolnej chwili. + Silnik metadanych + Odpowiada za odkrywanie muzyki, wyszukiwanie, listy utworów i playlisty. + Silnik audio + Pobiera i dekoduje odtwarzane strumienie audio. + Silnik tekstów + Wyświetla zsynchronizowane teksty karaoke w czasie rzeczywistym. + Silnik scrobblingu + Zapisuje historię słuchania w Last.fm lub ListenBrainz. + Aktywny + Wbudowany + Gotowy + Opcjonalny + Wybierz silnik + Zainstalowane silniki + Ustaw jako domyślny + NewPipe YouTube i LRCLib Lyrics są wbudowane i od razu gotowe do działania. + Zarządzaj wtyczkami społeczności lub dodawaj nowe w dowolnej chwili w Ustawienia → Wtyczki. + + + Retro-nowoczesna architektura + Czyste Hi-Fi, zero pośredników + Oto jak Spotube organizuje zdecentralizowane odtwarzanie bezpośrednio na Twoim komputerze. + BEZPOŚREDNIO PEER-TO-PEER • 256 KBPS OPUS + Niebieska godzina + Spotube · demonstracja odtwarzacza + Trochę muzyki na chwile pomiędzy. + 1. Zdecentralizowane odkrywanie + Zapytania trafiają prosto do wybranej wtyczki katalogu. Spotube nigdy nie profiluje Twojego gustu. + 2. Bezpośrednie pozyskiwanie strumienia + Dźwięk płynie bezpośrednio z serwerów treści na Twój komputer. Bez pośrednich serwerów proxy i zdalnego zbierania danych. + 3. Lokalne DSP i odtwarzanie + Dekodowanie i buforowanie odbywa się lokalnie, z korektorem i opcjonalnym pobieraniem offline. + 4. Zsynchronizowane teksty i Jam + Teksty karaoke podążają za odtwarzaniem, a do tego zdalne sterowanie w sieci LAN i Grupowy Jam. + Żadnych serwerów pośredniczących. Odtwarzanie odbywa się na Twoim urządzeniu — prywatność w standardzie. + + + Wybierz urządzenie + %1$s: „%2$s” + Odtwórz + Dodaj do kolejki + Odtwórz jako następny + To urządzenie + Na tym urządzeniu + Urządzenie zdalne + Urządzenie zdalne (%1$s) + Na połączonym urządzeniu + + + Połączenie nieudane + Utracono połączenie + Wysyłanie nieudane + Wykrywanie nieudane: %1$s + Nie udało się połączyć: %1$s + + + Urządzenia + Odśwież + Rozłącz + Łączenie... + Wyszukiwanie urządzeń w sieci... + Nie znaleziono urządzeń + Upewnij się, że na drugim urządzeniu w ustawieniach włączono opcję „Zezwól na zdalne sterowanie”. + Nieznane urządzenie + Ustalanie adresu... + + + Zdalne sterowanie + Rozłączono + Błąd połączenia: %1$s + Nieznany utwór + Nieznany wykonawca + Głośność + Usuń z kolejki + Więcej opcji + + + Gdzie odtworzyć? + Masz aktywną sesję Jam. Wybierz, gdzie odtworzyć tę kolekcję. + Odtwórz tutaj + Zaproponuj w Jamie + + + Nie skonfigurowano brokera Jam + Host brokera jest pusty + Połączenie nieudane + Utracono połączenie + + + Broker + anonim + + + Dodano do kolejki Jamu + + Dodano %1$d utwór do kolejki Jamu + Dodano %1$d utwory do kolejki Jamu + Dodano %1$d utworów do kolejki Jamu + Dodano %1$d utworu do kolejki Jamu + + + + Prośba o zdalne sterowanie + „%1$s” chce sterować odtwarzaniem na tym urządzeniu. + Odmów + Zezwól + Zawsze zezwalaj + + + Wyniki wyszukiwania + Wszystko + Utwory + Użytkownicy + Brak dostępnych typów wyszukiwania + Zacznij pisać, aby wyszukać + Zacznij pisać, aby wyszukać utwory + Brak wyników + Pokaż wszystko + Wyszukiwanie + Wczytywanie utworów... + Wczytywanie albumów... + Wczytywanie wykonawców... + Wczytywanie playlist... + Wczytywanie użytkowników... + Nie znaleziono albumów + Nie znaleziono wykonawców + Nie znaleziono playlist + Nie znaleziono użytkowników + Przekopujemy skrzynki z płytami... + Przeszukujemy kosmos... + Rozgrzewamy głośniki... + Odkurzamy winyle... + Radzimy się bogów muzyki... + Stroimy anteny... + Przeglądamy płyty... + Nie udało się wczytać możliwości wyszukiwania + Wyszukiwanie nie powiodło się + Nie udało się wyszukać utworów + Nie udało się wyszukać albumów + Nie udało się wyszukać wykonawców + Nie udało się wyszukać playlist + Nie udało się wyszukać użytkowników + + + Przeglądaj + + + Zsynchronizowany + Zwykły + Brak zsynchronizowanego tekstu + Brak zwykłego tekstu + + + %1$s w %2$s + %1$d kbps + %1$s kHz • %2$d kan. + Nie wybrano wtyczki metadanych + + + Wstecz + Ponów + Zamknij okno + Wczytywanie... + Nieznane + Nieznany błąd + + + Użytkownik + Playlista + + + Bardzo nam przykro + Coś teraz nie działa. Zależy nam na Twoich wrażeniach i robimy wszystko, aby to naprawić. Spróbuj ponownie lub sprawdź szczegóły błędu poniżej. + Szczegóły błędu + Kopiuj do schowka + Pokaż pełny dziennik + Pełny dziennik błędu + + + Losowo + Odtwarzaj losowo + Obserwuj + Przestań obserwować + Edytuj playlistę + Autor: %1$s + + + Opcje utworu + Dodaj do Jamu + Włącz radio + Odtwórz jako następny + Przenieś na następny + Usuń z kolejki + Usuń z ulubionych + Dodaj do ulubionych + Dodaj do playlisty + Pobierz + Udostępnij + + + Oryginalna + Tytuł + Wykonawca + Album + Czas trwania + Sortuj + Nie znaleziono utworów + Filtruj + Działania zbiorcze + Pobierz wszystkie + Pobierz (%1$d) + Dodaj wszystkie do kolejki + Dodaj do kolejki (%1$d) + Odtwórz wszystkie jako następne + Odtwórz jako następne (%1$d) + Dodaj wszystkie do playlisty + Dodaj do playlisty (%1$d) + Dodaj wszystkie do Jamu + Dodaj do Jamu (%1$d) + +%1$d więcej + + + Brak adresu URL do wczytania + Wstecz w historii przeglądarki + Dalej w historii przeglądarki + + + Filtruj czarną listę... + Czarna lista jest pusta + Brak elementów czarnej listy pasujących do „%1$s” + Utwory + Utwór + Dodaj do czarnej listy + Usuń z czarnej listy + + + Brak metadanych utworu + Nie udało się ustalić adresu URL strumienia + Nie udało się ustalić adresu URL strumienia. Spróbuj najpierw odtworzyć utwór. + + + Zapisane utwory + Ty + + + Obserwujesz + Popularne utwory + Odtwórz popularne utwory + Dodaj popularne utwory do kolejki + Podobni wykonawcy + Polecane playlisty + Nie udało się wczytać przeglądu wykonawcy + Liczba obserwujących niedostępna + %1$s tys. obserwujących + %1$s mln obserwujących + %1$s mld obserwujących + + %1$d obserwujący + %1$d obserwujących + %1$d obserwujących + %1$d obserwującego + + + + Polub + Cofnij polubienie + Tytuł utworu zastępczego + Wykonawcy zastępczy + Nieznany album + Przewiń + Głośność + Włącz odtwarzanie losowe + Wyłącz odtwarzanie losowe + Poprzedni + Następny + Odtwórz lub wstrzymaj + Powtarzanie wyłączone + Powtarzaj jeden utwór + Powtarzaj wszystko + Wycisz + Wyłącz wyciszenie + Więcej opcji + Opcje odtwarzacza + Zwiń odtwarzacz + Rozwiń tekst + Teraz odtwarzane + Tekst + Alternatywne źródło + Alternatywne źródło + Pobierz + Pobrano + Pobieranie... + Już pobrano + Ponawianie pobierania... + Pobieranie: %1$s + Odtwarzanie zostanie wstrzymane za %1$s. + Wstrzymaj odtwarzanie po wybranej liczbie minut. + Minuty + Wybierz od 1 do 600 minut + Anuluj wyłącznik + Start + Adres URL odtwarzania został ustalony przy innych ustawieniach strumieniowania. Wyróżniony strumień odpowiada Twoim obecnym preferencjom; aktywny adres URL odświeży się przy następnym żądaniu strumienia. + Dopasowane źródło audio + Wczytywanie dopasowanego źródła… + Dopasowane zdalne źródło nie jest potrzebne podczas odtwarzania lokalnego pliku z pamięci podręcznej. + Brak dostępnego dopasowanego źródła. + Brak dopasowanego źródła audio w pamięci podręcznej dla tego utworu. + Szczegóły strumienia nie są jeszcze zapisane. Najpierw odtwórz utwór, aby je wczytać. + Nie udało się wczytać szczegółów utworu. + Dostępne strumienie + Ten utwór jest odtwarzany z lokalnej pamięci podręcznej. Warianty strumienia nie są używane podczas tego odtwarzania. + Wczytywanie strumieni… + Brak strumieni dla tego źródła. + Obecnie wybrany strumień + Zgodny z preferowanym formatem i jakością strumieniowania + Aktywny adres URL odtwarzania + Odtwarzane z lokalnej pamięci podręcznej + Utwór z metadanych + Tytuł + ID + Album + Pewność + Zewnętrzny URI + ID albumu + Typ albumu + Data wydania albumu + Liczba utworów w albumie + Gatunki albumu + Czas trwania + Numer utworu + Numer płyty + Treści dla dorosłych + Popularność + ISRC + Adresy URL miniatur + + + Przełącz pasek boczny + + + Alternatywne źródła + Nic nie jest obecnie odtwarzane + Wyszukiwanie alternatywnych źródeł... + Nie znaleziono alternatywnych źródeł + Pewność: %1$d% + Aktywne źródło + + + Wyrzuć + Usuń tę osobę z sesji + Wyrzuć i uniemożliw ponowne dołączenie + Usuń propozycje + Usuń wszystkie utwory dodane przez tę osobę do kolejki + Zmień kolejność + Usuń z kolejki + %1$s • Dodane przez: %2$s + + + Nieznany + Wczytywanie playlisty... + Dodaj utwory + Dodaj utwory do playlisty + Szukaj utworów... + Szukaj + Nie znaleziono utworów + Wyszukaj utwory do dodania + Gotowe + Dodaj + Dodano + + + Wczytywanie albumu... + + + Dodaj do swojej playlisty + Szukaj + Szukaj w swoich playlistach... + Wczytywanie Twoich playlist... + Nie masz jeszcze własnych playlist.\nUtwórz playlistę, aby dodać utwory. + Utwórz playlistę + Edytuj playlistę + Utwórz + Nazwa + Nazwa playlisty + Opis + Opis (opcjonalnie) + Publiczna + Każdy może zobaczyć tę playlistę + Wspólna + Inni mogą dodawać utwory do tej playlisty + Okładka + Okładka playlisty + Brak obrazu + Zmień obraz + Wybierz obraz + + + Zainstaluj + Zastąp + Nie znaleziono pliku plugin.json w archiwum zip + Nie znaleziono pliku manifest.zipline.json w archiwum zip + Nieprawidłowy format pliku plugin.json: %1$s + Nie udało się odczytać wtyczki: %1$s + Nie udało się zainstalować wtyczki: %1$s + Dostępna jest nowsza wersja %1$s. Potwierdź aktualizację przed instalacją. + %1$s jest już zainstalowana. Potwierdź zastąpienie przed instalacją. + Wbudowane wtyczki są już dołączone i nie można ich dodać ponownie. + Nie można usunąć wbudowanych wtyczek. + %1$s deklaruje nieprawidłową wartość apiVersion (%2$s). Oczekiwano wersji semantycznej zgodnej z %3$s. + Wersja API wtyczek Spotube %1$s jest nieprawidłowa. + %1$s jest przeznaczona dla API wtyczek %2$s, a Spotube obsługuje niezgodne wstecz API wtyczek %3$s. Zainstaluj wtyczkę zbudowaną dla tej samej głównej wersji API. + + + Niezgodne API wtyczki + Zainstalować wtyczkę? + %1$s zostanie dodana do zainstalowanych wtyczek. + Zaktualizować wtyczkę? + Zainstalowana jest starsza wersja (%1$s). Zaktualizować do %2$s? + Zastąpić zainstalowaną wtyczkę? + %1$s używa nieprawidłowej wersji semantycznej (%2$s) i nie można jej porównać jako aktualizacji. Mimo to zastąpić zainstalowaną wtyczkę? + Zainstalowanej wersji (%1$s) nie można porównać za pomocą wersjonowania semantycznego. Zastąpić ją wersją %2$s? + Wersja %1$s jest już zainstalowana. Zastąpić istniejącą wtyczkę dostarczoną kopią? + Zainstalowana wersja %1$s jest nowsza niż dostarczona wersja %2$s. Mimo to zastąpić? + Zastąpić zainstalowaną wtyczkę dostarczoną kopią? + + + %1$s: + Wersja + Wersja API + Autor + Licencja + Uprawnienia + Funkcje + Repozytorium + Kontakt + Zgłoś błędy + Wesprzyj %1$s + + + Oficjalna + Zweryfikowana + Gwiazdki w GitHub + Adres URL repozytorium GitHub + + + Nie znaleziono wydań + Wybierz wydanie + Wybierz wydanie + Wersja przedpremierowa + Informacje o wydaniu + + + Główna + Szukaj + Na urządzeniu + Połącz + Urządzenia + + + Ukryj okno + Pokaż okno + Następny utwór + Poprzedni utwór + Losowo + Powtarzanie + Powtarzanie: WYŁ. + Powtarzanie: JEDEN + Powtarzanie: WSZYSTKO + Głośność: %1$d% + Głośność + + Głośność - + Wycisz + Wyłącz wyciszenie + Polub utwór + Cofnij polubienie utworu + Zakończ + + + Minimalizuj + Maksymalizuj + Przywróć + Zamknij + + + Odtwarzanie + Sterowanie odtwarzaniem multimediów + + + Przeglądaj + Teraz odtwarzane + Polecane + Zapisane utwory + Popularne utwory + diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml new file mode 100644 index 00000000..fed0a741 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -0,0 +1,932 @@ + + + + Fila + Limpar fila + Filtrar fila... + A fila está vazia + + Buscar músicas, artistas, álbuns... + Limpar todo o histórico + Buscar... + + + Sua Biblioteca + Biblioteca + Playlists + Álbuns + Artistas + Arquivos locais + Downloads + Buscar playlists salvas… + Buscar álbuns salvos… + Buscar artistas salvos… + Buscar pastas ou faixas… + Filtrar biblioteca + Fechar filtro + Criar playlist + Atualizar arquivos locais + Mudar para visualização em grade + Mudar para visualização em lista + Nenhuma playlist salva + Nenhuma playlist corresponde a “%1$s” + Nenhum álbum salvo + Nenhum álbum corresponde a “%1$s” + Nenhum artista salvo + Nenhum artista corresponde a “%1$s” + + + Pastas + Pasta + %1$s • %2$s + + %1$d faixa + %1$d de faixas + %1$d faixas + + + %1$d pasta + %1$d de pastas + %1$d pastas + + Voltar para pastas + Tocar pasta + Acesso ao áudio necessário + Permita o acesso ao áudio para navegar pelas músicas do seu dispositivo. + Permitir acesso + Nenhuma pasta local encontrada + Nenhuma faixa nesta pasta + Nenhuma pasta ou faixa correspondente + Falha ao atualizar a mídia local + + + %1$d em andamento • %2$d concluídos + Limpar concluídos + Nenhum download ainda + Baixando %1$d% + Na fila + Concluído + Cancelado + Falhou + Falhou: %1$s + Cancelar download + Tentar baixar de novo + Remover download + + + Artista + Bloqueado + Músicas Curtidas + Suas músicas salvas + Artista desconhecido + Título da faixa + Nome do artista + + Selecionado + + Configurações + + Plugins + Idioma e região + Aparência + Reprodução + Downloads + Cache + Desktop + Atualizações + Apoio + Sobre + + Fechar + Salvar + Cancelar + + Idioma + Idioma da interface do app. Atual: %1$s + Padrão do sistema + País + País usado para conteúdo e disponibilidade. Atual: %1$s + %1$s (%2$s) + + Tema + Escolha como o Spotube acompanha a aparência do sistema. Atual: %1$s + Claro + Escuro + Sistema + + Superfície + Material de fundo do app. Atual: %1$s + Aquoso + Gelo + Cromado + Acrílico + Vidro + + Cor de destaque + Paleta atual: %1$s + Cor de destaque + Escolha uma paleta de destaque e veja como fica nos temas claro e escuro. + Claro + Escuro + Prévia + Botão + Duende Verde + Violeta Elétrico + Ciano Oceânico + Laranja Pôr do Sol + Jardim de Rosas + Azul Meia-Noite + Ardósia Metálica + + Visual + Material da superfície e destaque. Atual: %1$s · %2$s + Visual + Escolha um material de superfície e depois um destaque que combine com ele. Cada opção é uma combinação selecionada. + Superfície + Destaque + Secundário + + Paleta + Paleta de destaque Millennium. Atual: %1$s + Paleta + Escolha uma paleta de destaque Millennium e veja como fica no claro e no escuro. + Paleta + Paleta Millennium e destaque. Atual: %1$s + Azul + Laranja + Lima + + Pasta de download personalizada + Pasta atual: %1$s + Deixe em branco para usar a pasta de músicas padrão. + C:\Music\Spotube + Pastas de mídia local + Pastas personalizadas a serem verificadas no desktop. Adicionadas: %1$d + Nenhuma pasta personalizada adicionada ainda + Pastas personalizadas para sua biblioteca de músicas local. + Gerenciar + Adicionar pasta + Remover pasta + Nenhuma pasta personalizada adicionada. + Remover %1$s da lista de verificação + As pastas de download e de cache de músicas são incluídas automaticamente. + Sua pasta de download é incluída automaticamente. + A pasta de cache de músicas é incluída automaticamente. + Salvando… + Não foi possível salvar as pastas. Suas alterações continuam aqui; tente de novo. + + %1$d pasta + %1$d de pastas + %1$d pastas + + Formato de download das músicas + Codec e contêiner preferidos para downloads. Atual: %1$s + Qualidade de download das músicas + + Formato de streaming das músicas + Codec e contêiner preferidos para o streaming de música. Atual: %1$s + Qualidade de streaming das músicas + Atual: %1$s + Ativar cache de músicas + Armazena o áudio transmitido no dispositivo para acelerar as próximas reproduções. + Pasta de cache + Atual: %1$s + Atual: cache padrão do app + Limite de tamanho do cache + Sem limite + Limite atual: %1$d MB + Defina o tamanho máximo do cache em MB. Digite 0 ou deixe em branco para não ter limite. + 500 + O tamanho deve ser 0 ou um número positivo. + Ativar reprodução contínua + Continua tocando faixas recomendadas quando sua fila acabar. + Porta do servidor proxy de reprodução + Permitir controle remoto + Permite que outros dispositivos na mesma rede controlem a reprodução quando se conectarem. + Nome do dispositivo para controle remoto + O nome que outros dispositivos veem ao encontrar este. Atual: %1$s + Escolha um nome amigável para exibir aos outros dispositivos da sua rede quando encontrarem esta instância do Spotube. + ex.: Spotube da Sala + nome do host do dispositivo + Porta usada pelo servidor proxy de reprodução. Atual: %1$d + Escolha uma porta entre 1 e 65535. + 14769 + Digite um número inteiro válido. + A porta deve estar entre 1 e 65535. + + Lista de bloqueio + Gerencie faixas e artistas bloqueados + + Minimizar para a bandeja + Mantém o Spotube em execução na bandeja do sistema quando a janela for minimizada. + Rich Presence do Discord + Mostra o que você está ouvindo no Discord no desktop. + + Verificar atualizações automaticamente + Procura novas versões do Spotube em segundo plano. + + Gerenciar plugins + Gerencie seus plugins + Plugin de %1$s padrão + Metadados + Áudio + Letras + Scrobble + Nenhum plugin selecionado + Alterar + Selecionar + Selecionado + Limpar seleção + Nenhum plugin disponível + Plugin %1$s + Limpar + + Configurar + Gerenciador de plugins + Digite uma URL + A URL deve começar com http:// ou https:// + Falha ao baixar o plugin + Nenhum plugin instalado ainda + Cole uma URL de download ou escolha um arquivo acima. + + %1$d plugin instalado + %1$d de plugins instalados + %1$d plugins instalados + + Instalar um plugin + https://example.com/plugin.smplug + Baixar + Instalar de arquivo (.smplug) + Ativo + v%1$s + Integrado + Remover plugin + Entrar + Sair + Baixar de URL + Instalar de arquivo + Instalar + + • %1$s + Permissões solicitadas + Este plugin não precisa de permissões especiais. + Instalado vs. fornecido + Instalado + Fornecido + API %1$s → %2$s + Armazenamento persistente + Ler e gravar dados no disco + Solicitações de rede + Enviar e receber dados pela internet + WebView + Exibir conteúdo da web dentro do app + Jam em grupo + Broker MQTT + Host do broker + %1$s:%2$d + Porta do broker + Usar TLS + Nome de usuário (opcional) + Senha (opcional) + Prefixo do ID do cliente + Usado para identificar esta instância do Spotube + Criptografa a conexão com o broker + Verifique as configurações atuais do broker antes de criar uma sala. + Testar conexão + Testando… + Conectado em %1$d ms + Falhou: %1$s + Broker provisório — configure o seu próprio para hospedar por conta própria + Conexão + Credenciais + Identidade + Diagnóstico + + + Jam em grupo + Criar + Entrar + Crie uma sala e compartilhe o código com seus amigos. + Criar sala + Entrar na sala + Conectando… + Código da sala + ABC123 + Broker: %1$s + Configure um broker nas Configurações para criar ou entrar em uma sala. + Configurações do broker + Conectado + Reconectando… + Copiar código + Copiado! + Compartilhar + Participantes (%1$d) + Anfitrião + Ouvinte + Aguardando ouvintes… + Remover + Banir + Gerenciar + Sair da sala + Não foi possível conectar + Dispensar + Falha ao criar a Jam + Falha ao entrar na Jam + Digite um código de sala com seis caracteres, usando letras e números. + Entre na minha Jam do Spotube com o código: %1$s + + Ir para o álbum + Timer + Detalhes da faixa + Tocar + Pausar + Nada tocando + Adicionar à fila + + Ignorar + Atualizar + Spotube %1$s está disponível + Veja as novidades desta versão na página de downloads do Spotube. + %1$s %2$s está disponível + Versão instalada: %1$s + Veja as novidades desta atualização na página do plugin. + + Pular + Avançar + Voltar + Começar + Spotube + Música, sem amarras. + Um player de código aberto para um mundo de música.\nPara você explorar. Para você deixar do seu jeito. + Mantenha este projeto vivo. + O Spotube é feito por pessoas, não por uma empresa de assinaturas. Se ele fizer parte do seu dia a dia, um pequeno apoio nos ajuda a dar a ele o cuidado que merece.\n\nObrigado por abrir espaço para software independente. + Comece com um plugin de catálogo para encontrar músicas e playlists. Escolha um plugin de áudio para tocá-las. Letras e histórico de reprodução são opcionais. Descubra um plugin abaixo, revise as permissões, instale-o e depois selecione-o aqui. + Descobrir plugins + Suas fontes + Tentar de novo + Mais plugins + Nenhum plugin novo encontrado. Você também pode instalar um a partir de um link ou de um arquivo .smplug acima. + A descoberta de plugins está indisponível no momento. Tente de novo ou instale a partir de um link ou arquivo. + Boas-vindas + Apoio + Plugins + Guia + + Como usar o Spotube + Quatro passos rápidos e você já está ouvindo. + Entrar + Conecte sua conta do Spotify ou escolha seu próprio plugin de catálogo. + Busque e toque + Encontre qualquer música, álbum ou artista e aperte o play. + Ouça offline + Baixe faixas para continuar ouvindo sem conexão. + Acompanhe a letra + Abra a letra sincronizada no player enquanto a faixa toca. + + + Sua música. Suas fontes. Suas regras. + Um player de música gratuito e de código aberto, feito por uma comunidade que simplesmente ama música. + Código aberto + Sem anúncios + Sem telemetria + Movido a plugins + Vamos lá + + + Apoiadores nos mantêm de pé + Feito com paixão. Mantido vivo por você. + O Spotube é 100% gratuito, livre e de código aberto — sem anúncios, sem assinaturas, sem rastreamento. Cada recurso existe porque pessoas como você decidiram ajudar a manter o código fluindo. + Patreon + Apoio mensal + Apoie o desenvolvimento contínuo e a infraestrutura + Buy Me a Coffee + Um café avulso + Dê energia a uma sessão de programação noturna com uma gorjeta rápida + Open Collective + Fundo da comunidade + Apoio financeiro transparente e gerido pela comunidade + Do fundo do coração, obrigado por acreditar na música aberta. + Apoiar é totalmente opcional — o Spotube continua gratuito de qualquer forma. + Apoiar + Abrir %1$s + + + Apoiar o Spotube + Apoie o Spotube + O Spotube é gratuito, livre e de código aberto — sem anúncios, sem assinaturas, sem rastreamento. Se ele te traz alegria, considere apoiar o projeto. + Você pode desativar esses lembretes em Configurações → Apoio. + Agora não + Lembretes de apoio + Mostra um lembrete gentil para apoiar o projeto a cada 7 dias. + + + Arquitetura modular + Escolha de onde vem a sua música. + O Spotube separa a descoberta do catálogo, o streaming de áudio e as letras sincronizadas. Configure seus motores abaixo ou troque-os quando quiser. + Motor de metadados + Alimenta a descoberta de músicas, a busca, as listas de faixas e as playlists. + Motor de áudio + Obtém e decodifica os streams de áudio que tocam. + Motor de letras + Transmite letras de karaokê sincronizadas em tempo real. + Motor de scrobble + Registra o histórico de reprodução no Last.fm ou no ListenBrainz. + Ativo + Integrado + Pronto + Opcional + Selecione um motor + Motores instalados + Definir como padrão + NewPipe YouTube e LRCLib Lyrics já vêm integrados e prontos para usar. + Gerencie ou adicione novos plugins da comunidade quando quiser em Configurações → Plugins. + + + Arquitetura retrô-moderna + Hi-Fi puro, zero intermediários + Veja como o Spotube orquestra a reprodução descentralizada diretamente na sua máquina. + PEER-TO-PEER DIRETO • OPUS 256 KBPS + Hora azul + Spotube · demonstração do player + Um pouco de música para os momentos entre um e outro. + 1. Descoberta descentralizada + As buscas vão direto para o plugin de catálogo que você escolheu. O Spotube nunca traça o perfil do seu gosto. + 2. Resolução direta do stream + O áudio vem direto dos servidores de conteúdo para a sua máquina. Sem saltos por proxy nem coleta remota de dados. + 3. DSP e reprodução locais + Decodificado e armazenado em cache localmente, com controles de equalizador e download offline opcional. + 4. Letras sincronizadas e Jam + As letras de karaokê acompanham o tempo da reprodução, com controle remoto via LAN e Jam em grupo. + Sem servidores intermediários. A reprodução acontece no seu dispositivo — privado por natureza. + + + Escolha um dispositivo + %1$s: “%2$s” + Tocar + Adicionar à fila + Tocar em seguida + Este dispositivo + Neste dispositivo + Dispositivo remoto + Dispositivo remoto (%1$s) + No dispositivo conectado + + + Falha na conexão + Conexão perdida + Falha no envio + Falha na descoberta: %1$s + Falha ao conectar: %1$s + + + Dispositivos + Atualizar + Desconectar + Conectando... + Procurando dispositivos na rede... + Nenhum dispositivo encontrado + Verifique se o outro dispositivo está com \"Permitir controle remoto\" ativado nas configurações. + Dispositivo desconhecido + Resolvendo... + + + Controle remoto + Desconectado + Erro de conexão: %1$s + Faixa desconhecida + Artista desconhecido + Volume + Remover da fila + Mais opções + + + Tocar onde? + Você tem uma Jam ativa. Escolha onde tocar esta coleção. + Tocar aqui + Sugerir na Jam + + + Nenhum broker de Jam configurado + O host do broker está vazio + Falha na conexão + Conexão perdida + + + Broker + anônimo + + + Adicionada à fila da Jam + + %1$d faixa adicionada à fila da Jam + %1$d de faixas adicionadas à fila da Jam + %1$d faixas adicionadas à fila da Jam + + + + Solicitação de controle remoto + \"%1$s\" quer controlar a reprodução neste dispositivo. + Negar + Permitir + Sempre permitir + + + Resultados da busca + Tudo + Faixas + Usuários + Nenhum tipo de busca disponível + Comece a digitar para buscar + Comece a digitar para buscar faixas + Nenhum resultado encontrado + Ver tudo + Buscando + Carregando faixas... + Carregando álbuns... + Carregando artistas... + Carregando playlists... + Carregando usuários... + Nenhum álbum encontrado + Nenhum artista encontrado + Nenhuma playlist encontrada + Nenhum usuário encontrado + Garimpando nos caixotes de discos... + Vasculhando o cosmos... + Aquecendo as caixas de som... + Tirando o pó do vinil... + Consultando os deuses da música... + Sintonizando as antenas... + Folheando os discos... + Falha ao carregar os recursos de busca + Falha na busca + Falha ao buscar faixas + Falha ao buscar álbuns + Falha ao buscar artistas + Falha ao buscar playlists + Falha ao buscar usuários + + + Explorar + + + Sincronizada + Simples + Nenhuma letra sincronizada disponível + Nenhuma letra simples disponível + + + %1$s em %2$s + %1$d kbps + %1$s kHz • %2$d canais + Nenhum plugin de metadados selecionado + + + Voltar + Tentar de novo + Fechar caixa de diálogo + Carregando... + Desconhecido + Erro desconhecido + + + Usuário + Playlist + + + Pedimos sinceras desculpas + Algo não está funcionando agora. Sua experiência importa para nós, e estamos fazendo o possível para resolver isso. Tente de novo ou confira os detalhes do erro abaixo. + Detalhes do erro + Copiar para a área de transferência + Ver log completo + Log de erro completo + + + Aleatório + Tocar em ordem aleatória + Seguir + Deixar de seguir + Editar playlist + De %1$s + + + Opções da faixa + Adicionar à Jam + Iniciar rádio + Tocar em seguida + Mover para a próxima + Remover da fila + Remover dos favoritos + Salvar nos favoritos + Adicionar à playlist + Baixar + Compartilhar + + + Original + Título + Artista + Álbum + Duração + Ordenar + Nenhuma faixa encontrada + Filtrar + Ações em massa + Baixar tudo + Baixar %1$d + Adicionar tudo à fila + Adicionar %1$d à fila + Tocar tudo em seguida + Tocar %1$d em seguida + Adicionar tudo à playlist + Adicionar %1$d à playlist + Adicionar tudo à Jam + Adicionar %1$d à Jam + +%1$d outros + + + Nenhuma URL para carregar + Voltar no histórico do navegador + Avançar no histórico do navegador + + + Filtrar lista de bloqueio... + Nenhum item bloqueado + Nenhum item bloqueado corresponde a \'%1$s\' + Faixas + Faixa + Adicionar à lista de bloqueio + Remover da lista de bloqueio + + + Sem metadados da faixa + Não foi possível obter a URL do stream + Não foi possível obter a URL do stream. Tente tocar a faixa primeiro. + + + Faixas salvas + Você + + + Seguindo + Mais tocadas + Tocar as mais tocadas + Adicionar as mais tocadas à fila + Artistas relacionados + Playlists em destaque + Falha ao carregar a visão geral do artista + Seguidores indisponíveis + %1$s mil seguidores + %1$s mi de seguidores + %1$s bi de seguidores + + %1$d seguidor + %1$d de seguidores + %1$d seguidores + + + + Curtir + Descurtir + Título de faixa genérico + Artistas genéricos + Álbum desconhecido + Avançar/retroceder + Volume + Ativar ordem aleatória + Desativar ordem aleatória + Anterior + Próxima + Tocar ou pausar + Repetição desativada + Repetir uma + Repetir todas + Silenciar + Ativar som + Mais opções + Opções do player + Recolher player + Expandir letra + Tocando agora + Letra + Fonte alternativa + Fonte alternativa + Baixar + Baixada + Baixando... + Já baixada + Tentando baixar de novo... + Baixando %1$s + A reprodução será pausada em %1$s. + Pausa a reprodução após o número de minutos selecionado. + Minutos + Escolha entre 1 e 600 minutos + Cancelar timer + Iniciar + A URL de reprodução foi obtida com outras configurações de streaming. O stream destacado corresponde à sua preferência atual; a URL ativa será atualizada na próxima solicitação de stream. + Fonte de áudio correspondente + Carregando fonte correspondente… + Uma fonte remota correspondente não é necessária enquanto o arquivo em cache local estiver tocando. + Nenhuma fonte correspondente disponível. + Nenhuma fonte de áudio correspondente está em cache para esta faixa. + Os detalhes do stream ainda não estão em cache. Toque a faixa primeiro para carregá-los. + Falha ao carregar os detalhes da faixa. + Streams disponíveis + Esta faixa está sendo reproduzida do cache local. As variantes de stream não são usadas nesta reprodução. + Carregando streams… + Nenhum stream disponível para esta fonte. + Stream selecionado no momento + Corresponde à sua preferência de formato e qualidade de streaming + URL de reprodução ativa + Reproduzida do cache local + Faixa nos metadados + Título + ID + Álbum + Confiança + URI externo + ID do álbum + Tipo de álbum + Data de lançamento do álbum + Número de faixas do álbum + Gêneros do álbum + Duração + Número da faixa + Número do disco + Explícito + Popularidade + ISRC + URLs das miniaturas + + + Alternar barra lateral + + + Fontes alternativas + Nenhuma faixa tocando no momento + Procurando fontes alternativas... + Nenhuma fonte alternativa encontrada + Confiança: %1$d% + Fonte ativa + + + Expulsar + Remove a pessoa da sessão + Expulsa e impede que a pessoa volte a entrar + Remover sugestões + Remove todas as faixas que a pessoa adicionou à fila + Reordenar + Remover da fila + %1$s • Adicionada por %2$s + + + Desconhecido + Carregando playlist... + Adicionar faixas + Adicionar faixas à playlist + Buscar faixas... + Buscar + Nenhuma faixa encontrada + Busque faixas para adicionar + Concluído + Adicionar + Adicionada + + + Carregando álbum... + + + Adicionar à sua playlist + Buscar + Buscar nas suas playlists... + Carregando suas playlists... + Você ainda não tem nenhuma playlist.\nCrie uma para adicionar faixas. + Criar playlist + Editar playlist + Criar + Nome + Nome da playlist + Descrição + Descrição (opcional) + Pública + Qualquer pessoa pode ver esta playlist + Colaborativa + Outras pessoas podem adicionar faixas a esta playlist + Imagem de capa + Capa da playlist + Sem imagem + Alterar imagem + Escolher imagem + + + Instalar + Substituir + plugin.json não encontrado no arquivo zip + manifest.zipline.json não encontrado no arquivo zip + Formato de plugin.json inválido: %1$s + Falha ao ler o plugin: %1$s + Falha ao instalar o plugin: %1$s + Há uma versão mais recente de %1$s disponível. Confirme a atualização antes de instalar. + %1$s já está instalado. Confirme a substituição antes de instalar. + Os plugins integrados já estão incluídos e não podem ser adicionados de novo. + Os plugins integrados não podem ser removidos. + %1$s declara uma apiVersion inválida (%2$s). Era esperado um versionamento semântico compatível com %3$s. + A versão %1$s da API de plugins do Spotube é inválida. + %1$s foi feito para a API de plugins %2$s, mas o Spotube suporta a versão principal %3$s da API. Instale um plugin feito para a mesma versão principal da API. + + + API do plugin incompatível + Instalar plugin? + %1$s será adicionado aos seus plugins instalados. + Atualizar plugin? + Uma versão mais antiga está instalada (%1$s). Atualizar para %2$s? + Substituir plugin instalado? + %1$s usa uma versão semântica inválida (%2$s) e não pode ser comparado como uma atualização. Substituir o plugin instalado mesmo assim? + A versão instalada (%1$s) não pode ser comparada usando versionamento semântico. Substituí-la por %2$s? + A versão %1$s já está instalada. Substituir o plugin existente pela cópia fornecida? + A versão instalada %1$s é mais recente que a versão fornecida %2$s. Substituir mesmo assim? + Substituir o plugin instalado pela cópia fornecida? + + + %1$s: + Versão + Versão da API + Autor + Licença + Permissões + Funções + Repositório + Contato + Relatar bugs + Apoiar %1$s + + + Oficial + Verificado + Estrelas no GitHub + URL do repositório no GitHub + + + Nenhuma versão encontrada + Selecionar versão + Selecione uma versão + Pré-lançamento + Notas da versão + + + Início + Buscar + No dispositivo + Conectar + Dispositivos + + + Ocultar janela + Mostrar janela + Próxima faixa + Faixa anterior + Ordem aleatória + Repetir + Repetir: DESATIVADO + Repetir: UMA + Repetir: TODAS + Volume: %1$d% + Volume + + Volume - + Silenciar + Ativar som + Curtir faixa + Descurtir faixa + Sair + + + Minimizar + Maximizar + Restaurar + Fechar + + + Reprodução + Controles de reprodução de mídia + + + Explorar + Tocando agora + Em destaque + Faixas salvas + Mais tocadas + diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml new file mode 100644 index 00000000..e499d01d --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -0,0 +1,938 @@ + + + + Очередь + Очистить очередь + Фильтр очереди... + Очередь пуста + + Треки, исполнители, альбомы... + Очистить историю + Поиск... + + + Моя медиатека + Медиатека + Плейлисты + Альбомы + Исполнители + Локальные треки + Загрузки + Поиск по сохранённым плейлистам… + Поиск по сохранённым альбомам… + Поиск по сохранённым исполнителям… + Поиск папок или треков… + Фильтр медиатеки + Закрыть фильтр + Создать плейлист + Обновить локальные треки + Показать сеткой + Показать списком + Нет сохранённых плейлистов + Нет плейлистов по запросу «%1$s» + Нет сохранённых альбомов + Нет альбомов по запросу «%1$s» + Нет сохранённых исполнителей + Нет исполнителей по запросу «%1$s» + + + Папки + Папка + %1$s • %2$s + + %1$d трек + %1$d трека + %1$d треков + %1$d трека + + + %1$d папка + %1$d папки + %1$d папок + %1$d папки + + Назад к папкам + Слушать папку + Нужен доступ к аудио + Разрешите доступ к аудио, чтобы просматривать музыку на устройстве. + Разрешить доступ + Локальные папки не найдены + В этой папке нет треков + Подходящих папок или треков нет + Не удалось обновить локальные файлы + + + Активных: %1$d • Завершённых: %2$d + Очистить завершённые + Загрузок пока нет + Загрузка %1$d% + В очереди + Завершено + Отменено + Ошибка + Ошибка: %1$s + Отменить загрузку + Повторить загрузку + Удалить загрузку + + + Исполнитель + В чёрном списке + Любимые треки + Ваши сохранённые треки + Неизвестный исполнитель + Название трека + Имя исполнителя + + Выбрано + + Настройки + + Плагины + Язык и регион + Оформление + Воспроизведение + Загрузки + Кеширование + Компьютер + Обновления + Поддержка + О приложении + + Закрыть + Сохранить + Отмена + + Язык + Язык интерфейса приложения. Сейчас: %1$s + Как в системе + Страна + Страна для контента и доступности. Сейчас: %1$s + %1$s (%2$s) + + Тема + Как Spotube подстраивается под тему системы. Сейчас: %1$s + Светлая + Тёмная + Системная + + Поверхность + Фоновый материал приложения. Сейчас: %1$s + Вода + Лёд + Хром + Акрил + Стекло + + Акцентный цвет + Текущая палитра: %1$s + Акцентный цвет + Выберите акцентную палитру и посмотрите, как она выглядит в светлой и тёмной темах. + Светлая + Тёмная + Предпросмотр + Кнопка + Зелёный гоблин + Электрик-фиолетовый + Океанский циан + Оранжевый закат + Розовый сад + Полночный синий + Металлик-графит + + Внешний вид + Материал поверхности и акцент. Сейчас: %1$s · %2$s + Внешний вид + Выберите материал поверхности, а затем подходящий к нему акцент. Все варианты — продуманные сочетания. + Поверхность + Акцент + Вторичная + + Палитра + Акцентная палитра Millennium. Сейчас: %1$s + Палитра + Выберите акцентную палитру Millennium и посмотрите её в светлой и тёмной темах. + Палитра + Палитра Millennium и акцент. Сейчас: %1$s + Синяя + Оранжевая + Лаймовая + + Своя папка для загрузок + Текущая папка: %1$s + Оставьте пустым, чтобы использовать стандартную папку «Музыка». + C:\Music\Spotube + Папки с локальной музыкой + Дополнительные папки для сканирования на компьютере. Добавлено: %1$d + Дополнительные папки ещё не добавлены + Дополнительные папки для вашей локальной медиатеки. + Управлять + Добавить папку + Удалить папку + Дополнительные папки не добавлены. + Убрать %1$s из списка сканирования + Папки загрузок и музыкального кеша добавляются автоматически. + Папка загрузок добавляется автоматически. + Папка музыкального кеша добавляется автоматически. + Сохранение… + Не удалось сохранить папки. Изменения не потеряны — попробуйте ещё раз. + + %1$d папка + %1$d папки + %1$d папок + %1$d папки + + Формат загрузки музыки + Предпочтительный кодек и контейнер для загрузок. Сейчас: %1$s + Качество загрузки музыки + + Формат стриминга музыки + Предпочтительный кодек и контейнер для стриминга. Сейчас: %1$s + Качество стриминга музыки + Сейчас: %1$s + Кешировать музыку + Сохранять прослушанное аудио на устройстве для быстрого повторного воспроизведения. + Папка кеша + Сейчас: %1$s + Сейчас: стандартный кеш приложения + Лимит размера кеша + Без ограничений + Текущий лимит: %1$d MB + Укажите максимальный размер кеша в MB. Введите 0 или оставьте пустым, чтобы снять ограничение. + 500 + Размер должен быть 0 или положительным числом. + Бесконечное воспроизведение + Продолжать воспроизведение рекомендованными треками, когда очередь закончится. + Порт прокси-сервера воспроизведения + Разрешить удалённое управление + Разрешить другим устройствам в той же сети управлять воспроизведением после подключения. + Имя устройства для удалённого управления + Имя, которое видят другие устройства при поиске. Сейчас: %1$s + Задайте понятное имя, которое будут видеть другие устройства в вашей сети при обнаружении этого Spotube. + например, Spotube в гостиной + имя хоста устройства + Порт прокси-сервера воспроизведения. Сейчас: %1$d + Укажите порт от 1 до 65535. + 14769 + Введите целое число. + Порт должен быть от 1 до 65535. + + Чёрный список + Управление заблокированными треками и исполнителями + + Сворачивать в трей + Оставлять Spotube в системном трее при сворачивании окна. + Статус в Discord + Показывать в Discord, что сейчас играет (на компьютере). + + Автоматически проверять обновления + Искать новые версии Spotube в фоновом режиме. + + Управление плагинами + Управляйте своими плагинами + Плагин по умолчанию: %1$s + Метаданные + Аудио + Тексты песен + Скробблинг + Плагин не выбран + Изменить + Выбрать + Выбран + Сбросить выбор + Нет доступных плагинов + Плагин %1$s + Сбросить + + Настроить + Менеджер плагинов + Введите URL + URL должен начинаться с http:// или https:// + Не удалось скачать плагин + Плагины ещё не установлены + Вставьте ссылку для загрузки или выберите файл выше. + + %1$d плагин установлен + %1$d плагина установлено + %1$d плагинов установлено + %1$d плагина установлено + + Установка плагина + https://example.com/plugin.smplug + Скачать + Установить из файла (.smplug) + Активен + v%1$s + Встроенный + Удалить плагин + Войти + Выйти + Скачать по URL + Установить из файла + Установить + + • %1$s + Запрашиваемые разрешения + Этому плагину не нужны особые разрешения. + Установленный и новый + Установлен + Новый + API %1$s → %2$s + Постоянное хранилище + Чтение и запись данных на диск + Сетевые запросы + Отправка и получение данных через интернет + WebView + Показ веб-контента внутри приложения + Групповой джем + MQTT-брокер + Хост брокера + %1$s:%2$d + Порт брокера + Использовать TLS + Имя пользователя (необязательно) + Пароль (необязательно) + Префикс ID клиента + Используется для идентификации этого экземпляра Spotube + Шифровать подключение к брокеру + Проверьте текущие настройки брокера перед созданием комнаты. + Проверить подключение + Проверка… + Подключено за %1$d мс + Ошибка: %1$s + Временный брокер — настройте свой, чтобы размещать джемы самостоятельно + Подключение + Учётные данные + Идентификация + Диагностика + + + Групповой джем + Создать + Присоединиться + Создайте комнату и поделитесь её кодом с друзьями. + Создать комнату + Войти в комнату + Подключение… + Код комнаты + ABC123 + Брокер: %1$s + Настройте брокер в настройках, чтобы создать комнату или присоединиться к ней. + Настройки брокера + Подключено + Переподключение… + Копировать код + Скопировано! + Поделиться + Участники (%1$d) + Организатор + Слушатель + Ждём слушателей… + Удалить + Заблокировать + Управлять + Покинуть комнату + Не удалось подключиться + Закрыть + Не удалось создать джем + Не удалось присоединиться к джему + Введите код комнаты из шести букв и цифр. + Присоединяйся к моему джему в Spotube по коду: %1$s + + Перейти к альбому + Таймер сна + Сведения о треке + Воспроизвести + Пауза + Сейчас ничего не играет + Добавить в очередь + + Пропустить + Обновить + Доступен Spotube %1$s + Узнайте, что нового в этой версии, на странице загрузок Spotube. + Доступен %1$s %2$s + Установлена версия: %1$s + Узнайте, что нового в этом обновлении, на странице плагина. + + Пропустить + Далее + Назад + Начать + Spotube + Музыка без лишних условий. + Плеер с открытым исходным кодом для целого мира музыки.\nИсследуйте его. Сделайте его своим. + Помогите проекту жить. + Spotube создают люди, а не бизнес на подписках. Если он стал частью вашей жизни, даже небольшая поддержка поможет нам уделять ему то внимание, которого он заслуживает.\n\nСпасибо, что находите место для независимого софта. + Начните с плагина каталога, чтобы находить треки и плейлисты. Выберите аудиоплагин для их воспроизведения. Тексты песен и история прослушиваний — по желанию. Найдите плагин ниже, проверьте его разрешения, установите и выберите здесь. + Обзор плагинов + Ваши источники + Повторить + Другие плагины + Новых плагинов не найдено. Плагин также можно установить по ссылке или из файла .smplug выше. + Каталог плагинов сейчас недоступен. Повторите попытку или установите плагин по ссылке или из файла. + Добро пожаловать + Поддержка + Плагины + Руководство + + Как пользоваться Spotube + Четыре простых шага — и музыка уже играет. + Вход + Подключите аккаунт Spotify или выберите свой плагин каталога. + Ищите и слушайте + Найдите любой трек, альбом или исполнителя и нажмите «Воспроизвести». + Слушайте офлайн + Скачивайте треки, чтобы слушать их без интернета. + Следите за текстом + Открывайте синхронизированный текст в плеере во время воспроизведения. + + + Ваша музыка. Ваши источники. Ваши правила. + Бесплатный музыкальный плеер с открытым исходным кодом от сообщества, которое просто любит музыку. + Открытый код + Без рекламы + Без телеметрии + На плагинах + Поехали + + + Нас поддерживают слушатели + Создан с любовью. Живёт благодаря вам. + Spotube на 100% бесплатный, свободный и открытый — без рекламы, подписок и слежки. Каждая функция существует благодаря таким людям, как вы, которые помогают проекту развиваться. + Patreon + Ежемесячная поддержка + Поддержите постоянную разработку и инфраструктуру + Buy Me a Coffee + Разовый кофе + Взбодрите ночную сессию программирования небольшим донатом + Open Collective + Фонд сообщества + Прозрачное финансирование под управлением сообщества + От всего сердца спасибо, что верите в открытую музыку. + Поддержка полностью добровольная — Spotube в любом случае останется бесплатным. + Поддержать + Открыть %1$s + + + Поддержать Spotube + Поддержите Spotube + Spotube бесплатный, свободный и открытый — без рекламы, подписок и слежки. Если он приносит вам радость, поддержите проект. + Эти напоминания можно отключить в разделе «Настройки → Поддержка». + Может, позже + Напоминания о поддержке + Раз в 7 дней ненавязчиво напоминать о поддержке проекта. + + + Модульная архитектура + Выберите, откуда брать музыку. + В Spotube каталог, стриминг аудио и синхронизированные тексты работают отдельно. Настройте движки ниже или меняйте их в любой момент. + Движок метаданных + Отвечает за поиск музыки, списки треков и плейлисты. + Аудиодвижок + Получает и декодирует аудиопотоки для воспроизведения. + Движок текстов + Показывает синхронизированные караоке-тексты в реальном времени. + Движок скробблинга + Записывает историю прослушиваний в Last.fm или ListenBrainz. + Активен + Встроенный + Готов + Необязательно + Выберите движок + Установленные движки + Сделать основным + NewPipe YouTube и LRCLib Lyrics встроены и работают сразу после установки. + Управлять плагинами сообщества и добавлять новые можно в любой момент в разделе «Настройки → Плагины». + + + Ретромодерн-архитектура + Чистый Hi-Fi без посредников + Вот как Spotube организует децентрализованное воспроизведение прямо на вашем устройстве. + НАПРЯМУЮ, P2P • 256 KBPS OPUS + Синий час + Spotube · демонстрация плеера + Немного музыки для мгновений между делами. + 1. Децентрализованный поиск + Запросы идут напрямую в выбранный плагин каталога. Spotube никогда не анализирует ваши вкусы. + 2. Прямое получение потока + Аудио поступает напрямую с серверов контента на ваше устройство. Никаких прокси и удалённого сбора данных. + 3. Локальная обработка и воспроизведение + Декодирование и кеширование на устройстве, эквалайзер и загрузка для офлайн-прослушивания по желанию. + 4. Синхронные тексты и джем + Караоке-тексты следуют за воспроизведением, а ещё есть удалённое управление по LAN и групповой джем. + Никаких серверов-посредников. Воспроизведение происходит прямо на вашем устройстве — приватность по умолчанию. + + + Выберите устройство + %1$s: «%2$s» + Воспроизвести + Добавить в очередь + Воспроизвести следующим + Это устройство + На этом устройстве + Удалённое устройство + Удалённое устройство (%1$s) + На подключённом устройстве + + + Не удалось подключиться + Соединение потеряно + Не удалось отправить + Ошибка поиска: %1$s + Не удалось подключиться: %1$s + + + Устройства + Обновить + Отключиться + Подключение... + Поиск устройств в сети... + Устройства не найдены + Убедитесь, что на другом устройстве в настройках включено «Разрешить удалённое управление». + Неизвестное устройство + Определение адреса... + + + Удалённое управление + Отключено + Ошибка подключения: %1$s + Неизвестный трек + Неизвестный исполнитель + Громкость + Удалить из очереди + Другие действия + + + Где воспроизвести? + У вас активный джем. Выберите, где воспроизвести эту подборку. + Воспроизвести здесь + Предложить в джем + + + Брокер для джема не настроен + Не указан хост брокера + Не удалось подключиться + Соединение потеряно + + + Брокер + anonymous + + + Добавлено в очередь джема + + %1$d трек добавлен в очередь джема + %1$d трека добавлено в очередь джема + %1$d треков добавлено в очередь джема + %1$d трека добавлено в очередь джема + + + + Запрос на удалённое управление + «%1$s» хочет управлять воспроизведением на этом устройстве. + Отклонить + Разрешить + Разрешать всегда + + + Результаты поиска + Все + Треки + Пользователи + Нет доступных типов поиска + Начните вводить запрос + Начните вводить, чтобы найти треки + Ничего не найдено + Показать все + Поиск + Загрузка треков... + Загрузка альбомов... + Загрузка исполнителей... + Загрузка плейлистов... + Загрузка пользователей... + Альбомы не найдены + Исполнители не найдены + Плейлисты не найдены + Пользователи не найдены + Роемся в ящиках с пластинками... + Ищем по всей вселенной... + Прогреваем колонки... + Сдуваем пыль с винила... + Советуемся с богами музыки... + Настраиваем антенны... + Перебираем пластинки... + Не удалось загрузить возможности поиска + Не удалось выполнить поиск + Не удалось найти треки + Не удалось найти альбомы + Не удалось найти исполнителей + Не удалось найти плейлисты + Не удалось найти пользователей + + + Обзор + + + Синхронный + Простой + Синхронизированный текст недоступен + Текст недоступен + + + %1$s в %2$s + %1$d kbps + %1$s kHz • %2$d кан. + Плагин метаданных не выбран + + + Назад + Повторить + Закрыть окно + Загрузка... + Неизвестно + Неизвестная ошибка + + + Пользователь + Плейлист + + + Нам очень жаль + Что-то пошло не так. Нам важно ваше впечатление, и мы делаем всё, чтобы это исправить. Попробуйте ещё раз или посмотрите подробности ошибки ниже. + Подробности ошибки + Копировать в буфер обмена + Показать полный журнал + Полный журнал ошибки + + + Перемешать + Перемешать и слушать + Подписаться + Отписаться + Изменить плейлист + Автор: %1$s + + + Действия с треком + Добавить в джем + Запустить радио + Воспроизвести следующим + Сделать следующим + Удалить из очереди + Удалить из избранного + Добавить в избранное + Добавить в плейлист + Скачать + Поделиться + + + Исходный порядок + Название + Исполнитель + Альбом + Длительность + Сортировка + Треки не найдены + Фильтр + Действия с выбранными + Скачать все + Скачать (%1$d) + Добавить все в очередь + Добавить в очередь (%1$d) + Воспроизвести все следующими + Воспроизвести следующими (%1$d) + Добавить все в плейлист + Добавить в плейлист (%1$d) + Добавить все в джем + Добавить в джем (%1$d) + и ещё %1$d + + + Нет URL для загрузки + Назад по истории браузера + Вперёд по истории браузера + + + Фильтр чёрного списка... + Чёрный список пуст + В чёрном списке нет совпадений с «%1$s» + Треки + Трек + Добавить в чёрный список + Удалить из чёрного списка + + + Нет метаданных трека + Не удалось получить URL потока + Не удалось получить URL потока. Сначала попробуйте воспроизвести трек. + + + Сохранённые треки + Вы + + + Вы подписаны + Популярные треки + Слушать популярные треки + Добавить популярные треки в очередь + Похожие исполнители + Рекомендуемые плейлисты + Не удалось загрузить страницу исполнителя + Число подписчиков недоступно + Подписчиков: %1$s тыс. + Подписчиков: %1$s млн + Подписчиков: %1$s млрд + + %1$d подписчик + %1$d подписчика + %1$d подписчиков + %1$d подписчика + + + + Нравится + Больше не нравится + Название трека-заглушки + Исполнители-заглушки + Неизвестный альбом + Перемотка + Громкость + Включить перемешивание + Выключить перемешивание + Предыдущий + Следующий + Воспроизвести или приостановить + Повтор выключен + Повтор трека + Повтор всех + Выключить звук + Включить звук + Другие действия + Параметры плеера + Свернуть плеер + Развернуть текст + Сейчас играет + Текст + Альтернативный источник + Альтернативный источник + Скачать + Скачано + Загрузка... + Уже скачано + Повторная загрузка... + Загрузка: %1$s + Воспроизведение остановится через %1$s. + Поставить воспроизведение на паузу через выбранное число минут. + Минуты + От 1 до 600 минут + Отменить таймер + Запустить + URL воспроизведения был получен с другими настройками стриминга. Выделенный поток соответствует текущим предпочтениям; активный URL обновится при следующем запросе потока. + Подобранный источник аудио + Загрузка подобранного источника… + Удалённый источник не нужен, пока воспроизводится локальный кешированный файл. + Подходящий источник недоступен. + Для этого трека нет кешированного подобранного источника аудио. + Сведения о потоках ещё не закешированы. Сначала воспроизведите трек, чтобы загрузить их. + Не удалось загрузить сведения о треке. + Доступные потоки + Этот трек воспроизводится из локального кеша. Варианты потоков сейчас не используются. + Загрузка потоков… + Для этого источника нет доступных потоков. + Выбранный поток + Соответствует вашим настройкам формата и качества стриминга + Активный URL воспроизведения + Из локального кеша + Трек из метаданных + Название + ID + Альбом + Точность совпадения + Внешний URI + ID альбома + Тип альбома + Дата выхода альбома + Треков в альбоме + Жанры альбома + Длительность + Номер трека + Номер диска + Ненормативный контент + Популярность + ISRC + URL миниатюр + + + Показать или скрыть боковую панель + + + Альтернативные источники + Сейчас ничего не играет + Поиск альтернативных источников... + Альтернативные источники не найдены + Совпадение: %1$d% + Текущий источник + + + Исключить + Удалить участника из сессии + Исключить и запретить возвращаться + Удалить предложения + Удалить все треки, которые участник добавил в очередь + Изменить порядок + Удалить из очереди + %1$s • Добавил(а) %2$s + + + Неизвестно + Загрузка плейлиста... + Добавить треки + Добавить треки в плейлист + Поиск треков... + Поиск + Треки не найдены + Найдите треки, чтобы добавить их + Готово + Добавить + Добавлено + + + Загрузка альбома... + + + Добавить в ваш плейлист + Поиск + Поиск по вашим плейлистам... + Загрузка ваших плейлистов... + У вас пока нет своих плейлистов.\nСоздайте плейлист, чтобы добавлять в него треки. + Новый плейлист + Изменить плейлист + Создать + Название + Название плейлиста + Описание + Описание (необязательно) + Открытый + Этот плейлист виден всем + Совместный + Другие могут добавлять треки в этот плейлист + Обложка + Обложка плейлиста + Нет изображения + Сменить изображение + Выбрать изображение + + + Установить + Заменить + В zip-архиве не найден plugin.json + В zip-архиве не найден manifest.zipline.json + Неверный формат plugin.json: %1$s + Не удалось прочитать плагин: %1$s + Не удалось установить плагин: %1$s + Доступна новая версия %1$s. Подтвердите обновление перед установкой. + %1$s уже установлен. Подтвердите замену перед установкой. + Встроенные плагины уже включены, их нельзя добавить повторно. + Встроенные плагины нельзя удалить. + %1$s указывает недопустимую apiVersion (%2$s). Ожидается семантическая версия, совместимая с %3$s. + Недопустимая версия API плагинов Spotube: %1$s. + %1$s рассчитан на API плагинов %2$s, а Spotube поддерживает несовместимую версию API %3$s. Установите плагин, собранный для той же основной версии API. + + + Несовместимый API плагина + Установить плагин? + %1$s будет добавлен к установленным плагинам. + Обновить плагин? + Установлена более старая версия (%1$s). Обновить до %2$s? + Заменить установленный плагин? + %1$s использует недопустимую семантическую версию (%2$s), поэтому её нельзя сравнить как обновление. Всё равно заменить установленный плагин? + Установленную версию (%1$s) нельзя сравнить по правилам семантического версионирования. Заменить её на %2$s? + Версия %1$s уже установлена. Заменить существующий плагин новой копией? + Установленная версия %1$s новее предложенной версии %2$s. Всё равно заменить? + Заменить установленный плагин новой копией? + + + %1$s: + Версия + Версия API + Автор + Лицензия + Разрешения + Функции + Репозиторий + Контакты + Сообщить об ошибке + Поддержать %1$s + + + Официальный + Проверенный + Звёзды на GitHub + URL репозитория на GitHub + + + Релизы не найдены + Выберите релиз + Выберите релиз + Предварительный релиз + Примечания к выпуску + + + Главная + Поиск + На устройстве + Подключения + Устройства + + + Скрыть окно + Показать окно + Следующий трек + Предыдущий трек + Перемешать + Повтор + Повтор: ВЫКЛ + Повтор: ТРЕК + Повтор: ВСЕ + Громкость: %1$d% + Громкость + + Громкость - + Выключить звук + Включить звук + Нравится + Больше не нравится + Выход + + + Свернуть + Развернуть + Восстановить + Закрыть + + + Воспроизведение + Управление воспроизведением + + + Обзор + Сейчас играет + Рекомендуемое + Сохранённые треки + Популярные треки + diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml new file mode 100644 index 00000000..5c5b861d --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml @@ -0,0 +1,920 @@ + + + + คิว + ล้างคิว + กรองคิว... + ไม่มีรายการในคิว + + ค้นหาเพลง ศิลปิน อัลบั้ม... + ล้างประวัติทั้งหมด + ค้นหา... + + + คลังเพลงของคุณ + คลังเพลง + เพลย์ลิสต์ + อัลบั้ม + ศิลปิน + เพลงในเครื่อง + ดาวน์โหลด + ค้นหาเพลย์ลิสต์ที่บันทึกไว้… + ค้นหาอัลบั้มที่บันทึกไว้… + ค้นหาศิลปินที่บันทึกไว้… + ค้นหาโฟลเดอร์หรือเพลง… + กรองคลังเพลง + ปิดตัวกรอง + สร้างเพลย์ลิสต์ + รีเฟรชเพลงในเครื่อง + เปลี่ยนเป็นมุมมองตาราง + เปลี่ยนเป็นมุมมองรายการ + ไม่มีเพลย์ลิสต์ที่บันทึกไว้ + ไม่มีเพลย์ลิสต์ที่ตรงกับ “%1$s” + ไม่มีอัลบั้มที่บันทึกไว้ + ไม่มีอัลบั้มที่ตรงกับ “%1$s” + ไม่มีศิลปินที่บันทึกไว้ + ไม่มีศิลปินที่ตรงกับ “%1$s” + + + โฟลเดอร์ + โฟลเดอร์ + %1$s • %2$s + + %1$d เพลง + + + %1$d โฟลเดอร์ + + กลับไปที่โฟลเดอร์ + เล่นโฟลเดอร์ + ต้องได้รับสิทธิ์เข้าถึงไฟล์เสียง + อนุญาตการเข้าถึงไฟล์เสียงเพื่อเรียกดูเพลงในอุปกรณ์ของคุณ + อนุญาตการเข้าถึง + ไม่พบโฟลเดอร์ในเครื่อง + ไม่มีเพลงในโฟลเดอร์นี้ + ไม่มีโฟลเดอร์หรือเพลงที่ตรงกัน + รีเฟรชสื่อในเครื่องไม่สำเร็จ + + + กำลังดำเนินการ %1$d • เสร็จสิ้น %2$d + ล้างรายการที่เสร็จแล้ว + ยังไม่มีการดาวน์โหลด + กำลังดาวน์โหลด %1$d% + อยู่ในคิว + เสร็จสิ้น + ยกเลิกแล้ว + ล้มเหลว + ล้มเหลว: %1$s + ยกเลิกการดาวน์โหลด + ลองดาวน์โหลดอีกครั้ง + ลบการดาวน์โหลด + + + ศิลปิน + อยู่ในบัญชีดำ + เพลงที่ถูกใจ + เพลงที่คุณบันทึกไว้ + ไม่ทราบศิลปิน + ชื่อเพลง + ชื่อศิลปิน + + เลือกแล้ว + + การตั้งค่า + + ปลั๊กอิน + ภาษาและภูมิภาค + รูปลักษณ์ + การเล่น + การดาวน์โหลด + แคช + เดสก์ท็อป + อัปเดต + สนับสนุน + เกี่ยวกับ + + ปิด + บันทึก + ยกเลิก + + ภาษา + ภาษาของอินเทอร์เฟซแอป ปัจจุบัน: %1$s + ค่าเริ่มต้นของระบบ + ประเทศ + ประเทศสำหรับเนื้อหาและความพร้อมใช้งาน ปัจจุบัน: %1$s + %1$s (%2$s) + + ธีม + เลือกว่าจะให้ Spotube แสดงผลตามระบบอย่างไร ปัจจุบัน: %1$s + สว่าง + มืด + ตามระบบ + + พื้นผิว + วัสดุพื้นหลังของแอป ปัจจุบัน: %1$s + สายน้ำ + น้ำแข็ง + โครม + อะคริลิก + กระจก + + สีเน้น + ชุดสีปัจจุบัน: %1$s + สีเน้น + เลือกชุดสีเน้นและดูตัวอย่างทั้งในธีมสว่างและธีมมืด + สว่าง + มืด + ตัวอย่าง + ปุ่ม + กรีนก็อบลิน + ม่วงไฟฟ้า + ฟ้าครามมหาสมุทร + ส้มอัสดง + สวนกุหลาบ + น้ำเงินเที่ยงคืน + เทาเมทัลลิก + + รูปลักษณ์และความรู้สึก + วัสดุพื้นผิวและสีเน้น ปัจจุบัน: %1$s · %2$s + รูปลักษณ์และความรู้สึก + เลือกวัสดุพื้นผิว แล้วเลือกสีเน้นที่เข้ากัน ทุกตัวเลือกเป็นชุดที่คัดสรรมาแล้ว + พื้นผิว + สีเน้น + รอง + + ชุดสี + ชุดสีเน้น Millennium ปัจจุบัน: %1$s + ชุดสี + เลือกชุดสีเน้น Millennium และดูตัวอย่างทั้งในโหมดสว่างและมืด + ชุดสี + ชุดสีและสีเน้น Millennium ปัจจุบัน: %1$s + น้ำเงิน + ส้ม + เขียวมะนาว + + กำหนดโฟลเดอร์ดาวน์โหลดเอง + โฟลเดอร์ปัจจุบัน: %1$s + เว้นว่างไว้เพื่อใช้โฟลเดอร์เพลงเริ่มต้น + C:\Music\Spotube + โฟลเดอร์สื่อในเครื่อง + โฟลเดอร์ที่กำหนดเองเพื่อสแกนบนเดสก์ท็อป เพิ่มแล้ว: %1$d + ยังไม่ได้เพิ่มโฟลเดอร์ที่กำหนดเอง + โฟลเดอร์ที่กำหนดเองสำหรับคลังเพลงในเครื่องของคุณ + จัดการ + เพิ่มโฟลเดอร์ + ลบโฟลเดอร์ + ยังไม่ได้เพิ่มโฟลเดอร์ที่กำหนดเอง + ลบ %1$s ออกจากรายการสแกน + โฟลเดอร์ดาวน์โหลดและโฟลเดอร์แคชเพลงจะถูกรวมโดยอัตโนมัติ + โฟลเดอร์ดาวน์โหลดของคุณจะถูกรวมโดยอัตโนมัติ + โฟลเดอร์แคชเพลงจะถูกรวมโดยอัตโนมัติ + กำลังบันทึก… + บันทึกโฟลเดอร์ไม่ได้ การเปลี่ยนแปลงของคุณยังอยู่ โปรดลองอีกครั้ง + + %1$d โฟลเดอร์ + + รูปแบบเพลงที่ดาวน์โหลด + โคเดกและคอนเทนเนอร์ที่ต้องการสำหรับการดาวน์โหลด ปัจจุบัน: %1$s + คุณภาพเพลงที่ดาวน์โหลด + + รูปแบบการสตรีมเพลง + โคเดกและคอนเทนเนอร์ที่ต้องการสำหรับการสตรีมเพลง ปัจจุบัน: %1$s + คุณภาพการสตรีมเพลง + ปัจจุบัน: %1$s + เปิดใช้แคชเพลง + จัดเก็บเสียงที่สตรีมไว้ในเครื่องเพื่อให้เล่นซ้ำได้เร็วขึ้น + โฟลเดอร์แคช + ปัจจุบัน: %1$s + ปัจจุบัน: แคชเริ่มต้นของแอป + ขีดจำกัดขนาดแคช + ไม่จำกัด + ขีดจำกัดปัจจุบัน: %1$d MB + กำหนดขนาดแคชสูงสุดเป็น MB ใส่ 0 หรือเว้นว่างไว้เพื่อไม่จำกัด + 500 + ขนาดต้องเป็น 0 หรือจำนวนบวก + เปิดใช้การเล่นต่อเนื่องไม่สิ้นสุด + เล่นต่อด้วยเพลงแนะนำเมื่อคิวของคุณหมด + พอร์ตเซิร์ฟเวอร์พร็อกซีการเล่น + อนุญาตการควบคุมระยะไกล + ให้อุปกรณ์อื่นในเครือข่ายเดียวกันควบคุมการเล่นได้เมื่อเชื่อมต่อ + ชื่ออุปกรณ์สำหรับควบคุมระยะไกล + ชื่อที่อุปกรณ์อื่นจะเห็นเมื่อค้นหาอุปกรณ์นี้ ปัจจุบัน: %1$s + เลือกชื่อที่จำง่ายเพื่อแสดงให้อุปกรณ์อื่นในเครือข่ายของคุณเห็นเมื่อค้นพบ Spotube เครื่องนี้ + เช่น Spotube ห้องนั่งเล่น + ชื่อโฮสต์ของอุปกรณ์ + พอร์ตที่เซิร์ฟเวอร์พร็อกซีการเล่นใช้ ปัจจุบัน: %1$d + เลือกพอร์ตระหว่าง 1 ถึง 65535 + 14769 + ใส่จำนวนเต็มที่ถูกต้อง + พอร์ตต้องอยู่ระหว่าง 1 ถึง 65535 + + บัญชีดำ + จัดการเพลงและศิลปินในบัญชีดำ + + ย่อไปที่ถาดระบบ + ให้ Spotube ทำงานต่อในถาดระบบเมื่อย่อหน้าต่าง + Discord Rich Presence + แสดงสถานะการเล่นปัจจุบันของคุณใน Discord บนเดสก์ท็อป + + ตรวจหาอัปเดตโดยอัตโนมัติ + ค้นหา Spotube เวอร์ชันใหม่ในเบื้องหลัง + + จัดการปลั๊กอิน + จัดการปลั๊กอินของคุณ + ปลั๊กอิน%1$sเริ่มต้น + เมทาดาทา + เสียง + เนื้อเพลง + Scrobble + ยังไม่ได้เลือกปลั๊กอิน + เปลี่ยน + เลือก + เลือกแล้ว + ล้างการเลือก + ไม่มีปลั๊กอินให้ใช้ + ปลั๊กอิน %1$s + ล้าง + + กำหนดค่า + ตัวจัดการปลั๊กอิน + โปรดใส่ URL + URL ต้องขึ้นต้นด้วย http:// หรือ https:// + ดาวน์โหลดปลั๊กอินไม่สำเร็จ + ยังไม่ได้ติดตั้งปลั๊กอิน + วาง URL สำหรับดาวน์โหลดหรือเลือกไฟล์ด้านบน + + ติดตั้งปลั๊กอินแล้ว %1$d รายการ + + ติดตั้งปลั๊กอิน + https://example.com/plugin.smplug + ดาวน์โหลด + ติดตั้งจากไฟล์ (.smplug) + ใช้งานอยู่ + v%1$s + ในตัว + ลบปลั๊กอิน + เข้าสู่ระบบ + ออกจากระบบ + ดาวน์โหลดจาก URL + ติดตั้งจากไฟล์ + ติดตั้ง + + • %1$s + สิทธิ์ที่ร้องขอ + ปลั๊กอินนี้ไม่ต้องใช้สิทธิ์พิเศษ + ที่ติดตั้งอยู่เทียบกับที่ให้มา + ติดตั้งอยู่ + ที่ให้มา + API %1$s → %2$s + พื้นที่จัดเก็บถาวร + อ่านและเขียนข้อมูลลงดิสก์ + คำขอเครือข่าย + ส่งและรับข้อมูลผ่านอินเทอร์เน็ต + WebView + แสดงเนื้อหาเว็บภายในแอป + แจมกลุ่ม + MQTT Broker + โฮสต์ของ Broker + %1$s:%2$d + พอร์ตของ Broker + ใช้ TLS + ชื่อผู้ใช้ (ไม่บังคับ) + รหัสผ่าน (ไม่บังคับ) + คำนำหน้า Client ID + ใช้เพื่อระบุ Spotube เครื่องนี้ + เข้ารหัสการเชื่อมต่อกับ Broker + ตรวจสอบการตั้งค่า Broker ปัจจุบันก่อนเริ่มห้อง + ทดสอบการเชื่อมต่อ + กำลังทดสอบ… + เชื่อมต่อแล้วใน %1$d ms + ล้มเหลว: %1$s + Broker ตัวอย่าง — กำหนดค่าของคุณเองเพื่อโฮสต์เอง + การเชื่อมต่อ + ข้อมูลเข้าสู่ระบบ + ตัวตน + การวินิจฉัย + + + แจมกลุ่ม + สร้าง + เข้าร่วม + สร้างห้อง แล้วแชร์รหัสห้องให้เพื่อนของคุณ + สร้างห้อง + เข้าร่วมห้อง + กำลังเชื่อมต่อ… + รหัสห้อง + ABC123 + Broker: %1$s + ตั้งค่า Broker ในการตั้งค่าเพื่อสร้างหรือเข้าร่วมห้อง + การตั้งค่า Broker + เชื่อมต่อแล้ว + กำลังเชื่อมต่อใหม่… + คัดลอกรหัส + คัดลอกแล้ว! + แชร์ + ผู้เข้าร่วม (%1$d) + โฮสต์ + ผู้ฟัง + กำลังรอผู้ฟัง… + นำออก + แบน + จัดการ + ออกจากห้อง + เชื่อมต่อไม่ได้ + ปิด + สร้างเซสชันแจมไม่สำเร็จ + เข้าร่วมเซสชันแจมไม่สำเร็จ + ใส่รหัสห้อง 6 ตัวอักษร ที่ประกอบด้วยตัวอักษรและตัวเลข + มาร่วมแจมกับฉันบน Spotube ด้วยรหัส: %1$s + + ไปที่อัลบั้ม + ตั้งเวลาปิด + รายละเอียดเพลง + เล่น + หยุดชั่วคราว + ไม่มีเพลงที่กำลังเล่น + เพิ่มลงในคิว + + ละเว้น + อัปเดต + Spotube %1$s พร้อมให้ใช้งานแล้ว + ดูว่ามีอะไรใหม่ในเวอร์ชันนี้ได้ที่หน้าดาวน์โหลดของ Spotube + %1$s %2$s พร้อมให้ใช้งานแล้ว + ติดตั้งอยู่ในปัจจุบัน: %1$s + ดูว่ามีอะไรใหม่ในอัปเดตนี้ได้จากหน้าของปลั๊กอิน + + ข้าม + ถัดไป + ย้อนกลับ + เริ่มต้นใช้งาน + Spotube + ดนตรีที่ไร้พันธนาการ + เครื่องเล่นโอเพนซอร์สสำหรับโลกแห่งดนตรี\nให้คุณสำรวจ ให้คุณสร้างในแบบของคุณเอง + ช่วยให้โปรเจกต์นี้อยู่ต่อไป + Spotube สร้างขึ้นโดยผู้คน ไม่ใช่ธุรกิจแบบสมัครสมาชิก หากแอปนี้กลายเป็นส่วนหนึ่งในชีวิตประจำวันของคุณ การสนับสนุนเพียงเล็กน้อยจะช่วยให้เราดูแลแอปได้อย่างที่ควรเป็น\n\nขอบคุณที่เปิดพื้นที่ให้ซอฟต์แวร์อิสระ + เริ่มต้นด้วยปลั๊กอินแคตตาล็อกเพื่อค้นหาเพลงและเพลย์ลิสต์ เลือกปลั๊กอินเสียงเพื่อเล่นเพลง ส่วนเนื้อเพลงและประวัติการฟังเป็นตัวเลือกเสริม ค้นพบปลั๊กอินด้านล่าง ตรวจสอบสิทธิ์ ติดตั้ง แล้วเลือกใช้ที่นี่ + ค้นพบปลั๊กอิน + แหล่งที่มาของคุณ + ลองอีกครั้ง + ปลั๊กอินเพิ่มเติม + ไม่พบปลั๊กอินใหม่ คุณยังติดตั้งจากลิงก์หรือไฟล์ .smplug ด้านบนได้ + ไม่สามารถค้นหาปลั๊กอินได้ในขณะนี้ ลองอีกครั้ง หรือติดตั้งจากลิงก์หรือไฟล์ + ยินดีต้อนรับ + สนับสนุน + ปลั๊กอิน + คู่มือ + + วิธีใช้ Spotube + แค่ 4 ขั้นตอนง่าย ๆ ก็เริ่มฟังได้เลย + เข้าสู่ระบบ + เชื่อมต่อบัญชี Spotify ของคุณ หรือเลือกปลั๊กอินแคตตาล็อกของคุณเอง + ค้นหาและเล่น + ค้นหาเพลง อัลบั้ม หรือศิลปิน แล้วกดเล่น + ฟังแบบออฟไลน์ + ดาวน์โหลดเพลงเพื่อฟังต่อได้แม้ไม่มีการเชื่อมต่อ + ร้องตามเนื้อเพลง + เปิดเนื้อเพลงแบบซิงค์จากเครื่องเล่นขณะที่เพลงกำลังเล่น + + + เพลงของคุณ แหล่งที่มาของคุณ กฎของคุณ + เครื่องเล่นเพลงฟรีและโอเพนซอร์ส สร้างโดยชุมชนที่รักในเสียงเพลง + โอเพนซอร์ส + ไม่มีโฆษณา + ไม่มีการเก็บข้อมูล + ขับเคลื่อนด้วยปลั๊กอิน + ไปกันเลย + + + ผู้สนับสนุนคือแรงผลักดันของเรา + สร้างด้วยใจ อยู่ได้ด้วยคุณ + Spotube ฟรี เสรี และโอเพนซอร์ส 100% — ไม่มีโฆษณา ไม่มีการสมัครสมาชิก ไม่มีการติดตาม ทุกฟีเจอร์มีอยู่ได้เพราะคนอย่างคุณเลือกที่จะช่วยให้การพัฒนาเดินหน้าต่อไป + Patreon + ผู้สนับสนุนรายเดือน + สนับสนุนการพัฒนาและโครงสร้างพื้นฐานอย่างต่อเนื่อง + Buy Me a Coffee + กาแฟครั้งเดียว + เติมพลังให้การเขียนโค้ดยามดึกด้วยทิปเล็ก ๆ + Open Collective + กองทุนชุมชน + การสนับสนุนทางการเงินที่โปร่งใสและบริหารโดยชุมชน + ขอบคุณจากใจที่เชื่อมั่นในดนตรีแบบเปิด + การสนับสนุนเป็นทางเลือกโดยสมบูรณ์ — Spotube ยังคงฟรีไม่ว่าอย่างไร + สนับสนุน + เปิด %1$s + + + สนับสนุน Spotube + สนับสนุน Spotube + Spotube ฟรี เสรี และโอเพนซอร์ส — ไม่มีโฆษณา ไม่มีการสมัครสมาชิก ไม่มีการติดตาม หากแอปนี้ทำให้คุณมีความสุข ลองพิจารณาสนับสนุนโปรเจกต์นี้ + คุณปิดการแจ้งเตือนเหล่านี้ได้ที่ การตั้งค่า → สนับสนุน + ไว้ทีหลัง + การแจ้งเตือนให้สนับสนุน + แสดงการแจ้งเตือนเบา ๆ ให้สนับสนุนโปรเจกต์ทุก 7 วัน + + + สถาปัตยกรรมแบบโมดูล + เลือกได้ว่าเพลงของคุณมาจากไหน + Spotube แยกการค้นหาแคตตาล็อก การสตรีมเสียง และเนื้อเพลงแบบซิงค์ออกจากกัน กำหนดค่าเอนจินของคุณด้านล่างหรือเปลี่ยนได้ทุกเมื่อ + เอนจินเมทาดาทา + ขับเคลื่อนการค้นพบเพลง การค้นหา รายชื่อเพลง และเพลย์ลิสต์ + เอนจินเสียง + ดึงและถอดรหัสสตรีมเสียงที่จะเล่น + เอนจินเนื้อเพลง + สตรีมเนื้อเพลงคาราโอเกะที่ซิงค์ตามเวลาแบบเรียลไทม์ + เอนจิน Scrobble + บันทึกประวัติการฟังไปยัง Last.fm หรือ ListenBrainz + ใช้งานอยู่ + ในตัว + พร้อม + ไม่บังคับ + เลือกเอนจิน + เอนจินที่ติดตั้งไว้ + ตั้งเป็นค่าเริ่มต้น + NewPipe YouTube และ LRCLib Lyrics มีมาในตัวและพร้อมสตรีมได้ทันที + จัดการหรือเพิ่มปลั๊กอินจากชุมชนได้ทุกเมื่อที่ การตั้งค่า → ปลั๊กอิน + + + สถาปัตยกรรมย้อนยุคผสมสมัยใหม่ + Hi-Fi บริสุทธิ์ ไม่มีคนกลาง + นี่คือวิธีที่ Spotube จัดการการเล่นแบบกระจายศูนย์บนเครื่องของคุณโดยตรง + เพียร์ทูเพียร์โดยตรง • 256 KBPS OPUS + Blue hour + Spotube · ตัวอย่างเครื่องเล่น + ดนตรีเล็ก ๆ สำหรับช่วงเวลาระหว่างทาง + 1. การค้นพบแบบกระจายศูนย์ + คำค้นหาส่งตรงไปยังปลั๊กอินแคตตาล็อกที่คุณเลือก Spotube ไม่เคยเก็บข้อมูลรสนิยมของคุณ + 2. ระบุสตรีมโดยตรง + เสียงสตรีมตรงจากเซิร์ฟเวอร์เนื้อหามายังเครื่องของคุณ ไม่ผ่านพร็อกซีหรือการเก็บข้อมูลจากระยะไกล + 3. DSP และการเล่นในเครื่อง + ถอดรหัสและแคชในเครื่อง พร้อมอีควอไลเซอร์และการดาวน์โหลดแบบออฟไลน์ (ไม่บังคับ) + 4. เนื้อเพลงแบบซิงค์และแจม + เนื้อเพลงคาราโอเกะเลื่อนตามเวลาที่เล่น พร้อมการควบคุมระยะไกลผ่าน LAN และแจมกลุ่ม + ไม่มีเซิร์ฟเวอร์คนกลาง การเล่นเกิดขึ้นบนอุปกรณ์ของคุณ — เป็นส่วนตัวตั้งแต่การออกแบบ + + + เลือกอุปกรณ์ + %1$s: “%2$s” + เล่น + เพิ่มลงในคิว + เล่นถัดไป + อุปกรณ์นี้ + บนอุปกรณ์นี้ + อุปกรณ์ระยะไกล + อุปกรณ์ระยะไกล (%1$s) + บนอุปกรณ์ที่เชื่อมต่ออยู่ + + + การเชื่อมต่อล้มเหลว + การเชื่อมต่อขาดหาย + ส่งไม่สำเร็จ + การค้นหาล้มเหลว: %1$s + เชื่อมต่อไม่สำเร็จ: %1$s + + + อุปกรณ์ + รีเฟรช + ยกเลิกการเชื่อมต่อ + กำลังเชื่อมต่อ... + กำลังค้นหาอุปกรณ์ในเครือข่าย... + ไม่พบอุปกรณ์ + ตรวจสอบว่าอุปกรณ์อีกเครื่องเปิด “อนุญาตการควบคุมระยะไกล” ไว้ในการตั้งค่า + อุปกรณ์ที่ไม่รู้จัก + กำลังระบุ... + + + รีโมตคอนโทรล + ยกเลิกการเชื่อมต่อแล้ว + ข้อผิดพลาดในการเชื่อมต่อ: %1$s + ไม่ทราบชื่อเพลง + ไม่ทราบศิลปิน + ระดับเสียง + นำออกจากคิว + ตัวเลือกเพิ่มเติม + + + เล่นที่ไหน? + คุณมีเซสชันแจมที่ใช้งานอยู่ เลือกว่าจะเล่นคอลเลกชันนี้ที่ไหน + เล่นที่นี่ + เสนอให้แจม + + + ยังไม่ได้กำหนดค่า Broker สำหรับแจม + โฮสต์ของ Broker ว่างอยู่ + การเชื่อมต่อล้มเหลว + การเชื่อมต่อขาดหาย + + + Broker + anonymous + + + เพิ่มลงในคิวแจมแล้ว + + เพิ่ม %1$d เพลงลงในคิวแจมแล้ว + + + + คำขอควบคุมระยะไกล + “%1$s” ต้องการควบคุมการเล่นบนอุปกรณ์นี้ + ปฏิเสธ + อนุญาต + อนุญาตเสมอ + + + ผลการค้นหา + ทั้งหมด + เพลง + ผู้ใช้ + ไม่มีประเภทการค้นหาให้ใช้ + เริ่มพิมพ์เพื่อค้นหา + เริ่มพิมพ์เพื่อค้นหาเพลง + ไม่พบผลลัพธ์ + ดูทั้งหมด + กำลังค้นหา + กำลังโหลดเพลง... + กำลังโหลดอัลบั้ม... + กำลังโหลดศิลปิน... + กำลังโหลดเพลย์ลิสต์... + กำลังโหลดผู้ใช้... + ไม่พบอัลบั้ม + ไม่พบศิลปิน + ไม่พบเพลย์ลิสต์ + ไม่พบผู้ใช้ + กำลังคุ้ยกล่องแผ่นเสียง... + กำลังค้นหาทั่วจักรวาล... + กำลังวอร์มลำโพง... + กำลังปัดฝุ่นแผ่นไวนิล... + กำลังปรึกษาเทพแห่งดนตรี... + กำลังจูนเสาอากาศ... + กำลังพลิกหาแผ่นเสียง... + โหลดความสามารถในการค้นหาไม่สำเร็จ + ค้นหาไม่สำเร็จ + ค้นหาเพลงไม่สำเร็จ + ค้นหาอัลบั้มไม่สำเร็จ + ค้นหาศิลปินไม่สำเร็จ + ค้นหาเพลย์ลิสต์ไม่สำเร็จ + ค้นหาผู้ใช้ไม่สำเร็จ + + + เรียกดู + + + ซิงค์ + ธรรมดา + ไม่มีเนื้อเพลงแบบซิงค์ + ไม่มีเนื้อเพลงแบบธรรมดา + + + %1$s ใน %2$s + %1$d kbps + %1$s kHz • %2$d ช่อง + ยังไม่ได้เลือกปลั๊กอินเมทาดาทา + + + ย้อนกลับ + ลองอีกครั้ง + ปิดกล่องโต้ตอบ + กำลังโหลด... + ไม่ทราบ + ข้อผิดพลาดที่ไม่ทราบสาเหตุ + + + ผู้ใช้ + เพลย์ลิสต์ + + + ขออภัยเป็นอย่างยิ่ง + มีบางอย่างทำงานไม่ถูกต้องในขณะนี้ ประสบการณ์ของคุณสำคัญสำหรับเรา และเรากำลังพยายามแก้ไขอย่างเต็มที่ โปรดลองอีกครั้งหรือตรวจสอบรายละเอียดข้อผิดพลาดด้านล่าง + รายละเอียดข้อผิดพลาด + คัดลอกไปยังคลิปบอร์ด + ดูบันทึกทั้งหมด + บันทึกข้อผิดพลาดทั้งหมด + + + สุ่มเพลง + เล่นแบบสุ่ม + ติดตาม + เลิกติดตาม + แก้ไขเพลย์ลิสต์ + โดย %1$s + + + ตัวเลือกเพลง + เพิ่มลงในแจม + เริ่มวิทยุ + เล่นถัดไป + ย้ายไปเป็นเพลงถัดไป + นำออกจากคิว + นำออกจากรายการโปรด + บันทึกเป็นรายการโปรด + เพิ่มลงในเพลย์ลิสต์ + ดาวน์โหลด + แชร์ + + + ลำดับเดิม + ชื่อเพลง + ศิลปิน + อัลบั้ม + ความยาว + เรียงลำดับ + ไม่พบเพลง + กรอง + การดำเนินการแบบกลุ่ม + ดาวน์โหลดทั้งหมด + ดาวน์โหลด %1$d เพลง + เพิ่มทั้งหมดลงในคิว + เพิ่ม %1$d เพลงลงในคิว + เล่นทั้งหมดถัดไป + เล่น %1$d เพลงถัดไป + เพิ่มทั้งหมดลงในเพลย์ลิสต์ + เพิ่ม %1$d เพลงลงในเพลย์ลิสต์ + เพิ่มทั้งหมดลงในแจม + เพิ่ม %1$d เพลงลงในแจม + +อีก %1$d + + + ไม่มี URL ให้โหลด + ย้อนกลับในประวัติเบราว์เซอร์ + ไปข้างหน้าในประวัติเบราว์เซอร์ + + + กรองบัญชีดำ... + ไม่มีรายการในบัญชีดำ + ไม่มีรายการในบัญชีดำที่ตรงกับ “%1$s” + เพลง + เพลง + เพิ่มลงในบัญชีดำ + นำออกจากบัญชีดำ + + + ไม่มีเมทาดาทาของเพลง + ไม่สามารถระบุ URL ของสตรีมได้ + ไม่สามารถระบุ URL ของสตรีมได้ ลองเล่นเพลงนี้ก่อน + + + เพลงที่บันทึกไว้ + คุณ + + + กำลังติดตาม + เพลงยอดนิยม + เล่นเพลงยอดนิยม + เพิ่มเพลงยอดนิยมลงในคิว + ศิลปินที่เกี่ยวข้อง + เพลย์ลิสต์แนะนำ + โหลดภาพรวมศิลปินไม่สำเร็จ + ไม่มีข้อมูลผู้ติดตาม + ผู้ติดตาม %1$sK คน + ผู้ติดตาม %1$sM คน + ผู้ติดตาม %1$sB คน + + ผู้ติดตาม %1$d คน + + + + ถูกใจ + เลิกถูกใจ + ชื่อเพลงตัวอย่าง + ศิลปินตัวอย่าง + ไม่ทราบอัลบั้ม + เลื่อนตำแหน่ง + ระดับเสียง + เปิดการสุ่มเพลง + ปิดการสุ่มเพลง + ก่อนหน้า + ถัดไป + เล่นหรือหยุดชั่วคราว + ปิดการเล่นซ้ำ + เล่นซ้ำเพลงเดียว + เล่นซ้ำทั้งหมด + ปิดเสียง + เปิดเสียง + ตัวเลือกเพิ่มเติม + ตัวเลือกเครื่องเล่น + ยุบเครื่องเล่น + ขยายเนื้อเพลง + กำลังเล่น + เนื้อเพลง + แหล่งที่มาสำรอง + แหล่งที่มาสำรอง + ดาวน์โหลด + ดาวน์โหลดแล้ว + กำลังดาวน์โหลด... + ดาวน์โหลดไว้แล้ว + กำลังลองดาวน์โหลดอีกครั้ง... + กำลังดาวน์โหลด %1$s + การเล่นจะหยุดชั่วคราวในอีก %1$s + หยุดการเล่นชั่วคราวหลังจากจำนวนนาทีที่เลือก + นาที + เลือกระหว่าง 1 ถึง 600 นาที + ยกเลิกตัวตั้งเวลา + เริ่ม + URL สำหรับเล่นถูกระบุด้วยการตั้งค่าการสตรีมที่ต่างออกไป สตรีมที่ไฮไลต์ตรงกับค่าที่คุณต้องการในปัจจุบัน URL ที่ใช้งานอยู่จะรีเฟรชเมื่อมีการขอสตรีมครั้งถัดไป + แหล่งเสียงที่จับคู่ได้ + กำลังโหลดแหล่งที่จับคู่ได้… + ไม่จำเป็นต้องใช้แหล่งระยะไกลที่จับคู่ได้ขณะเล่นไฟล์ที่แคชไว้ในเครื่อง + ไม่มีแหล่งที่จับคู่ได้ + ไม่มีแหล่งเสียงที่จับคู่ได้ถูกแคชไว้สำหรับเพลงนี้ + ยังไม่ได้แคชรายละเอียดสตรีม เล่นเพลงนี้ก่อนเพื่อโหลดข้อมูล + โหลดรายละเอียดเพลงไม่สำเร็จ + สตรีมที่มีให้ใช้ + เพลงนี้กำลังเล่นจากแคชในเครื่อง จึงไม่ได้ใช้สตรีมรูปแบบต่าง ๆ สำหรับการเล่นนี้ + กำลังโหลดสตรีม… + ไม่มีสตรีมสำหรับแหล่งนี้ + สตรีมที่เลือกอยู่ในปัจจุบัน + ตรงกับรูปแบบและคุณภาพการสตรีมที่คุณต้องการ + URL ที่ใช้เล่นอยู่ + เล่นจากแคชในเครื่อง + เพลงจากเมทาดาทา + ชื่อเพลง + ID + อัลบั้ม + ความแม่นยำ + URI ภายนอก + ID อัลบั้ม + ประเภทอัลบั้ม + วันที่วางจำหน่ายอัลบั้ม + จำนวนเพลงในอัลบั้ม + แนวเพลงของอัลบั้ม + ความยาว + หมายเลขเพลง + หมายเลขแผ่น + เนื้อหาไม่เหมาะสม + ความนิยม + ISRC + URL ภาพขนาดย่อ + + + สลับแถบด้านข้าง + + + แหล่งที่มาสำรอง + ไม่มีเพลงที่กำลังเล่นอยู่ + กำลังค้นหาแหล่งที่มาสำรอง... + ไม่พบแหล่งที่มาสำรอง + ความแม่นยำ: %1$d% + แหล่งที่ใช้งานอยู่ + + + เตะออก + นำผู้ใช้นี้ออกจากเซสชัน + เตะออกและไม่ให้กลับเข้ามาอีก + ลบเพลงที่เสนอ + ลบทุกเพลงที่ผู้ใช้นี้เพิ่มลงในคิว + จัดลำดับใหม่ + นำออกจากคิว + %1$s • เพิ่มโดย %2$s + + + ไม่ทราบ + กำลังโหลดเพลย์ลิสต์... + เพิ่มเพลง + เพิ่มเพลงลงในเพลย์ลิสต์ + ค้นหาเพลง... + ค้นหา + ไม่พบเพลง + ค้นหาเพลงที่จะเพิ่ม + เสร็จสิ้น + เพิ่ม + เพิ่มแล้ว + + + กำลังโหลดอัลบั้ม... + + + เพิ่มลงในเพลย์ลิสต์ของคุณ + ค้นหา + ค้นหาเพลย์ลิสต์ของคุณ... + กำลังโหลดเพลย์ลิสต์ของคุณ... + คุณยังไม่มีเพลย์ลิสต์ของตัวเอง\nสร้างเพลย์ลิสต์เพื่อเพิ่มเพลง + สร้างเพลย์ลิสต์ + แก้ไขเพลย์ลิสต์ + สร้าง + ชื่อ + ชื่อเพลย์ลิสต์ + คำอธิบาย + คำอธิบาย (ไม่บังคับ) + สาธารณะ + ทุกคนสามารถเห็นเพลย์ลิสต์นี้ + ทำร่วมกัน + ผู้อื่นสามารถเพิ่มเพลงลงในเพลย์ลิสต์นี้ได้ + ภาพปก + ปกเพลย์ลิสต์ + ไม่มีภาพ + เปลี่ยนภาพ + เลือกภาพ + + + ติดตั้ง + แทนที่ + ไม่พบ plugin.json ในไฟล์ zip + ไม่พบ manifest.zipline.json ในไฟล์ zip + รูปแบบ plugin.json ไม่ถูกต้อง: %1$s + อ่านปลั๊กอินไม่สำเร็จ: %1$s + ติดตั้งปลั๊กอินไม่สำเร็จ: %1$s + มี %1$s เวอร์ชันใหม่กว่า โปรดยืนยันการอัปเดตก่อนติดตั้ง + %1$s ติดตั้งอยู่แล้ว โปรดยืนยันการแทนที่ก่อนติดตั้ง + ปลั๊กอินในตัวรวมอยู่แล้วและไม่สามารถเพิ่มซ้ำได้ + ไม่สามารถลบปลั๊กอินในตัวได้ + %1$s ระบุ apiVersion ไม่ถูกต้อง (%2$s) ต้องเป็น semantic versioning ที่เข้ากันได้กับ %3$s + เวอร์ชัน API ปลั๊กอินของ Spotube %1$s ไม่ถูกต้อง + %1$s ใช้ API ปลั๊กอิน %2$s แต่ Spotube รองรับ API ปลั๊กอินแบบ breaking %3$s โปรดติดตั้งปลั๊กอินที่สร้างสำหรับ API เวอร์ชัน breaking เดียวกัน + + + API ปลั๊กอินเข้ากันไม่ได้ + ติดตั้งปลั๊กอินหรือไม่? + %1$s จะถูกเพิ่มลงในปลั๊กอินที่ติดตั้งไว้ของคุณ + อัปเดตปลั๊กอินหรือไม่? + มีการติดตั้งเวอร์ชันเก่ากว่าอยู่ (%1$s) อัปเดตเป็น %2$s หรือไม่? + แทนที่ปลั๊กอินที่ติดตั้งไว้หรือไม่? + %1$s ใช้ semantic version ที่ไม่ถูกต้อง (%2$s) จึงไม่สามารถเปรียบเทียบเป็นการอัปเดตได้ ต้องการแทนที่ปลั๊กอินที่ติดตั้งไว้ต่อไปหรือไม่? + ไม่สามารถเปรียบเทียบเวอร์ชันที่ติดตั้งไว้ (%1$s) ด้วย semantic versioning ได้ แทนที่ด้วย %2$s หรือไม่? + เวอร์ชัน %1$s ติดตั้งอยู่แล้ว แทนที่ปลั๊กอินเดิมด้วยสำเนาที่ให้มาหรือไม่? + เวอร์ชันที่ติดตั้งไว้ %1$s ใหม่กว่าเวอร์ชันที่ให้มา %2$s ต้องการแทนที่ต่อไปหรือไม่? + แทนที่ปลั๊กอินที่ติดตั้งไว้ด้วยสำเนาที่ให้มาหรือไม่? + + + %1$s: + เวอร์ชัน + เวอร์ชัน API + ผู้สร้าง + สัญญาอนุญาต + สิทธิ์การเข้าถึง + ความสามารถ + รีโพซิทอรี + ติดต่อ + รายงานข้อบกพร่อง + สนับสนุน %1$s + + + ทางการ + ยืนยันแล้ว + ดาวบน GitHub + URL รีโพซิทอรี GitHub + + + ไม่พบรีลีส + เลือกรีลีส + เลือกรีลีส + รุ่นทดสอบก่อนเปิดตัว + บันทึกประจำรุ่น + + + หน้าแรก + ค้นหา + ในอุปกรณ์ + เชื่อมต่อ + อุปกรณ์ + + + ซ่อนหน้าต่าง + แสดงหน้าต่าง + เพลงถัดไป + เพลงก่อนหน้า + สุ่มเพลง + เล่นซ้ำ + เล่นซ้ำ: ปิด + เล่นซ้ำ: เพลงเดียว + เล่นซ้ำ: ทั้งหมด + ระดับเสียง: %1$d% + เพิ่มเสียง + + ลดเสียง - + ปิดเสียง + เปิดเสียง + ถูกใจเพลง + เลิกถูกใจเพลง + ออก + + + ย่อขนาด + ขยายใหญ่สุด + คืนขนาด + ปิด + + + การเล่น + ตัวควบคุมการเล่นสื่อ + + + เรียกดู + กำลังเล่น + แนะนำ + เพลงที่บันทึกไว้ + เพลงยอดนิยม + diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml new file mode 100644 index 00000000..4999b886 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -0,0 +1,926 @@ + + + + Sıra + Sırayı temizle + Sırada filtrele... + Sıra boş + + Şarkı, sanatçı, albüm ara... + Tüm geçmişi temizle + Ara... + + + Kitaplığın + Kitaplık + Çalma listeleri + Albümler + Sanatçılar + Yerel parçalar + İndirilenler + Kayıtlı çalma listelerinde ara… + Kayıtlı albümlerde ara… + Kayıtlı sanatçılarda ara… + Klasör veya parça ara… + Kitaplığı filtrele + Filtreyi kapat + Çalma listesi oluştur + Yerel parçaları yenile + Izgara görünümüne geç + Liste görünümüne geç + Kayıtlı çalma listesi yok + “%1$s” ile eşleşen çalma listesi yok + Kayıtlı albüm yok + “%1$s” ile eşleşen albüm yok + Kayıtlı sanatçı yok + “%1$s” ile eşleşen sanatçı yok + + + Klasörler + Klasör + %1$s • %2$s + + %1$d parça + %1$d parça + + + %1$d klasör + %1$d klasör + + Klasörlere dön + Klasörü çal + Ses erişimi gerekli + Cihazındaki müziklere göz atmak için ses erişimine izin ver. + Erişim izni ver + Yerel klasör bulunamadı + Bu klasörde parça yok + Eşleşen klasör veya parça yok + Yerel medya yenilenemedi + + + %1$d etkin • %2$d tamamlandı + Tamamlananları temizle + Henüz indirme yok + İndiriliyor: %1$d% + Sırada + Tamamlandı + İptal edildi + Başarısız + Başarısız: %1$s + İndirmeyi iptal et + İndirmeyi yeniden dene + İndirmeyi kaldır + + + Sanatçı + Kara listede + Beğenilen Şarkılar + Kaydettiğin şarkılar + Bilinmeyen sanatçı + Parça adı + Sanatçı adı + + Seçili + + Ayarlar + + Eklentiler + Dil ve Bölge + Görünüm + Çalma + İndirmeler + Önbellek + Masaüstü + Güncellemeler + Destek + Hakkında + + Kapat + Kaydet + İptal + + Dil + Uygulama arayüzünün dili. Geçerli: %1$s + Sistem varsayılanı + Ülke + İçerik ve kullanılabilirlik için bölge ülkesi. Geçerli: %1$s + %1$s (%2$s) + + Tema + Spotube\'un sistem görünümüne nasıl uyacağını seç. Geçerli: %1$s + Açık + Koyu + Sistem + + Yüzey + Uygulamanın arka plan malzemesi. Geçerli: %1$s + Su + Buz + Krom + Akrilik + Cam + + Vurgu rengi + Geçerli palet: %1$s + Vurgu rengi + Bir vurgu paleti seç ve hem açık hem koyu temada önizle. + Açık + Koyu + Önizleme + Düğme + Yeşil Goblin + Elektrik Moru + Okyanus Camgöbeği + Gün Batımı Turuncusu + Gül Bahçesi + Gece Mavisi + Metalik Arduvaz + + Görünüm ve tarz + Yüzey malzemesi ve vurgu. Geçerli: %1$s · %2$s + Görünüm ve tarz + Önce bir yüzey malzemesi, ardından ona uyan bir vurgu seç. Her seçenek özenle hazırlanmış bir kombinasyon. + Yüzey + Vurgu + İkincil + + Palet + Millennium vurgu paleti. Geçerli: %1$s + Palet + Bir Millennium vurgu paleti seç ve açık ile koyu temada önizle. + Palet + Millennium paleti ve vurgu. Geçerli: %1$s + Mavi + Turuncu + Limon yeşili + + Özel indirme klasörü + Geçerli klasör: %1$s + Varsayılan müzik klasörünü kullanmak için boş bırak. + C:\Music\Spotube + Yerel medya klasörleri + Masaüstünde taranacak özel klasörler. Eklenen: %1$d + Henüz özel klasör eklenmedi + Yerel müzik kitaplığın için özel klasörler. + Yönet + Klasör ekle + Klasörü kaldır + Özel klasör eklenmedi. + %1$s klasörünü tarama listesinden kaldır + İndirme ve müzik önbelleği klasörleri otomatik olarak dahil edilir. + İndirme klasörün otomatik olarak dahil edilir. + Müzik önbelleği klasörü otomatik olarak dahil edilir. + Kaydediliyor… + Klasörler kaydedilemedi. Değişikliklerin duruyor; tekrar dene. + + %1$d klasör + %1$d klasör + + İndirme müzik biçimi + İndirmeler için tercih edilen kodek ve kapsayıcı. Geçerli: %1$s + İndirme müzik kalitesi + + Akış müzik biçimi + Müzik akışı için tercih edilen kodek ve kapsayıcı. Geçerli: %1$s + Akış müzik kalitesi + Geçerli: %1$s + Müzik önbelleğini etkinleştir + Tekrar çalmayı hızlandırmak için akışla gelen sesi cihazda sakla. + Önbellek klasörü + Geçerli: %1$s + Geçerli: varsayılan uygulama önbelleği + Önbellek boyutu sınırı + Sınır yok + Geçerli sınır: %1$d MB + Maksimum önbellek boyutunu MB cinsinden belirle. Sınır olmaması için 0 gir veya boş bırak. + 500 + Boyut 0 veya pozitif bir sayı olmalı. + Sonsuz çalmayı etkinleştir + Sıran bittiğinde önerilen parçalarla çalmaya devam et. + Çalma proxy sunucusu portu + Uzaktan kontrole izin ver + Aynı ağdaki diğer cihazlar bağlandığında çalmayı kontrol edebilsin. + Uzaktan kontrol cihaz adı + Diğer cihazların bu cihazı bulurken gördüğü ad. Geçerli: %1$s + Ağındaki diğer cihazlar bu Spotube\'u bulduğunda görünecek kolay bir ad seç. + örn. Salon Spotube + cihaz ana bilgisayar adı + Çalma proxy sunucusunun kullandığı port. Geçerli: %1$d + 1 ile 65535 arasında bir port seç. + 14769 + Geçerli bir tam sayı gir. + Port 1 ile 65535 arasında olmalı. + + Kara liste + Kara listedeki parçaları ve sanatçıları yönet + + Sistem tepsisine küçült + Pencere küçültüldüğünde Spotube sistem tepsisinde çalışmaya devam etsin. + Discord Rich Presence + Masaüstünde şu an çalanı Discord\'da göster. + + Güncellemeleri otomatik denetle + Yeni Spotube sürümlerini arka planda ara. + + Eklentileri Yönet + Eklentilerini yönet + Varsayılan %1$s eklentisi + Meta veri + Ses + Şarkı sözleri + Scrobble + Eklenti seçilmedi + Değiştir + Seç + Seçili + Seçimi temizle + Kullanılabilir eklenti yok + %1$s eklentisi + Temizle + + Yapılandır + Eklenti Yöneticisi + Lütfen bir URL gir + URL, http:// veya https:// ile başlamalı + Eklenti indirilemedi + Henüz yüklü eklenti yok + Bir indirme URL\'si yapıştır veya yukarıdan bir dosya seç. + + %1$d eklenti yüklü + %1$d eklenti yüklü + + Eklenti Yükle + https://example.com/plugin.smplug + İndir + Dosyadan yükle (.smplug) + Etkin + v%1$s + Yerleşik + Eklentiyi kaldır + Giriş yap + Çıkış yap + URL\'den indir + Dosyadan yükle + Yükle + + • %1$s + İstenen izinler + Bu eklenti özel bir izin gerektirmiyor. + Yüklü ve sağlanan + Yüklü + Sağlanan + API %1$s → %2$s + Kalıcı Depolama + Diske veri okuma ve yazma + Ağ İstekleri + İnternet üzerinden veri gönderme ve alma + WebView + Uygulama içinde web içeriği görüntüleme + Grup Jam + MQTT Aracısı + Aracı ana bilgisayarı + %1$s:%2$d + Aracı portu + TLS kullan + Kullanıcı adı (isteğe bağlı) + Şifre (isteğe bağlı) + İstemci kimliği ön eki + Bu Spotube örneğini tanımlamak için kullanılır + Aracı bağlantısını şifrele + Oda başlatmadan önce geçerli aracı ayarlarını kontrol et. + Bağlantıyı test et + Test ediliyor… + %1$d ms\'de bağlandı + Başarısız: %1$s + Geçici aracı — kendin barındırmak için kendi aracını yapılandır + Bağlantı + Kimlik bilgileri + Kimlik + Tanılama + + + Grup Jam + Oluştur + Katıl + Bir oda oluştur, sonra kodunu arkadaşlarınla paylaş. + Oda oluştur + Odaya katıl + Bağlanıyor… + Oda kodu + ABC123 + Aracı: %1$s + Oda açmak veya bir odaya katılmak için Ayarlar\'da bir aracı ayarla. + Aracı ayarları + Bağlandı + Yeniden bağlanıyor… + Kodu kopyala + Kopyalandı! + Paylaş + Katılımcılar (%1$d) + Sahip + Dinleyici + Dinleyiciler bekleniyor… + Çıkar + Engelle + Yönet + Odadan ayrıl + Bağlanılamadı + Kapat + Jam oturumu oluşturulamadı + Jam oturumuna katılınamadı + Harf ve rakamlardan oluşan altı karakterli bir oda kodu gir. + Spotube Jam\'ime şu kodla katıl: %1$s + + Albüme git + Uyku zamanlayıcısı + Parça ayrıntıları + Çal + Duraklat + Hiçbir şey çalmıyor + Sıraya ekle + + Yok say + Güncelle + Spotube %1$s kullanıma sunuldu + Bu sürümdeki yenilikleri Spotube indirme sayfasında gör. + %1$s %2$s kullanıma sunuldu + Şu an yüklü: %1$s + Bu güncellemedeki yenilikleri eklentinin sayfasında gör. + + Atla + İleri + Geri + Başla + Spotube + Müzik, hiçbir kısıtlama olmadan. + Koca bir müzik dünyası için açık kaynaklı bir oynatıcı.\nKeşfetmesi sana kalmış. Kendine göre şekillendirmesi de. + Bu projeyi yaşat. + Spotube\'u bir abonelik şirketi değil, insanlar geliştiriyor. Günlük hayatının bir parçası olduysa, küçük bir destek bile ona hak ettiği özeni göstermemize yardımcı olur.\n\nBağımsız yazılıma yer açtığın için teşekkürler. + Şarkı ve çalma listesi bulmak için bir katalog eklentisiyle başla. Onları çalmak için bir ses eklentisi seç. Şarkı sözleri ve dinleme geçmişi isteğe bağlı. Aşağıdan bir eklenti keşfet, izinlerini incele, yükle ve burada seç. + Eklentileri keşfet + Kaynakların + Tekrar dene + Daha fazla eklenti + Yeni eklenti bulunamadı. Yukarıdan bir bağlantı veya .smplug dosyasıyla da yükleyebilirsin. + Eklenti keşfi şu anda kullanılamıyor. Tekrar dene ya da bir bağlantı veya dosyadan yükle. + Hoş geldin + Destek + Eklentiler + Rehber + + Spotube nasıl kullanılır + Dört hızlı adım ve dinlemeye başlıyorsun. + Giriş yap + Spotify hesabını bağla veya kendi katalog eklentini seç. + Ara ve çal + Herhangi bir şarkıyı, albümü veya sanatçıyı bul, sonra çal\'a bas. + Çevrimdışı dinle + Bağlantı olmadan dinlemeye devam etmek için parçaları indir. + Sözleri takip et + Bir parça çalarken oynatıcıdan senkronize sözleri aç. + + + Senin müziğin. Senin kaynakların. Senin kuralların. + Müziği sadece seven bir topluluğun geliştirdiği ücretsiz, açık kaynaklı bir müzik oynatıcı. + Açık kaynak + Reklamsız + Telemetri yok + Eklenti destekli + Hadi Başlayalım + + + Destekçilerimiz bizi ayakta tutuyor + Tutkuyla yapıldı. Seninle yaşıyor. + Spotube %100 ücretsiz, özgür ve açık kaynak — reklam yok, abonelik yok, takip yok. Her özellik, kodun akmaya devam etmesine yardım etmeyi seçen senin gibi insanlar sayesinde var. + Patreon + Aylık Sponsor + Sürekli geliştirmeyi ve altyapıyı destekle + Buy Me a Coffee + Tek Seferlik Kahve + Küçük bir bahşişle gece yarısı kodlama seansına güç ver + Open Collective + Topluluk Fonu + Şeffaf, topluluk tarafından yönetilen finansal destek + Açık müziğe inandığın için tüm kalbimizle teşekkür ederiz. + Destek tamamen isteğe bağlı — Spotube her durumda ücretsiz kalır. + Sponsor ol + %1$s sayfasını aç + + + Spotube\'u destekle + Spotube\'u destekle + Spotube ücretsiz, özgür ve açık kaynak — reklam yok, abonelik yok, takip yok. Sana keyif veriyorsa projeyi desteklemeyi düşünebilirsin. + Bu hatırlatıcıları Ayarlar → Destek bölümünden kapatabilirsin. + Belki sonra + Destek hatırlatıcıları + Projeyi desteklemek için her 7 günde bir nazik bir hatırlatma göster. + + + Modüler Mimari + Müziğinin nereden geleceğini seç. + Spotube katalog keşfini, ses akışını ve senkronize sözleri birbirinden ayırır. Motorlarını aşağıdan yapılandır veya istediğin zaman değiştir. + Meta Veri Motoru + Müzik keşfini, aramayı, parça listelerini ve çalma listelerini sağlar. + Ses Motoru + Çalınan ses akışlarını getirir ve çözer. + Şarkı Sözü Motoru + Zamanla senkronize karaoke sözlerini gerçek zamanlı olarak sunar. + Scrobble Motoru + Dinleme geçmişini Last.fm veya ListenBrainz\'e kaydeder. + Etkin + Yerleşik + Hazır + İsteğe bağlı + Bir motor seç + Yüklü Motorlar + Varsayılan yap + NewPipe YouTube ve LRCLib Lyrics yerleşiktir ve kutudan çıktığı gibi akışa hazırdır. + Topluluk eklentilerini istediğin zaman Ayarlar → Eklentiler bölümünden yönetebilir veya yenilerini ekleyebilirsin. + + + Retro-Modern Mimari + Saf Hi-Fi, sıfır aracı + Spotube\'un merkeziyetsiz çalmayı doğrudan cihazında nasıl yönettiğini gör. + DOĞRUDAN EŞLER ARASI • 256 KBPS OPUS + Mavi saat + Spotube · oynatıcı tanıtımı + Aradaki anlar için biraz müzik. + 1. Merkeziyetsiz Keşif + Sorgular doğrudan seçtiğin katalog eklentisine gider. Spotube zevkinin profilini asla çıkarmaz. + 2. Doğrudan Akış Çözümleme + Ses, içerik sunucularından doğrudan cihazına akar. Proxy aktarması veya uzaktan veri toplama yok. + 3. Yerel DSP ve Çalma + Ekolayzer kontrolleri ve isteğe bağlı çevrimdışı indirme ile cihazında çözülür ve önbelleğe alınır. + 4. Senkronize Sözler ve Jam + Karaoke sözleri çalma zamanını takip eder; LAN üzerinden uzaktan kontrol ve Grup Jam de var. + Aracı sunucu yok. Çalma doğrudan cihazında gerçekleşir — tasarımı gereği gizli. + + + Bir cihaz seç + %1$s: “%2$s” + Çal + Sıraya ekle + Sonra çal + Bu Cihaz + Bu cihazda + Uzak Cihaz + Uzak Cihaz (%1$s) + Bağlı cihazda + + + Bağlantı başarısız + Bağlantı kesildi + Gönderilemedi + Keşif başarısız: %1$s + Bağlanılamadı: %1$s + + + Cihazlar + Yenile + Bağlantıyı kes + Bağlanıyor... + Ağda cihaz aranıyor... + Cihaz bulunamadı + Diğer cihazın ayarlarında \"Uzaktan kontrole izin ver\" seçeneğinin açık olduğundan emin ol. + Bilinmeyen Cihaz + Çözümleniyor... + + + Uzaktan Kontrol + Bağlantı kesildi + Bağlantı hatası: %1$s + Bilinmeyen Parça + Bilinmeyen Sanatçı + Ses düzeyi + Sıradan kaldır + Diğer seçenekler + + + Nerede Çalınsın? + Etkin bir Jam oturumun var. Bu koleksiyonun nerede çalınacağını seç. + Burada çal + Jam\'e öner + + + Jam aracısı yapılandırılmadı + Aracı ana bilgisayarı boş + Bağlantı başarısız + Bağlantı kesildi + + + Aracı + anonymous + + + Jam sırasına eklendi + + %1$d parça Jam sırasına eklendi + %1$d parça Jam sırasına eklendi + + + + Uzaktan Kontrol İsteği + \"%1$s\" bu cihazda çalmayı kontrol etmek istiyor. + Reddet + İzin ver + Her Zaman İzin Ver + + + Arama sonuçları + Tümü + Parçalar + Kullanıcılar + Kullanılabilir arama türü yok + Aramak için yazmaya başla + Parça aramak için yazmaya başla + Sonuç bulunamadı + Tümünü gör + Aranıyor + Parçalar yükleniyor... + Albümler yükleniyor... + Sanatçılar yükleniyor... + Çalma listeleri yükleniyor... + Kullanıcılar yükleniyor... + Albüm bulunamadı + Sanatçı bulunamadı + Çalma listesi bulunamadı + Kullanıcı bulunamadı + Plak kasaları karıştırılıyor... + Evren taranıyor... + Hoparlörler ısınıyor... + Plakların tozu alınıyor... + Müzik tanrılarına danışılıyor... + Antenler ayarlanıyor... + Plaklar karıştırılıyor... + Arama özellikleri yüklenemedi + Arama yapılamadı + Parçalar aranamadı + Albümler aranamadı + Sanatçılar aranamadı + Çalma listeleri aranamadı + Kullanıcılar aranamadı + + + Göz at + + + Senkronize + Düz + Senkronize şarkı sözü yok + Düz şarkı sözü yok + + + %2$s içinde %1$s + %1$d kbps + %1$s kHz • %2$d kanal + Meta veri eklentisi seçilmedi + + + Geri + Tekrar dene + İletişim kutusunu kapat + Yükleniyor... + Bilinmiyor + Bilinmeyen hata + + + Kullanıcı + Çalma listesi + + + Gerçekten Üzgünüz + Şu anda bir şeyler düzgün çalışmıyor. Deneyimin bizim için önemli ve bunu düzeltmek için elimizden geleni yapıyoruz. Lütfen tekrar dene veya aşağıdaki hata ayrıntılarına göz at. + Hata Ayrıntıları + Panoya kopyala + Tam günlüğü görüntüle + Tam Hata Günlüğü + + + Karıştır + Karışık çal + Takip et + Takibi bırak + Çalma listesini düzenle + %1$s tarafından + + + Parça seçenekleri + Jam\'e ekle + Radyo başlat + Sonra çal + Sıradakine taşı + Sıradan kaldır + Favorilerden kaldır + Favorilere kaydet + Çalma listesine ekle + İndir + Paylaş + + + Orijinal + Başlık + Sanatçı + Albüm + Süre + Sırala + Parça bulunamadı + Filtrele + Toplu işlemler + Tümünü İndir + %1$d Parçayı İndir + Tümünü Sıraya Ekle + %1$d Parçayı Sıraya Ekle + Tümünü Sonra Çal + %1$d Parçayı Sonra Çal + Tümünü Çalma Listesine Ekle + %1$d Parçayı Çalma Listesine Ekle + Tümünü Jam\'e Ekle + %1$d Parçayı Jam\'e Ekle + +%1$d kişi daha + + + Yüklenecek URL yok + Tarayıcı geçmişinde geri git + Tarayıcı geçmişinde ileri git + + + Kara listeyi filtrele... + Kara listede öğe yok + Kara listede \'%1$s\' ile eşleşen öğe yok + Parçalar + Parça + Kara listeye ekle + Kara listeden kaldır + + + Parça meta verisi yok + Akış URL\'si çözümlenemedi + Akış URL\'si çözümlenemedi. Önce parçayı çalmayı dene. + + + Kayıtlı Parçalar + Sen + + + Takip ediliyor + Popüler Parçalar + Popüler parçaları çal + Popüler parçaları sıraya ekle + Benzer Sanatçılar + Öne Çıkan Çalma Listeleri + Sanatçı sayfası yüklenemedi + Takipçi sayısı kullanılamıyor + %1$s B takipçi + %1$s Mn takipçi + %1$s Mr takipçi + + %1$d takipçi + %1$d takipçi + + + + Beğen + Beğenmekten vazgeç + Yer tutucu parça adı + Yer tutucu sanatçılar + Bilinmeyen Albüm + Konum + Ses düzeyi + Karıştırmayı aç + Karıştırmayı kapat + Önceki + Sonraki + Çal veya duraklat + Tekrar kapalı + Bir parçayı tekrarla + Tümünü tekrarla + Sesi kapat + Sesi aç + Diğer seçenekler + Oynatıcı seçenekleri + Oynatıcıyı daralt + Sözleri genişlet + Şu An Çalıyor + Şarkı Sözleri + Alternatif Kaynak + Alternatif kaynak + İndir + İndirildi + İndiriliyor... + Zaten indirildi + İndirme yeniden deneniyor... + %1$s indiriliyor + Çalma %1$s sonra duraklatılacak. + Seçilen dakika sayısından sonra çalmayı duraklat. + Dakika + 1 ile 600 dakika arasında seç + Zamanlayıcıyı iptal et + Başlat + Çalma URL\'si farklı akış ayarlarıyla çözümlendi. Vurgulanan akış geçerli tercihinle eşleşiyor; etkin URL bir sonraki akış isteğinde yenilenir. + Eşleşen ses kaynağı + Eşleşen kaynak yükleniyor… + Önbellekteki yerel dosya çalınırken eşleşen bir uzak kaynağa gerek yok. + Eşleşen kaynak yok. + Bu parça için önbellekte eşleşen ses kaynağı yok. + Akış ayrıntıları henüz önbelleğe alınmadı. Yüklemek için önce parçayı çal. + Parça ayrıntıları yüklenemedi. + Kullanılabilir akışlar + Bu parça yerel önbellekten çalınıyor. Bu çalma için akış varyantları kullanılmıyor. + Akışlar yükleniyor… + Bu kaynak için kullanılabilir akış yok. + Şu an seçili akış + Akış biçimi ve kalite tercihinle eşleşiyor + Etkin çalma URL\'si + Yerel önbellekten + Meta veri parçası + Başlık + Kimlik + Albüm + Eşleşme güveni + Harici URI + Albüm kimliği + Albüm türü + Albüm çıkış tarihi + Albümdeki parça sayısı + Albüm türleri + Süre + Parça numarası + Disk numarası + Müstehcen + Popülerlik + ISRC + Küçük resim URL\'leri + + + Kenar çubuğunu aç/kapat + + + Alternatif Kaynaklar + Şu anda çalan parça yok + Alternatif kaynaklar aranıyor... + Alternatif kaynak bulunamadı + Eşleşme: %1$d% + Etkin kaynak + + + At + Oturumdan çıkar + At ve yeniden katılmasını engelle + Önerileri kaldır + Sıraya eklediği tüm parçaları kaldır + Yeniden sırala + Sıradan kaldır + %1$s • Ekleyen: %2$s + + + Bilinmiyor + Çalma listesi yükleniyor... + Parça Ekle + Çalma Listesine Parça Ekle + Parça ara... + Ara + Parça bulunamadı + Eklemek için parça ara + Bitti + Ekle + Eklendi + + + Albüm yükleniyor... + + + Çalma Listene Ekle + Ara + Çalma listelerinde ara... + Çalma listelerin yükleniyor... + Henüz sana ait bir çalma listesi yok.\nParça eklemek için bir tane oluştur. + Çalma Listesi Oluştur + Çalma Listesini Düzenle + Oluştur + Ad + Çalma listesi adı + Açıklama + Açıklama (isteğe bağlı) + Herkese açık + Bu çalma listesini herkes görebilir + Ortak + Başkaları bu çalma listesine parça ekleyebilir + Kapak Görseli + Çalma listesi kapağı + Görsel yok + Görseli Değiştir + Görsel Seç + + + Yükle + Değiştir + Zip dosyasında plugin.json bulunamadı + Zip dosyasında manifest.zipline.json bulunamadı + Geçersiz plugin.json biçimi: %1$s + Eklenti okunamadı: %1$s + Eklenti yüklenemedi: %1$s + %1$s için daha yeni bir sürüm var. Yüklemeden önce güncellemeyi onayla. + %1$s zaten yüklü. Yüklemeden önce değiştirmeyi onayla. + Yerleşik eklentiler zaten dahil ve tekrar eklenemez. + Yerleşik eklentiler kaldırılamaz. + %1$s geçersiz bir apiVersion (%2$s) bildiriyor. %3$s ile uyumlu anlamsal sürümleme bekleniyor. + Spotube eklenti API sürümü %1$s geçersiz. + %1$s, eklenti API\'si %2$s hedefliyor ancak Spotube uyumsuz eklenti API\'si %3$s destekliyor. Aynı ana API sürümü için derlenmiş bir eklenti yükle. + + + Eklenti API\'si uyumlu değil + Eklenti yüklensin mi? + %1$s yüklü eklentilerine eklenecek. + Eklenti güncellensin mi? + Daha eski bir sürüm yüklü (%1$s). %2$s sürümüne güncellensin mi? + Yüklü eklenti değiştirilsin mi? + %1$s geçersiz bir anlamsal sürüm (%2$s) kullanıyor ve güncelleme olarak karşılaştırılamıyor. Yüklü eklenti yine de değiştirilsin mi? + Yüklü sürüm (%1$s) anlamsal sürümlemeyle karşılaştırılamıyor. %2$s ile değiştirilsin mi? + %1$s sürümü zaten yüklü. Mevcut eklenti sağlanan kopyayla değiştirilsin mi? + Yüklü %1$s sürümü, sağlanan %2$s sürümünden daha yeni. Yine de değiştirilsin mi? + Yüklü eklenti sağlanan kopyayla değiştirilsin mi? + + + %1$s: + Sürüm + API Sürümü + Geliştirici + Lisans + Yetenekler + İşlevler + Depo + İletişim + Hata Bildir + %1$s eklentisini destekle + + + Resmi + Doğrulanmış + GitHub yıldızları + GitHub depo URL\'si + + + Sürüm bulunamadı + Sürüm Seç + Bir sürüm seç + Ön sürüm + Sürüm Notları + + + Ana sayfa + Ara + Cihazda + Bağlan + Cihazlar + + + Pencereyi Gizle + Pencereyi Göster + Sonraki Parça + Önceki Parça + Karıştır + Tekrarla + Tekrar: KAPALI + Tekrar: BİR + Tekrar: TÜMÜ + Ses düzeyi: %1$d% + Ses + + Ses - + Sesi Kapat + Sesi Aç + Parçayı Beğen + Beğenmekten Vazgeç + Çıkış + + + Simge durumuna küçült + Ekranı kapla + Önceki boyuta getir + Kapat + + + Çalma + Medya çalma kontrolleri + + + Göz at + Şu An Çalıyor + Öne Çıkanlar + Kayıtlı Parçalar + Popüler Parçalar + diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml new file mode 100644 index 00000000..1f07bb5d --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml @@ -0,0 +1,938 @@ + + + + Черга + Очистити чергу + Фільтр черги... + Черга порожня + + Треки, виконавці, альбоми... + Очистити історію + Пошук... + + + Моя бібліотека + Бібліотека + Плейлисти + Альбоми + Виконавці + Локальні треки + Завантаження + Пошук у збережених плейлистах… + Пошук у збережених альбомах… + Пошук серед збережених виконавців… + Пошук папок або треків… + Фільтр бібліотеки + Закрити фільтр + Створити плейлист + Оновити локальні треки + Показати сіткою + Показати списком + Немає збережених плейлистів + Немає плейлистів за запитом «%1$s» + Немає збережених альбомів + Немає альбомів за запитом «%1$s» + Немає збережених виконавців + Немає виконавців за запитом «%1$s» + + + Папки + Папка + %1$s • %2$s + + %1$d трек + %1$d треки + %1$d треків + %1$d трека + + + %1$d папка + %1$d папки + %1$d папок + %1$d папки + + Назад до папок + Слухати папку + Потрібен доступ до аудіо + Дозвольте доступ до аудіо, щоб переглядати музику на пристрої. + Надати доступ + Локальних папок не знайдено + У цій папці немає треків + Немає відповідних папок або треків + Не вдалося оновити локальні файли + + + Активних: %1$d • Завершених: %2$d + Очистити завершені + Завантажень поки немає + Завантаження %1$d% + У черзі + Завершено + Скасовано + Помилка + Помилка: %1$s + Скасувати завантаження + Повторити завантаження + Видалити завантаження + + + Виконавець + У чорному списку + Улюблені треки + Ваші збережені треки + Невідомий виконавець + Назва треку + Ім’я виконавця + + Вибрано + + Налаштування + + Плагіни + Мова та регіон + Оформлення + Відтворення + Завантаження + Кешування + Комп’ютер + Оновлення + Підтримка + Про застосунок + + Закрити + Зберегти + Скасувати + + Мова + Мова інтерфейсу застосунку. Зараз: %1$s + Як у системі + Країна + Країна для контенту та доступності. Зараз: %1$s + %1$s (%2$s) + + Тема + Як Spotube підлаштовується під тему системи. Зараз: %1$s + Світла + Темна + Системна + + Поверхня + Фоновий матеріал застосунку. Зараз: %1$s + Вода + Лід + Хром + Акрил + Скло + + Акцентний колір + Поточна палітра: %1$s + Акцентний колір + Виберіть акцентну палітру й подивіться, як вона виглядає у світлій і темній темах. + Світла + Темна + Попередній перегляд + Кнопка + Зелений гоблін + Електрик-фіолетовий + Океанський ціан + Помаранчевий захід + Трояндовий сад + Опівнічний синій + Металік-графіт + + Зовнішній вигляд + Матеріал поверхні й акцент. Зараз: %1$s · %2$s + Зовнішній вигляд + Виберіть матеріал поверхні, а потім відповідний акцент. Кожен варіант — продумане поєднання. + Поверхня + Акцент + Другорядна + + Палітра + Акцентна палітра Millennium. Зараз: %1$s + Палітра + Виберіть акцентну палітру Millennium і перегляньте її у світлій і темній темах. + Палітра + Палітра Millennium і акцент. Зараз: %1$s + Синя + Помаранчева + Лаймова + + Власна папка для завантажень + Поточна папка: %1$s + Залиште порожнім, щоб використовувати стандартну папку «Музика». + C:\Music\Spotube + Папки з локальною музикою + Додаткові папки для сканування на комп’ютері. Додано: %1$d + Додаткових папок ще не додано + Додаткові папки для вашої локальної бібліотеки. + Керувати + Додати папку + Видалити папку + Додаткових папок не додано. + Прибрати %1$s зі списку сканування + Папки завантажень і музичного кешу додаються автоматично. + Папка завантажень додається автоматично. + Папка музичного кешу додається автоматично. + Збереження… + Не вдалося зберегти папки. Зміни не втрачено — спробуйте ще раз. + + %1$d папка + %1$d папки + %1$d папок + %1$d папки + + Формат завантаження музики + Бажаний кодек і контейнер для завантажень. Зараз: %1$s + Якість завантаження музики + + Формат стримінгу музики + Бажаний кодек і контейнер для стримінгу. Зараз: %1$s + Якість стримінгу музики + Зараз: %1$s + Кешувати музику + Зберігати прослухане аудіо на пристрої для швидшого повторного відтворення. + Папка кешу + Зараз: %1$s + Зараз: стандартний кеш застосунку + Ліміт розміру кешу + Без обмежень + Поточний ліміт: %1$d MB + Укажіть максимальний розмір кешу в MB. Введіть 0 або залиште порожнім, щоб зняти обмеження. + 500 + Розмір має бути 0 або додатним числом. + Безкінечне відтворення + Продовжувати відтворення рекомендованими треками, коли черга закінчиться. + Порт проксі-сервера відтворення + Дозволити віддалене керування + Дозволити іншим пристроям у тій самій мережі керувати відтворенням після підключення. + Назва пристрою для віддаленого керування + Назва, яку бачать інші пристрої під час пошуку. Зараз: %1$s + Задайте зрозумілу назву, яку бачитимуть інші пристрої у вашій мережі, коли знайдуть цей Spotube. + наприклад, Spotube у вітальні + ім’я хоста пристрою + Порт проксі-сервера відтворення. Зараз: %1$d + Укажіть порт від 1 до 65535. + 14769 + Введіть ціле число. + Порт має бути від 1 до 65535. + + Чорний список + Керування заблокованими треками й виконавцями + + Згортати в трей + Залишати Spotube в системному треї, коли вікно згорнуто. + Статус у Discord + Показувати в Discord, що зараз грає (на комп’ютері). + + Автоматично перевіряти оновлення + Шукати нові версії Spotube у фоновому режимі. + + Керування плагінами + Керуйте своїми плагінами + Плагін за замовчуванням: %1$s + Метадані + Аудіо + Тексти пісень + Скроблінг + Плагін не вибрано + Змінити + Вибрати + Вибрано + Скинути вибір + Немає доступних плагінів + Плагін %1$s + Скинути + + Налаштувати + Менеджер плагінів + Введіть URL + URL має починатися з http:// або https:// + Не вдалося завантажити плагін + Плагіни ще не встановлено + Вставте посилання для завантаження або виберіть файл вище. + + %1$d плагін встановлено + %1$d плагіни встановлено + %1$d плагінів встановлено + %1$d плагіна встановлено + + Встановлення плагіна + https://example.com/plugin.smplug + Завантажити + Встановити з файлу (.smplug) + Активний + v%1$s + Вбудований + Видалити плагін + Увійти + Вийти + Завантажити за URL + Встановити з файлу + Встановити + + • %1$s + Запитувані дозволи + Цьому плагіну не потрібні спеціальні дозволи. + Встановлений і новий + Встановлений + Новий + API %1$s → %2$s + Постійне сховище + Читання й запис даних на диск + Мережеві запити + Надсилання й отримання даних через інтернет + WebView + Показ вебвмісту всередині застосунку + Груповий джем + MQTT-брокер + Хост брокера + %1$s:%2$d + Порт брокера + Використовувати TLS + Ім’я користувача (необов’язково) + Пароль (необов’язково) + Префікс ID клієнта + Використовується для ідентифікації цього екземпляра Spotube + Шифрувати підключення до брокера + Перевірте поточні налаштування брокера перед створенням кімнати. + Перевірити підключення + Перевірка… + Підключено за %1$d мс + Помилка: %1$s + Тимчасовий брокер — налаштуйте власний, щоб розміщувати джеми самостійно + Підключення + Облікові дані + Ідентифікація + Діагностика + + + Груповий джем + Створити + Приєднатися + Створіть кімнату й поділіться її кодом із друзями. + Створити кімнату + Увійти в кімнату + Підключення… + Код кімнати + ABC123 + Брокер: %1$s + Налаштуйте брокер у налаштуваннях, щоб створити кімнату або приєднатися до неї. + Налаштування брокера + Підключено + Повторне підключення… + Копіювати код + Скопійовано! + Поділитися + Учасники (%1$d) + Організатор + Слухач + Чекаємо на слухачів… + Видалити + Заблокувати + Керувати + Вийти з кімнати + Не вдалося підключитися + Закрити + Не вдалося створити джем + Не вдалося приєднатися до джему + Введіть код кімнати з шести літер і цифр. + Приєднуйся до мого джему в Spotube за кодом: %1$s + + Перейти до альбому + Таймер сну + Відомості про трек + Відтворити + Пауза + Зараз нічого не грає + Додати до черги + + Пропустити + Оновити + Доступний Spotube %1$s + Дізнайтеся, що нового в цій версії, на сторінці завантажень Spotube. + Доступний %1$s %2$s + Встановлена версія: %1$s + Дізнайтеся, що нового в цьому оновленні, на сторінці плагіна. + + Пропустити + Далі + Назад + Почати + Spotube + Музика без зайвих умов. + Плеєр з відкритим кодом для цілого світу музики.\nДосліджуйте його. Зробіть його своїм. + Допоможіть проєкту жити. + Spotube створюють люди, а не бізнес на підписках. Якщо він став частиною вашого життя, навіть невелика підтримка допоможе нам приділяти йому ту увагу, на яку він заслуговує.\n\nДякуємо, що знаходите місце для незалежного софту. + Почніть із плагіна каталогу, щоб знаходити треки й плейлисти. Виберіть аудіоплагін для їх відтворення. Тексти пісень та історія прослуховувань — за бажанням. Знайдіть плагін нижче, перегляньте його дозволи, встановіть і виберіть тут. + Огляд плагінів + Ваші джерела + Повторити + Інші плагіни + Нових плагінів не знайдено. Плагін також можна встановити за посиланням або з файлу .smplug вище. + Каталог плагінів зараз недоступний. Повторіть спробу або встановіть плагін за посиланням чи з файлу. + Вітаємо + Підтримка + Плагіни + Посібник + + Як користуватися Spotube + Чотири прості кроки — і музика вже грає. + Вхід + Підключіть акаунт Spotify або виберіть власний плагін каталогу. + Шукайте й слухайте + Знайдіть будь-який трек, альбом чи виконавця й натисніть «Відтворити». + Слухайте офлайн + Завантажуйте треки, щоб слухати їх без інтернету. + Стежте за текстом + Відкривайте синхронізований текст у плеєрі під час відтворення. + + + Ваша музика. Ваші джерела. Ваші правила. + Безкоштовний музичний плеєр з відкритим кодом від спільноти, яка просто любить музику. + Відкритий код + Без реклами + Без телеметрії + На плагінах + Поїхали + + + Нас підтримують слухачі + Створено з любов’ю. Живе завдяки вам. + Spotube на 100% безкоштовний, вільний і відкритий — без реклами, підписок і стеження. Кожна функція існує завдяки таким людям, як ви, які допомагають проєкту розвиватися. + Patreon + Щомісячна підтримка + Підтримайте постійну розробку та інфраструктуру + Buy Me a Coffee + Разова кава + Підбадьорте нічну сесію програмування невеликим донатом + Open Collective + Фонд спільноти + Прозоре фінансування під керуванням спільноти + Щиро дякуємо, що вірите у відкриту музику. + Підтримка повністю добровільна — Spotube у будь-якому разі залишиться безкоштовним. + Підтримати + Відкрити %1$s + + + Підтримати Spotube + Підтримайте Spotube + Spotube безкоштовний, вільний і відкритий — без реклами, підписок і стеження. Якщо він приносить вам радість, підтримайте проєкт. + Ці нагадування можна вимкнути в розділі «Налаштування → Підтримка». + Можливо, пізніше + Нагадування про підтримку + Раз на 7 днів ненав’язливо нагадувати про підтримку проєкту. + + + Модульна архітектура + Виберіть, звідки брати музику. + У Spotube каталог, стримінг аудіо й синхронізовані тексти працюють окремо. Налаштуйте рушії нижче або змінюйте їх будь-коли. + Рушій метаданих + Відповідає за пошук музики, списки треків і плейлисти. + Аудіорушій + Отримує й декодує аудіопотоки для відтворення. + Рушій текстів + Показує синхронізовані караоке-тексти в реальному часі. + Рушій скроблінгу + Записує історію прослуховувань у Last.fm або ListenBrainz. + Активний + Вбудований + Готовий + Необов’язково + Виберіть рушій + Встановлені рушії + Зробити основним + NewPipe YouTube і LRCLib Lyrics вбудовані й працюють одразу після встановлення. + Керувати плагінами спільноти й додавати нові можна будь-коли в розділі «Налаштування → Плагіни». + + + Ретромодерн-архітектура + Чистий Hi-Fi без посередників + Ось як Spotube організовує децентралізоване відтворення просто на вашому пристрої. + НАПРЯМУ, P2P • 256 KBPS OPUS + Синя година + Spotube · демонстрація плеєра + Трохи музики для миттєвостей поміж справ. + 1. Децентралізований пошук + Запити надходять безпосередньо до вибраного плагіна каталогу. Spotube ніколи не аналізує ваші смаки. + 2. Пряме отримання потоку + Аудіо надходить безпосередньо із серверів контенту на ваш пристрій. Жодних проксі та віддаленого збору даних. + 3. Локальна обробка й відтворення + Декодування й кешування на пристрої, еквалайзер і завантаження для офлайн-прослуховування за бажанням. + 4. Синхронні тексти й джем + Караоке-тексти стежать за відтворенням, а ще є віддалене керування через LAN і груповий джем. + Жодних серверів-посередників. Відтворення відбувається просто на вашому пристрої — приватність за замовчуванням. + + + Виберіть пристрій + %1$s: «%2$s» + Відтворити + Додати до черги + Відтворити наступним + Цей пристрій + На цьому пристрої + Віддалений пристрій + Віддалений пристрій (%1$s) + На підключеному пристрої + + + Не вдалося підключитися + З’єднання втрачено + Не вдалося надіслати + Помилка пошуку: %1$s + Не вдалося підключитися: %1$s + + + Пристрої + Оновити + Від’єднатися + Підключення... + Пошук пристроїв у мережі... + Пристроїв не знайдено + Переконайтеся, що на іншому пристрої в налаштуваннях увімкнено «Дозволити віддалене керування». + Невідомий пристрій + Визначення адреси... + + + Віддалене керування + Від’єднано + Помилка підключення: %1$s + Невідомий трек + Невідомий виконавець + Гучність + Видалити з черги + Інші дії + + + Де відтворити? + У вас активний джем. Виберіть, де відтворити цю добірку. + Відтворити тут + Запропонувати в джем + + + Брокер для джему не налаштовано + Не вказано хост брокера + Не вдалося підключитися + З’єднання втрачено + + + Брокер + anonymous + + + Додано до черги джему + + %1$d трек додано до черги джему + %1$d треки додано до черги джему + %1$d треків додано до черги джему + %1$d трека додано до черги джему + + + + Запит на віддалене керування + «%1$s» хоче керувати відтворенням на цьому пристрої. + Відхилити + Дозволити + Дозволяти завжди + + + Результати пошуку + Усі + Треки + Користувачі + Немає доступних типів пошуку + Почніть вводити запит + Почніть вводити, щоб знайти треки + Нічого не знайдено + Показати всі + Пошук + Завантаження треків... + Завантаження альбомів... + Завантаження виконавців... + Завантаження плейлистів... + Завантаження користувачів... + Альбомів не знайдено + Виконавців не знайдено + Плейлистів не знайдено + Користувачів не знайдено + Риємося в ящиках із платівками... + Шукаємо по всьому всесвіту... + Розігріваємо колонки... + Здуваємо пил із вінілу... + Радимося з богами музики... + Налаштовуємо антени... + Перебираємо платівки... + Не вдалося завантажити можливості пошуку + Не вдалося виконати пошук + Не вдалося знайти треки + Не вдалося знайти альбоми + Не вдалося знайти виконавців + Не вдалося знайти плейлисти + Не вдалося знайти користувачів + + + Огляд + + + Синхронний + Простий + Синхронізований текст недоступний + Текст недоступний + + + %1$s у %2$s + %1$d kbps + %1$s kHz • %2$d кан. + Плагін метаданих не вибрано + + + Назад + Повторити + Закрити вікно + Завантаження... + Невідомо + Невідома помилка + + + Користувач + Плейлист + + + Нам дуже прикро + Щось пішло не так. Нам важливі ваші враження, і ми робимо все, щоб це виправити. Спробуйте ще раз або перегляньте подробиці помилки нижче. + Подробиці помилки + Копіювати в буфер обміну + Показати повний журнал + Повний журнал помилки + + + Перемішати + Перемішати й слухати + Підписатися + Відписатися + Редагувати плейлист + Автор: %1$s + + + Дії з треком + Додати в джем + Запустити радіо + Відтворити наступним + Зробити наступним + Видалити з черги + Видалити з улюблених + Додати в улюблені + Додати в плейлист + Завантажити + Поділитися + + + Початковий порядок + Назва + Виконавець + Альбом + Тривалість + Сортування + Треків не знайдено + Фільтр + Дії з вибраними + Завантажити всі + Завантажити (%1$d) + Додати всі до черги + Додати до черги (%1$d) + Відтворити всі наступними + Відтворити наступними (%1$d) + Додати всі в плейлист + Додати в плейлист (%1$d) + Додати всі в джем + Додати в джем (%1$d) + і ще %1$d + + + Немає URL для завантаження + Назад в історії браузера + Уперед в історії браузера + + + Фільтр чорного списку... + Чорний список порожній + У чорному списку немає збігів із «%1$s» + Треки + Трек + Додати до чорного списку + Видалити з чорного списку + + + Немає метаданих треку + Не вдалося отримати URL потоку + Не вдалося отримати URL потоку. Спершу спробуйте відтворити трек. + + + Збережені треки + Ви + + + Ви підписані + Популярні треки + Слухати популярні треки + Додати популярні треки до черги + Схожі виконавці + Рекомендовані плейлисти + Не вдалося завантажити сторінку виконавця + Кількість підписників недоступна + Підписників: %1$s тис. + Підписників: %1$s млн + Підписників: %1$s млрд + + %1$d підписник + %1$d підписники + %1$d підписників + %1$d підписника + + + + Подобається + Більше не подобається + Назва треку-заглушки + Виконавці-заглушки + Невідомий альбом + Перемотування + Гучність + Увімкнути перемішування + Вимкнути перемішування + Попередній + Наступний + Відтворити або призупинити + Повтор вимкнено + Повтор треку + Повтор усіх + Вимкнути звук + Увімкнути звук + Інші дії + Параметри плеєра + Згорнути плеєр + Розгорнути текст + Зараз грає + Текст + Альтернативне джерело + Альтернативне джерело + Завантажити + Завантажено + Завантаження... + Уже завантажено + Повторне завантаження... + Завантаження: %1$s + Відтворення призупиниться через %1$s. + Призупинити відтворення через вибрану кількість хвилин. + Хвилини + Від 1 до 600 хвилин + Скасувати таймер + Запустити + URL відтворення отримано з іншими налаштуваннями стримінгу. Виділений потік відповідає вашим поточним налаштуванням; активний URL оновиться під час наступного запиту потоку. + Підібране джерело аудіо + Завантаження підібраного джерела… + Віддалене джерело не потрібне, поки відтворюється локальний кешований файл. + Відповідне джерело недоступне. + Для цього треку немає кешованого підібраного джерела аудіо. + Відомості про потоки ще не кешовано. Спершу відтворіть трек, щоб завантажити їх. + Не вдалося завантажити відомості про трек. + Доступні потоки + Цей трек відтворюється з локального кешу. Варіанти потоків зараз не використовуються. + Завантаження потоків… + Для цього джерела немає доступних потоків. + Вибраний потік + Відповідає вашим налаштуванням формату та якості стримінгу + Активний URL відтворення + З локального кешу + Трек із метаданих + Назва + ID + Альбом + Точність збігу + Зовнішній URI + ID альбому + Тип альбому + Дата виходу альбому + Треків в альбомі + Жанри альбому + Тривалість + Номер треку + Номер диска + Відвертий контент + Популярність + ISRC + URL мініатюр + + + Показати або сховати бічну панель + + + Альтернативні джерела + Зараз нічого не грає + Пошук альтернативних джерел... + Альтернативних джерел не знайдено + Збіг: %1$d% + Поточне джерело + + + Вигнати + Видалити учасника із сесії + Вигнати й заборонити повертатися + Видалити пропозиції + Видалити всі треки, які учасник додав до черги + Змінити порядок + Видалити з черги + %1$s • Додав(-ла) %2$s + + + Невідомо + Завантаження плейлиста... + Додати треки + Додати треки в плейлист + Пошук треків... + Пошук + Треків не знайдено + Знайдіть треки, щоб додати їх + Готово + Додати + Додано + + + Завантаження альбому... + + + Додати у ваш плейлист + Пошук + Пошук у ваших плейлистах... + Завантаження ваших плейлистів... + У вас поки немає власних плейлистів.\nСтворіть плейлист, щоб додавати до нього треки. + Новий плейлист + Редагувати плейлист + Створити + Назва + Назва плейлиста + Опис + Опис (необов’язково) + Публічний + Цей плейлист бачать усі + Спільний + Інші можуть додавати треки до цього плейлиста + Обкладинка + Обкладинка плейлиста + Немає зображення + Змінити зображення + Вибрати зображення + + + Встановити + Замінити + У zip-архіві не знайдено plugin.json + У zip-архіві не знайдено manifest.zipline.json + Неправильний формат plugin.json: %1$s + Не вдалося прочитати плагін: %1$s + Не вдалося встановити плагін: %1$s + Доступна новіша версія %1$s. Підтвердьте оновлення перед встановленням. + %1$s уже встановлено. Підтвердьте заміну перед встановленням. + Вбудовані плагіни вже включено, їх не можна додати повторно. + Вбудовані плагіни не можна видалити. + %1$s вказує недійсну apiVersion (%2$s). Очікується семантична версія, сумісна з %3$s. + Недійсна версія API плагінів Spotube: %1$s. + %1$s розрахований на API плагінів %2$s, а Spotube підтримує несумісну версію API %3$s. Встановіть плагін, зібраний для тієї самої основної версії API. + + + Несумісний API плагіна + Встановити плагін? + %1$s буде додано до встановлених плагінів. + Оновити плагін? + Встановлено старішу версію (%1$s). Оновити до %2$s? + Замінити встановлений плагін? + %1$s використовує недійсну семантичну версію (%2$s), тому її не можна порівняти як оновлення. Усе одно замінити встановлений плагін? + Встановлену версію (%1$s) не можна порівняти за правилами семантичного версіонування. Замінити її на %2$s? + Версію %1$s уже встановлено. Замінити наявний плагін новою копією? + Встановлена версія %1$s новіша за запропоновану версію %2$s. Усе одно замінити? + Замінити встановлений плагін новою копією? + + + %1$s: + Версія + Версія API + Автор + Ліцензія + Дозволи + Функції + Репозиторій + Контакти + Повідомити про помилку + Підтримати %1$s + + + Офіційний + Перевірений + Зірки на GitHub + URL репозиторію на GitHub + + + Релізів не знайдено + Виберіть реліз + Виберіть реліз + Попередній реліз + Примітки до випуску + + + Головна + Пошук + На пристрої + Підключення + Пристрої + + + Сховати вікно + Показати вікно + Наступний трек + Попередній трек + Перемішати + Повтор + Повтор: ВИМК + Повтор: ТРЕК + Повтор: УСІ + Гучність: %1$d% + Гучність + + Гучність - + Вимкнути звук + Увімкнути звук + Подобається + Більше не подобається + Вийти + + + Згорнути + Розгорнути + Відновити + Закрити + + + Відтворення + Керування відтворенням + + + Огляд + Зараз грає + Рекомендоване + Збережені треки + Популярні треки + diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml new file mode 100644 index 00000000..b6d4f5ea --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -0,0 +1,920 @@ + + + + Danh sách chờ + Xóa danh sách chờ + Lọc danh sách chờ... + Danh sách chờ trống + + Tìm bài hát, nghệ sĩ, album... + Xóa toàn bộ lịch sử + Tìm kiếm... + + + Thư viện của bạn + Thư viện + Danh sách phát + Album + Nghệ sĩ + Bài hát trên máy + Đã tải xuống + Tìm danh sách phát đã lưu… + Tìm album đã lưu… + Tìm nghệ sĩ đã lưu… + Tìm thư mục hoặc bài hát… + Lọc thư viện + Đóng bộ lọc + Tạo danh sách phát + Làm mới bài hát trên máy + Chuyển sang dạng lưới + Chuyển sang dạng danh sách + Chưa có danh sách phát nào được lưu + Không có danh sách phát nào khớp với “%1$s” + Chưa có album nào được lưu + Không có album nào khớp với “%1$s” + Chưa có nghệ sĩ nào được lưu + Không có nghệ sĩ nào khớp với “%1$s” + + + Thư mục + Thư mục + %1$s • %2$s + + %1$d bài hát + + + %1$d thư mục + + Quay lại thư mục + Phát thư mục + Cần quyền truy cập âm thanh + Cho phép truy cập âm thanh để duyệt nhạc trên thiết bị của bạn. + Cấp quyền + Không tìm thấy thư mục nào trên máy + Thư mục này không có bài hát nào + Không có thư mục hoặc bài hát nào khớp + Không thể làm mới nội dung trên máy + + + %1$d đang tải • %2$d đã xong + Xóa mục đã xong + Chưa có nội dung tải xuống + Đang tải xuống %1$d% + Đang chờ + Đã xong + Đã hủy + Thất bại + Thất bại: %1$s + Hủy tải xuống + Thử tải lại + Xóa mục tải xuống + + + Nghệ sĩ + Đã chặn + Bài hát đã thích + Bài hát bạn đã lưu + Nghệ sĩ không xác định + Tên bài hát + Tên nghệ sĩ + + Đã chọn + + Cài đặt + + Plugin + Ngôn ngữ và khu vực + Giao diện + Phát lại + Tải xuống + Bộ nhớ đệm + Máy tính + Cập nhật + Ủng hộ + Giới thiệu + + Đóng + Lưu + Hủy + + Ngôn ngữ + Ngôn ngữ giao diện ứng dụng. Hiện tại: %1$s + Mặc định của hệ thống + Quốc gia + Quốc gia dùng cho nội dung và tính khả dụng. Hiện tại: %1$s + %1$s (%2$s) + + Chủ đề + Chọn cách Spotube khớp với giao diện hệ thống. Hiện tại: %1$s + Sáng + Tối + Theo hệ thống + + Bề mặt + Chất liệu nền của ứng dụng. Hiện tại: %1$s + Nước + Băng + Chrome + Acrylic + Kính + + Màu nhấn + Bảng màu hiện tại: %1$s + Màu nhấn + Chọn bảng màu nhấn và xem trước ở cả chủ đề sáng và tối. + Sáng + Tối + Xem trước + Nút + Xanh yêu tinh + Tím điện + Lam đại dương + Cam hoàng hôn + Vườn hồng + Xanh nửa đêm + Xám kim loại + + Giao diện & cảm nhận + Chất liệu bề mặt và màu nhấn. Hiện tại: %1$s · %2$s + Giao diện & cảm nhận + Chọn chất liệu bề mặt, sau đó chọn màu nhấn phù hợp. Mỗi lựa chọn đều là một sự kết hợp được tuyển chọn. + Bề mặt + Màu nhấn + Phụ + + Bảng màu + Bảng màu nhấn Millennium. Hiện tại: %1$s + Bảng màu + Chọn bảng màu nhấn Millennium và xem trước ở chế độ sáng và tối. + Bảng màu + Bảng màu Millennium và màu nhấn. Hiện tại: %1$s + Xanh lam + Cam + Xanh chanh + + Thư mục tải xuống tùy chỉnh + Thư mục hiện tại: %1$s + Để trống để dùng thư mục nhạc mặc định. + C:\Music\Spotube + Thư mục nhạc trên máy + Thư mục tùy chỉnh để quét trên máy tính. Đã thêm: %1$d + Chưa thêm thư mục tùy chỉnh nào + Thư mục tùy chỉnh cho thư viện nhạc trên máy của bạn. + Quản lý + Thêm thư mục + Xóa thư mục + Chưa thêm thư mục tùy chỉnh nào. + Xóa %1$s khỏi danh sách quét + Thư mục tải xuống và thư mục bộ nhớ đệm nhạc được tự động thêm vào. + Thư mục tải xuống của bạn được tự động thêm vào. + Thư mục bộ nhớ đệm nhạc được tự động thêm vào. + Đang lưu… + Không thể lưu thư mục. Các thay đổi của bạn vẫn còn đây; hãy thử lại. + + %1$d thư mục + + Định dạng nhạc tải xuống + Codec và container ưu tiên khi tải xuống. Hiện tại: %1$s + Chất lượng nhạc tải xuống + + Định dạng nhạc phát trực tuyến + Codec và container ưu tiên khi phát trực tuyến. Hiện tại: %1$s + Chất lượng nhạc phát trực tuyến + Hiện tại: %1$s + Bật bộ nhớ đệm nhạc + Lưu âm thanh đã phát trực tuyến trên máy để phát lại nhanh hơn. + Thư mục bộ nhớ đệm + Hiện tại: %1$s + Hiện tại: bộ nhớ đệm mặc định của ứng dụng + Giới hạn dung lượng bộ nhớ đệm + Không giới hạn + Giới hạn hiện tại: %1$d MB + Đặt dung lượng bộ nhớ đệm tối đa tính bằng MB. Nhập 0 hoặc để trống nếu không muốn giới hạn. + 500 + Dung lượng phải là 0 hoặc một số dương. + Bật phát liên tục + Tiếp tục phát với các bài hát được đề xuất khi danh sách chờ kết thúc. + Cổng máy chủ proxy phát lại + Cho phép điều khiển từ xa + Cho phép các thiết bị khác trong cùng mạng điều khiển phát lại khi kết nối. + Tên thiết bị điều khiển từ xa + Tên mà các thiết bị khác thấy khi tìm thấy thiết bị này. Hiện tại: %1$s + Chọn một tên dễ nhận biết để hiển thị cho các thiết bị khác trong mạng khi chúng tìm thấy Spotube này. + VD: Spotube phòng khách + tên máy chủ của thiết bị + Cổng được máy chủ proxy phát lại sử dụng. Hiện tại: %1$d + Chọn một cổng từ 1 đến 65535. + 14769 + Nhập một số nguyên hợp lệ. + Cổng phải nằm trong khoảng từ 1 đến 65535. + + Danh sách chặn + Quản lý bài hát và nghệ sĩ bị chặn + + Thu nhỏ xuống khay hệ thống + Giữ Spotube chạy trong khay hệ thống khi thu nhỏ cửa sổ. + Discord Rich Presence + Hiển thị trạng thái đang phát trên Discord trên máy tính. + + Tự động kiểm tra cập nhật + Tìm các phiên bản Spotube mới trong nền. + + Quản lý plugin + Quản lý các plugin của bạn + Plugin %1$s mặc định + Siêu dữ liệu + Âm thanh + Lời bài hát + Scrobble + Chưa chọn plugin + Thay đổi + Chọn + Đã chọn + Bỏ chọn + Không có plugin nào + Plugin %1$s + Xóa + + Cấu hình + Trình quản lý plugin + Vui lòng nhập URL + URL phải bắt đầu bằng http:// hoặc https:// + Không thể tải plugin xuống + Chưa cài đặt plugin nào + Dán URL tải xuống hoặc chọn tệp ở trên. + + Đã cài đặt %1$d plugin + + Cài đặt plugin + https://example.com/plugin.smplug + Tải xuống + Cài đặt từ tệp (.smplug) + Đang dùng + v%1$s + Tích hợp sẵn + Gỡ plugin + Đăng nhập + Đăng xuất + Tải xuống từ URL + Cài đặt từ tệp + Cài đặt + + • %1$s + Quyền được yêu cầu + Plugin này không yêu cầu quyền đặc biệt nào. + Đã cài đặt và bản cung cấp + Đã cài đặt + Bản cung cấp + API %1$s → %2$s + Lưu trữ lâu dài + Đọc và ghi dữ liệu vào ổ đĩa + Yêu cầu mạng + Gửi và nhận dữ liệu qua internet + WebView + Hiển thị nội dung web trong ứng dụng + Jam nhóm + MQTT Broker + Máy chủ broker + %1$s:%2$d + Cổng broker + Dùng TLS + Tên người dùng (không bắt buộc) + Mật khẩu (không bắt buộc) + Tiền tố Client ID + Dùng để nhận diện Spotube này + Mã hóa kết nối tới broker + Kiểm tra cài đặt broker hiện tại trước khi tạo phòng. + Kiểm tra kết nối + Đang kiểm tra… + Đã kết nối trong %1$d ms + Thất bại: %1$s + Broker tạm thời — hãy cấu hình broker riêng để tự lưu trữ + Kết nối + Thông tin đăng nhập + Danh tính + Chẩn đoán + + + Jam nhóm + Tạo + Tham gia + Tạo phòng, sau đó chia sẻ mã phòng với bạn bè. + Tạo phòng + Vào phòng + Đang kết nối… + Mã phòng + ABC123 + Broker: %1$s + Hãy thiết lập broker trong Cài đặt để tạo hoặc tham gia phòng. + Cài đặt broker + Đã kết nối + Đang kết nối lại… + Sao chép mã + Đã sao chép! + Chia sẻ + Người tham gia (%1$d) + Chủ phòng + Người nghe + Đang chờ người nghe… + Xóa + Cấm + Quản lý + Rời phòng + Không thể kết nối + Bỏ qua + Không thể tạo phiên Jam + Không thể tham gia phiên Jam + Nhập mã phòng gồm sáu ký tự chữ và số. + Tham gia Jam Spotube của mình với mã: %1$s + + Đi tới album + Hẹn giờ tắt + Chi tiết bài hát + Phát + Tạm dừng + Không có gì đang phát + Thêm vào danh sách chờ + + Bỏ qua + Cập nhật + Đã có Spotube %1$s + Xem có gì mới trong bản phát hành này trên trang tải xuống Spotube. + Đã có %1$s %2$s + Đang cài đặt: %1$s + Xem có gì mới trong bản cập nhật này trên trang của plugin. + + Bỏ qua + Tiếp + Quay lại + Bắt đầu + Spotube + Âm nhạc, không ràng buộc. + Trình phát mã nguồn mở cho cả thế giới âm nhạc.\nTùy bạn khám phá. Tùy bạn biến nó thành của riêng mình. + Giúp dự án này tiếp tục sống. + Spotube được tạo ra bởi con người, không phải một doanh nghiệp thu phí thuê bao. Nếu Spotube trở thành một phần cuộc sống hằng ngày của bạn, một chút ủng hộ sẽ giúp chúng tôi chăm chút nó xứng đáng hơn.\n\nCảm ơn bạn đã dành chỗ cho phần mềm độc lập. + Bắt đầu với một plugin danh mục để tìm bài hát và danh sách phát. Chọn một plugin âm thanh để phát chúng. Lời bài hát và lịch sử nghe là tùy chọn. Hãy khám phá plugin bên dưới, xem lại quyền của nó, cài đặt rồi chọn nó tại đây. + Khám phá plugin + Nguồn của bạn + Thử lại + Thêm plugin + Không tìm thấy plugin mới. Bạn cũng có thể cài đặt từ liên kết hoặc tệp .smplug ở trên. + Hiện không thể khám phá plugin. Hãy thử lại hoặc cài đặt từ liên kết hay tệp. + Chào mừng + Ủng hộ + Plugin + Hướng dẫn + + Cách dùng Spotube + Bốn bước nhanh là bạn đã có thể nghe nhạc. + Đăng nhập + Kết nối tài khoản Spotify hoặc chọn plugin danh mục của riêng bạn. + Tìm kiếm và phát + Tìm bất kỳ bài hát, album hay nghệ sĩ nào rồi nhấn phát. + Nghe ngoại tuyến + Tải bài hát xuống để tiếp tục nghe khi không có kết nối. + Theo dõi lời bài hát + Mở lời bài hát đồng bộ từ trình phát khi bài hát đang phát. + + + Âm nhạc của bạn. Nguồn của bạn. Luật của bạn. + Trình phát nhạc miễn phí, mã nguồn mở, được xây dựng bởi một cộng đồng yêu âm nhạc. + Mã nguồn mở + Không quảng cáo + Không thu thập dữ liệu + Vận hành bằng plugin + Bắt đầu thôi + + + Người ủng hộ giúp chúng tôi tiếp bước + Tạo nên bằng đam mê. Duy trì nhờ bạn. + Spotube miễn phí 100%, tự do và mã nguồn mở — không quảng cáo, không thuê bao, không theo dõi. Mọi tính năng tồn tại là nhờ những người như bạn đã chọn giúp mã nguồn tiếp tục phát triển. + Patreon + Tài trợ hằng tháng + Hỗ trợ phát triển và hạ tầng lâu dài + Buy Me a Coffee + Một ly cà phê + Tiếp sức cho một buổi code đêm khuya bằng một khoản tip nhỏ + Open Collective + Quỹ cộng đồng + Hỗ trợ tài chính minh bạch, do cộng đồng quản lý + Từ tận đáy lòng, cảm ơn bạn đã tin vào âm nhạc mở. + Ủng hộ hoàn toàn là tùy chọn — Spotube vẫn luôn miễn phí. + Tài trợ + Mở %1$s + + + Ủng hộ Spotube + Ủng hộ Spotube + Spotube miễn phí, tự do và mã nguồn mở — không quảng cáo, không thuê bao, không theo dõi. Nếu Spotube mang lại niềm vui cho bạn, hãy cân nhắc ủng hộ dự án. + Bạn có thể tắt lời nhắc này trong Cài đặt → Ủng hộ. + Để sau + Lời nhắc ủng hộ + Hiển thị lời nhắc nhẹ nhàng về việc ủng hộ dự án mỗi 7 ngày. + + + Kiến trúc mô-đun + Chọn nơi lấy nhạc của bạn. + Spotube tách riêng việc khám phá danh mục, phát âm thanh trực tuyến và lời bài hát đồng bộ. Cấu hình các engine bên dưới hoặc chuyển đổi bất cứ lúc nào. + Engine siêu dữ liệu + Cung cấp khám phá nhạc, tìm kiếm, danh sách bài hát và danh sách phát. + Engine âm thanh + Lấy và giải mã luồng âm thanh để phát. + Engine lời bài hát + Truyền lời bài hát karaoke đồng bộ theo thời gian thực. + Engine scrobble + Ghi lại lịch sử nghe lên Last.fm hoặc ListenBrainz. + Đang dùng + Tích hợp sẵn + Sẵn sàng + Tùy chọn + Chọn một engine + Engine đã cài đặt + Đặt làm mặc định + NewPipe YouTube và LRCLib Lyrics được tích hợp sẵn và có thể phát ngay. + Quản lý hoặc thêm plugin cộng đồng bất cứ lúc nào trong Cài đặt → Plugin. + + + Kiến trúc cổ điển hiện đại + Hi-Fi thuần túy, không trung gian + Đây là cách Spotube điều phối việc phát nhạc phi tập trung ngay trên máy của bạn. + NGANG HÀNG TRỰC TIẾP • 256 KBPS OPUS + Giờ xanh + Spotube · minh họa trình phát + Một chút âm nhạc cho những khoảnh khắc xen giữa. + 1. Khám phá phi tập trung + Truy vấn được gửi thẳng tới plugin danh mục bạn chọn. Spotube không bao giờ lập hồ sơ sở thích của bạn. + 2. Phân giải luồng trực tiếp + Âm thanh được truyền trực tiếp từ máy chủ nội dung tới máy của bạn. Không qua proxy, không thu thập dữ liệu từ xa. + 3. DSP & phát lại cục bộ + Giải mã và lưu đệm cục bộ với bộ chỉnh âm và tùy chọn tải xuống ngoại tuyến. + 4. Lời bài hát đồng bộ & Jam + Lời karaoke bám theo thời điểm phát, cùng với điều khiển từ xa qua LAN và Jam nhóm. + Không có máy chủ trung gian. Việc phát nhạc diễn ra ngay trên thiết bị của bạn — riêng tư từ thiết kế. + + + Chọn thiết bị + %1$s: “%2$s” + Phát + Thêm vào danh sách chờ + Phát tiếp theo + Thiết bị này + Trên thiết bị này + Thiết bị từ xa + Thiết bị từ xa (%1$s) + Trên thiết bị đã kết nối + + + Kết nối thất bại + Mất kết nối + Gửi thất bại + Tìm thiết bị thất bại: %1$s + Không thể kết nối: %1$s + + + Thiết bị + Làm mới + Ngắt kết nối + Đang kết nối... + Đang tìm thiết bị trong mạng... + Không tìm thấy thiết bị nào + Hãy đảm bảo thiết bị kia đã bật \"Cho phép điều khiển từ xa\" trong cài đặt. + Thiết bị không xác định + Đang phân giải... + + + Điều khiển từ xa + Đã ngắt kết nối + Lỗi kết nối: %1$s + Bài hát không xác định + Nghệ sĩ không xác định + Âm lượng + Xóa khỏi danh sách chờ + Tùy chọn khác + + + Phát ở đâu? + Bạn đang có một phiên Jam. Chọn nơi phát bộ sưu tập này. + Phát tại đây + Đề xuất cho Jam + + + Chưa cấu hình broker cho Jam + Máy chủ broker đang trống + Kết nối thất bại + Mất kết nối + + + Broker + ẩn danh + + + Đã thêm vào danh sách chờ của Jam + + Đã thêm %1$d bài hát vào danh sách chờ của Jam + + + + Yêu cầu điều khiển từ xa + \"%1$s\" muốn điều khiển việc phát trên thiết bị này. + Từ chối + Cho phép + Luôn cho phép + + + Kết quả tìm kiếm + Tất cả + Bài hát + Người dùng + Không có loại tìm kiếm nào + Nhập để bắt đầu tìm kiếm + Nhập để bắt đầu tìm bài hát + Không tìm thấy kết quả + Xem tất cả + Đang tìm kiếm + Đang tải bài hát... + Đang tải album... + Đang tải nghệ sĩ... + Đang tải danh sách phát... + Đang tải người dùng... + Không tìm thấy album nào + Không tìm thấy nghệ sĩ nào + Không tìm thấy danh sách phát nào + Không tìm thấy người dùng nào + Đang lục tìm kho đĩa... + Đang tìm khắp vũ trụ... + Đang làm nóng loa... + Đang phủi bụi đĩa than... + Đang hỏi ý các vị thần âm nhạc... + Đang dò ăng-ten... + Đang lật giở các đĩa nhạc... + Không thể tải khả năng tìm kiếm + Tìm kiếm thất bại + Không thể tìm bài hát + Không thể tìm album + Không thể tìm nghệ sĩ + Không thể tìm danh sách phát + Không thể tìm người dùng + + + Duyệt + + + Đồng bộ + Thường + Không có lời bài hát đồng bộ + Không có lời bài hát + + + %1$s trong %2$s + %1$d kbps + %1$s kHz • %2$d kênh + Chưa chọn plugin siêu dữ liệu + + + Quay lại + Thử lại + Đóng hộp thoại + Đang tải... + Không xác định + Lỗi không xác định + + + Người dùng + Danh sách phát + + + Chúng tôi thật sự xin lỗi + Đã có sự cố xảy ra. Trải nghiệm của bạn rất quan trọng với chúng tôi và chúng tôi đang cố gắng hết sức để khắc phục. Vui lòng thử lại hoặc xem chi tiết lỗi bên dưới. + Chi tiết lỗi + Sao chép vào bộ nhớ tạm + Xem nhật ký đầy đủ + Nhật ký lỗi đầy đủ + + + Phát ngẫu nhiên + Phát ngẫu nhiên + Theo dõi + Bỏ theo dõi + Sửa danh sách phát + Của %1$s + + + Tùy chọn bài hát + Thêm vào Jam + Phát radio + Phát tiếp theo + Chuyển lên phát tiếp theo + Xóa khỏi danh sách chờ + Xóa khỏi mục yêu thích + Lưu vào mục yêu thích + Thêm vào danh sách phát + Tải xuống + Chia sẻ + + + Mặc định + Tên + Nghệ sĩ + Album + Thời lượng + Sắp xếp + Không tìm thấy bài hát nào + Lọc + Thao tác hàng loạt + Tải xuống tất cả + Tải xuống %1$d + Thêm tất cả vào danh sách chờ + Thêm %1$d vào danh sách chờ + Phát tất cả tiếp theo + Phát %1$d bài tiếp theo + Thêm tất cả vào danh sách phát + Thêm %1$d vào danh sách phát + Thêm tất cả vào Jam + Thêm %1$d vào Jam + +%1$d người khác + + + Không có URL để tải + Quay lại trang trước + Đi tới trang sau + + + Lọc danh sách chặn... + Không có mục nào bị chặn + Không có mục bị chặn nào khớp với \'%1$s\' + Bài hát + Bài hát + Thêm vào danh sách chặn + Xóa khỏi danh sách chặn + + + Không có siêu dữ liệu bài hát + Không thể phân giải URL luồng + Không thể phân giải URL luồng. Hãy thử phát bài hát trước. + + + Bài hát đã lưu + Bạn + + + Đang theo dõi + Bài hát hàng đầu + Phát bài hát hàng đầu + Thêm bài hát hàng đầu vào danh sách chờ + Nghệ sĩ liên quan + Danh sách phát nổi bật + Không thể tải thông tin nghệ sĩ + Không có thông tin người theo dõi + %1$s N người theo dõi + %1$s Tr người theo dõi + %1$s T người theo dõi + + %1$d người theo dõi + + + + Thích + Bỏ thích + Tên bài hát trống + Nghệ sĩ trống + Album không xác định + Tua + Âm lượng + Bật phát ngẫu nhiên + Tắt phát ngẫu nhiên + Trước + Tiếp + Phát hoặc tạm dừng + Tắt lặp lại + Lặp lại một bài + Lặp lại tất cả + Tắt tiếng + Bật tiếng + Tùy chọn khác + Tùy chọn trình phát + Thu gọn trình phát + Mở rộng lời bài hát + Đang phát + Lời bài hát + Nguồn thay thế + Nguồn thay thế + Tải xuống + Đã tải xuống + Đang tải xuống... + Đã tải xuống trước đó + Đang thử tải lại... + Đang tải xuống %1$s + Nhạc sẽ tạm dừng sau %1$s. + Tạm dừng phát sau số phút đã chọn. + Phút + Chọn từ 1 đến 600 phút + Hủy hẹn giờ + Bắt đầu + URL phát được phân giải với cài đặt phát trực tuyến khác. Luồng được đánh dấu khớp với lựa chọn hiện tại của bạn; URL đang dùng sẽ được làm mới ở lần yêu cầu luồng tiếp theo. + Nguồn âm thanh khớp + Đang tải nguồn khớp… + Không cần nguồn từ xa khớp khi đang phát tệp đệm trên máy. + Không có nguồn khớp nào. + Chưa có nguồn âm thanh khớp nào được lưu đệm cho bài hát này. + Chi tiết luồng chưa được lưu đệm. Hãy phát bài hát trước để tải chúng. + Không thể tải chi tiết bài hát. + Các luồng khả dụng + Bài hát này đang được phát từ bộ nhớ đệm trên máy. Các biến thể luồng không được dùng cho lần phát này. + Đang tải luồng… + Không có luồng nào cho nguồn này. + Luồng đang được chọn + Khớp với định dạng & chất lượng phát trực tuyến bạn chọn + URL phát đang dùng + Phát từ bộ nhớ đệm trên máy + Bài hát theo siêu dữ liệu + Tên + ID + Album + Độ tin cậy + URI bên ngoài + ID album + Loại album + Ngày phát hành album + Số bài hát trong album + Thể loại album + Thời lượng + Số thứ tự bài hát + Số đĩa + Nội dung tục tĩu + Độ phổ biến + ISRC + URL ảnh thu nhỏ + + + Bật/tắt thanh bên + + + Nguồn thay thế + Hiện không có bài hát nào đang phát + Đang tìm nguồn thay thế... + Không tìm thấy nguồn thay thế nào + Độ tin cậy: %1$d% + Nguồn đang dùng + + + Mời ra + Xóa người này khỏi phiên + Mời ra và không cho tham gia lại + Xóa đề xuất + Xóa mọi bài hát họ đã thêm vào danh sách chờ + Sắp xếp lại + Xóa khỏi danh sách chờ + %1$s • Được thêm bởi %2$s + + + Không xác định + Đang tải danh sách phát... + Thêm bài hát + Thêm bài hát vào danh sách phát + Tìm bài hát... + Tìm kiếm + Không tìm thấy bài hát nào + Tìm bài hát để thêm + Xong + Thêm + Đã thêm + + + Đang tải album... + + + Thêm vào danh sách phát của bạn + Tìm kiếm + Tìm danh sách phát của bạn... + Đang tải danh sách phát của bạn... + Bạn chưa có danh sách phát nào.\nHãy tạo một danh sách để thêm bài hát. + Tạo danh sách phát + Sửa danh sách phát + Tạo + Tên + Tên danh sách phát + Mô tả + Mô tả (không bắt buộc) + Công khai + Ai cũng có thể xem danh sách phát này + Cộng tác + Người khác có thể thêm bài hát vào danh sách phát này + Ảnh bìa + Ảnh bìa danh sách phát + Không có ảnh + Đổi ảnh + Chọn ảnh + + + Cài đặt + Thay thế + Không tìm thấy plugin.json trong tệp zip + Không tìm thấy manifest.zipline.json trong tệp zip + Định dạng plugin.json không hợp lệ: %1$s + Không thể đọc plugin: %1$s + Không thể cài đặt plugin: %1$s + Đã có phiên bản mới hơn của %1$s. Hãy xác nhận cập nhật trước khi cài đặt. + %1$s đã được cài đặt. Hãy xác nhận thay thế trước khi cài đặt. + Plugin tích hợp sẵn đã có trong ứng dụng và không thể thêm lại. + Không thể gỡ plugin tích hợp sẵn. + %1$s khai báo apiVersion không hợp lệ (%2$s). Cần phiên bản theo chuẩn semantic versioning tương thích với %3$s. + Phiên bản API plugin Spotube %1$s không hợp lệ. + %1$s nhắm tới API plugin %2$s, nhưng Spotube hỗ trợ API plugin %3$s có thay đổi không tương thích. Hãy cài đặt plugin được xây dựng cho cùng phiên bản API chính. + + + API plugin không tương thích + Cài đặt plugin? + %1$s sẽ được thêm vào danh sách plugin đã cài đặt. + Cập nhật plugin? + Đang cài đặt phiên bản cũ hơn (%1$s). Cập nhật lên %2$s? + Thay thế plugin đã cài đặt? + %1$s dùng phiên bản semantic không hợp lệ (%2$s) nên không thể so sánh như một bản cập nhật. Vẫn thay thế plugin đã cài đặt? + Không thể so sánh phiên bản đã cài đặt (%1$s) theo semantic versioning. Thay thế bằng %2$s? + Phiên bản %1$s đã được cài đặt. Thay thế plugin hiện có bằng bản được cung cấp? + Phiên bản đã cài đặt %1$s mới hơn phiên bản được cung cấp %2$s. Vẫn thay thế? + Thay thế plugin đã cài đặt bằng bản được cung cấp? + + + %1$s: + Phiên bản + Phiên bản API + Tác giả + Giấy phép + Khả năng + Chức năng + Kho mã + Liên hệ + Báo lỗi + Ủng hộ %1$s + + + Chính thức + Đã xác minh + Sao GitHub + URL kho mã GitHub + + + Không tìm thấy bản phát hành nào + Chọn bản phát hành + Chọn một bản phát hành + Bản thử nghiệm + Ghi chú phát hành + + + Trang chủ + Tìm kiếm + Trên thiết bị + Kết nối + Thiết bị + + + Ẩn cửa sổ + Hiện cửa sổ + Bài tiếp theo + Bài trước + Phát ngẫu nhiên + Lặp lại + Lặp lại: TẮT + Lặp lại: MỘT BÀI + Lặp lại: TẤT CẢ + Âm lượng: %1$d% + Âm lượng + + Âm lượng - + Tắt tiếng + Bật tiếng + Thích bài hát + Bỏ thích bài hát + Thoát + + + Thu nhỏ + Phóng to + Khôi phục + Đóng + + + Phát nhạc + Điều khiển phát nội dung + + + Duyệt + Đang phát + Nổi bật + Bài hát đã lưu + Bài hát hàng đầu + diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml new file mode 100644 index 00000000..f5907323 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -0,0 +1,920 @@ + + + + 待播清單 + 清除待播清單 + 篩選待播清單... + 待播清單中沒有項目 + + 搜尋歌曲、藝人、專輯... + 清除所有紀錄 + 搜尋... + + + 你的音樂庫 + 音樂庫 + 播放清單 + 專輯 + 藝人 + 本機曲目 + 下載 + 搜尋已儲存的播放清單… + 搜尋已儲存的專輯… + 搜尋已儲存的藝人… + 搜尋資料夾或曲目… + 篩選音樂庫 + 關閉篩選 + 建立播放清單 + 重新整理本機曲目 + 切換為格狀檢視 + 切換為清單檢視 + 沒有已儲存的播放清單 + 沒有符合「%1$s」的播放清單 + 沒有已儲存的專輯 + 沒有符合「%1$s」的專輯 + 沒有已儲存的藝人 + 沒有符合「%1$s」的藝人 + + + 資料夾 + 資料夾 + %1$s • %2$s + + %1$d 首曲目 + + + %1$d 個資料夾 + + 返回資料夾 + 播放資料夾 + 需要音訊存取權限 + 允許存取音訊,即可瀏覽裝置上的音樂。 + 授予權限 + 找不到本機資料夾 + 此資料夾中沒有曲目 + 沒有符合的資料夾或曲目 + 無法重新整理本機媒體 + + + %1$d 個進行中 • %2$d 個已完成 + 清除已完成項目 + 尚無下載項目 + 下載中 %1$d% + 等待中 + 已完成 + 已取消 + 失敗 + 失敗:%1$s + 取消下載 + 重試下載 + 移除下載項目 + + + 藝人 + 已封鎖 + 喜歡的曲目 + 你已儲存的歌曲 + 未知藝人 + 曲目名稱 + 藝人名稱 + + 已選取 + + 設定 + + 外掛程式 + 語言與地區 + 外觀 + 播放 + 下載 + 快取 + 桌面版 + 更新 + 支持 + 關於 + + 關閉 + 儲存 + 取消 + + 語言 + 應用程式介面的語言。目前:%1$s + 跟隨系統 + 國家/地區 + 用於內容與可用性的國家/地區。目前:%1$s + %1$s(%2$s) + + 主題 + 選擇 Spotube 如何配合系統外觀。目前:%1$s + 淺色 + 深色 + 跟隨系統 + + 表面材質 + 應用程式的背景材質。目前:%1$s + 水波 + 冰晶 + 金屬鉻 + 壓克力 + 玻璃 + + 強調色 + 目前色盤:%1$s + 強調色 + 選擇強調色盤,並在淺色與深色主題中預覽效果。 + 淺色 + 深色 + 預覽 + 按鈕 + 妖精綠 + 電光紫 + 海洋青 + 夕陽橘 + 玫瑰園 + 午夜藍 + 金屬石板灰 + + 外觀與風格 + 表面材質與強調色。目前:%1$s · %2$s + 外觀與風格 + 先選擇表面材質,再挑選與之搭配的強調色。每個選項都是精心調配的組合。 + 表面材質 + 強調色 + 次要 + + 色盤 + Millennium 強調色盤。目前:%1$s + 色盤 + 選擇 Millennium 強調色盤,並在淺色與深色模式中預覽。 + 色盤 + Millennium 色盤與強調色。目前:%1$s + 藍色 + 橘色 + 萊姆綠 + + 自訂下載資料夾 + 目前資料夾:%1$s + 留空即使用預設音樂資料夾。 + C:\Music\Spotube + 本機媒體資料夾 + 桌面版要掃描的自訂資料夾。已新增:%1$d + 尚未新增自訂資料夾 + 本機音樂庫的自訂資料夾。 + 管理 + 新增資料夾 + 移除資料夾 + 尚未新增自訂資料夾。 + 從掃描清單中移除 %1$s + 下載資料夾與音樂快取資料夾會自動納入。 + 你的下載資料夾會自動納入。 + 音樂快取資料夾會自動納入。 + 儲存中… + 無法儲存資料夾。你的變更仍保留在此,請再試一次。 + + %1$d 個資料夾 + + 下載音樂格式 + 下載時偏好的編碼與封裝格式。目前:%1$s + 下載音樂品質 + + 串流音樂格式 + 串流音樂時偏好的編碼與封裝格式。目前:%1$s + 串流音樂品質 + 目前:%1$s + 啟用音樂快取 + 將串流音訊儲存在本機,加快重複播放的速度。 + 快取資料夾 + 目前:%1$s + 目前:預設應用程式快取 + 快取大小上限 + 無上限 + 目前上限:%1$d MB + 設定快取大小上限(MB)。輸入 0 或留空表示無上限。 + 500 + 大小必須為 0 或正數。 + 啟用無限播放 + 待播清單播完後,以推薦曲目繼續播放。 + 播放代理伺服器連接埠 + 允許遠端控制 + 讓同一網路中的其他裝置在連線後控制播放。 + 遠端控制裝置名稱 + 其他裝置搜尋到此裝置時所顯示的名稱。目前:%1$s + 選擇一個好記的名稱,讓網路上的其他裝置找到這個 Spotube 時顯示。 + 例如:客廳的 Spotube + 裝置主機名稱 + 播放代理伺服器使用的連接埠。目前:%1$d + 請選擇 1 到 65535 之間的連接埠。 + 14769 + 請輸入有效的整數。 + 連接埠必須介於 1 到 65535 之間。 + + 封鎖清單 + 管理已封鎖的曲目與藝人 + + 最小化至系統匣 + 視窗最小化時,讓 Spotube 在系統匣中繼續執行。 + Discord 動態狀態 + 在桌面版的 Discord 中顯示目前播放狀態。 + + 自動檢查更新 + 在背景檢查 Spotube 的新版本。 + + 管理外掛程式 + 管理你的外掛程式 + 預設%1$s外掛程式 + 中繼資料 + 音訊 + 歌詞 + 收聽紀錄 + 未選取外掛程式 + 變更 + 選取 + 已選取 + 清除選取 + 沒有可用的外掛程式 + %1$s 外掛程式 + 清除 + + 設定 + 外掛程式管理員 + 請輸入 URL + URL 必須以 http:// 或 https:// 開頭 + 無法下載外掛程式 + 尚未安裝任何外掛程式 + 請貼上下載 URL,或從上方選擇檔案。 + + 已安裝 %1$d 個外掛程式 + + 安裝外掛程式 + https://example.com/plugin.smplug + 下載 + 從檔案安裝(.smplug) + 使用中 + v%1$s + 內建 + 移除外掛程式 + 登入 + 登出 + 從 URL 下載 + 從檔案安裝 + 安裝 + + • %1$s + 要求的權限 + 此外掛程式不需要任何特殊權限。 + 已安裝與提供版本比較 + 已安裝 + 提供的版本 + API %1$s → %2$s + 永久儲存空間 + 讀取及寫入磁碟資料 + 網路要求 + 透過網際網路傳送及接收資料 + WebView + 在應用程式內顯示網頁內容 + 群組 Jam + MQTT 代理伺服器 + 代理伺服器主機 + %1$s:%2$d + 代理伺服器連接埠 + 使用 TLS + 使用者名稱(選填) + 密碼(選填) + 用戶端 ID 前綴 + 用於識別這個 Spotube + 加密與代理伺服器的連線 + 在建立房間前檢查目前的代理伺服器設定。 + 測試連線 + 測試中… + 已連線,耗時 %1$d 毫秒 + 失敗:%1$s + 預留的代理伺服器 — 請設定你自己的伺服器以自行架設 + 連線 + 憑證 + 身分 + 診斷 + + + 群組 Jam + 建立 + 加入 + 建立房間,然後將代碼分享給朋友。 + 建立房間 + 加入房間 + 連線中… + 房間代碼 + ABC123 + 代理伺服器:%1$s + 請先在「設定」中設定代理伺服器,才能建立或加入房間。 + 代理伺服器設定 + 已連線 + 重新連線中… + 複製代碼 + 已複製! + 分享 + 參與者(%1$d) + 主持人 + 聽眾 + 正在等待聽眾加入… + 移除 + 封鎖 + 管理 + 離開房間 + 無法連線 + 關閉 + 無法建立 Jam + 無法加入 Jam + 請輸入由英文字母和數字組成的六位房間代碼。 + 用代碼 %1$s 加入我的 Spotube Jam + + 前往專輯 + 睡眠定時器 + 曲目詳細資料 + 播放 + 暫停 + 目前沒有播放內容 + 加入待播清單 + + 略過 + 更新 + Spotube %1$s 已推出 + 前往 Spotube 下載頁面查看此版本的新功能。 + %1$s %2$s 已推出 + 目前安裝版本:%1$s + 前往外掛程式頁面查看此更新的新功能。 + + 略過 + 下一步 + 上一步 + 開始使用 + Spotube + 音樂,無拘無束。 + 一款開放原始碼的播放器,帶你走進音樂的世界。\n任你探索,由你打造。 + 讓這個專案延續下去。 + Spotube 是由一群人打造的,而不是靠訂閱賺錢的企業。如果它已成為你日常生活的一部分,一點點支持就能幫助我們給予它應有的照顧。\n\n感謝你為獨立軟體留出一席之地。 + 先從目錄外掛程式開始,用來尋找歌曲與播放清單。接著選擇音訊外掛程式來播放。歌詞與收聽紀錄為選用項目。在下方探索外掛程式,檢視其權限並安裝,然後在這裡選取。 + 探索外掛程式 + 你的來源 + 再試一次 + 更多外掛程式 + 找不到新的外掛程式。你也可以從上方透過連結或 .smplug 檔案安裝。 + 目前無法探索外掛程式。請再試一次,或透過連結或檔案安裝。 + 歡迎 + 支持 + 外掛程式 + 指南 + + 如何使用 Spotube + 只要四個簡單步驟,就能開始聆聽。 + 登入 + 連結你的 Spotify 帳戶,或選擇你自己的目錄外掛程式。 + 搜尋並播放 + 找到任何歌曲、專輯或藝人,然後按下播放。 + 離線聆聽 + 下載曲目,沒有網路也能繼續播放。 + 跟著歌詞唱 + 播放曲目時,從播放器開啟同步歌詞。 + + + 你的音樂,你的來源,你的規則。 + 一款免費、開放原始碼的音樂播放器,由熱愛音樂的社群打造。 + 開放原始碼 + 無廣告 + 無遙測 + 外掛程式驅動 + 出發吧 + + + 支持者讓我們走下去 + 以熱情打造,因你而延續。 + Spotube 百分之百免費、自由且開放原始碼 — 沒有廣告、沒有訂閱、沒有追蹤。每一項功能的存在,都是因為像你這樣的人選擇支持程式碼持續前進。 + Patreon + 每月贊助 + 支持持續的開發與基礎設施 + Buy Me a Coffee + 請喝一杯咖啡 + 小小打賞,為深夜寫程式加點油 + Open Collective + 社群基金 + 透明、由社群治理的資金支持 + 衷心感謝你相信開放的音樂。 + 支持完全是自願的 — 無論如何,Spotube 都會保持免費。 + 贊助 + 開啟 %1$s + + + 支持 Spotube + 支持 Spotube + Spotube 免費、自由且開放原始碼 — 沒有廣告、沒有訂閱、沒有追蹤。如果它為你帶來快樂,歡迎考慮支持這個專案。 + 你可以在「設定 → 支持」中關閉這些提醒。 + 稍後再說 + 支持提醒 + 每 7 天顯示一次溫和的提醒,邀請你支持這個專案。 + + + 模組化架構 + 選擇你的音樂來源。 + Spotube 將目錄探索、音訊串流與同步歌詞分開處理。在下方設定你的引擎,或隨時切換。 + 中繼資料引擎 + 負責音樂探索、搜尋、曲目清單與播放清單。 + 音訊引擎 + 取得並解碼要播放的音訊串流。 + 歌詞引擎 + 即時串流與時間同步的卡拉 OK 歌詞。 + 收聽紀錄引擎 + 將收聽紀錄傳送至 Last.fm 或 ListenBrainz。 + 使用中 + 內建 + 就緒 + 選用 + 選擇引擎 + 已安裝的引擎 + 設為預設 + NewPipe YouTube 與 LRCLib Lyrics 皆為內建,安裝後即可直接串流。 + 隨時可在「設定 → 外掛程式」中管理或新增社群外掛程式。 + + + 復古摩登架構 + 純粹 Hi-Fi,沒有中間人 + 以下說明 Spotube 如何直接在你的裝置上協調去中心化的播放。 + 直接點對點 • 256 KBPS OPUS + 藍調時刻 + Spotube · 播放器示範 + 為片刻之間的時光,送上一點音樂。 + 1. 去中心化探索 + 查詢會直接送到你選擇的目錄外掛程式。Spotube 絕不分析你的喜好。 + 2. 直接解析串流 + 音訊直接從內容伺服器串流到你的裝置。沒有代理轉送,也沒有遠端資料蒐集。 + 3. 本機 DSP 與播放 + 在本機解碼並快取,提供等化器控制及選用的離線下載。 + 4. 同步歌詞與 Jam + 卡拉 OK 歌詞跟隨播放時間軸,並支援 LAN 遠端控制與群組 Jam。 + 沒有中間伺服器。播放直接在你的裝置上進行 — 隱私從設計開始。 + + + 選擇裝置 + %1$s:「%2$s」 + 播放 + 加入待播清單 + 下一首播放 + 這部裝置 + 在這部裝置上 + 遠端裝置 + 遠端裝置(%1$s) + 在已連線的裝置上 + + + 連線失敗 + 連線中斷 + 傳送失敗 + 搜尋失敗:%1$s + 無法連線:%1$s + + + 裝置 + 重新整理 + 中斷連線 + 連線中... + 正在搜尋網路上的裝置... + 找不到裝置 + 請確認另一部裝置已在設定中啟用「允許遠端控制」。 + 未知裝置 + 解析中... + + + 遠端控制 + 已中斷連線 + 連線錯誤:%1$s + 未知曲目 + 未知藝人 + 音量 + 從待播清單移除 + 更多選項 + + + 要在哪裡播放? + 你有一個進行中的 Jam。請選擇要在哪裡播放這個合輯。 + 在這裡播放 + 推薦到 Jam + + + 尚未設定 Jam 代理伺服器 + 代理伺服器主機為空白 + 連線失敗 + 連線中斷 + + + 代理伺服器 + anonymous + + + 已加入 Jam 待播清單 + + 已將 %1$d 首加入 Jam 待播清單 + + + + 遠端控制要求 + 「%1$s」想要控制這部裝置上的播放。 + 拒絕 + 允許 + 一律允許 + + + 搜尋結果 + 全部 + 曲目 + 使用者 + 沒有可用的搜尋類型 + 輸入文字開始搜尋 + 輸入文字開始搜尋曲目 + 找不到結果 + 查看全部 + 搜尋中 + 正在載入曲目... + 正在載入專輯... + 正在載入藝人... + 正在載入播放清單... + 正在載入使用者... + 找不到專輯 + 找不到藝人 + 找不到播放清單 + 找不到使用者 + 正在翻找唱片箱... + 正在搜尋宇宙... + 正在暖機喇叭... + 正在擦拭黑膠唱片... + 正在請教音樂之神... + 正在調整天線... + 正在翻閱唱片... + 無法載入搜尋功能 + 搜尋失敗 + 無法搜尋曲目 + 無法搜尋專輯 + 無法搜尋藝人 + 無法搜尋播放清單 + 無法搜尋使用者 + + + 瀏覽 + + + 同步 + 純文字 + 沒有可用的同步歌詞 + 沒有可用的純文字歌詞 + + + %1$s(%2$s) + %1$d kbps + %1$s kHz • %2$d 聲道 + 未選取中繼資料外掛程式 + + + 返回 + 重試 + 關閉對話框 + 載入中... + 未知 + 未知錯誤 + + + 使用者 + 播放清單 + + + 我們深感抱歉 + 目前有些功能無法正常運作。你的使用體驗對我們很重要,我們正盡全力修正。請再試一次,或查看下方的錯誤詳細資料。 + 錯誤詳細資料 + 複製到剪貼簿 + 檢視完整記錄 + 完整錯誤記錄 + + + 隨機播放 + 隨機播放 + 追蹤 + 取消追蹤 + 編輯播放清單 + 由 %1$s 建立 + + + 曲目選項 + 加入 Jam + 開始播放電台 + 下一首播放 + 移至下一首 + 從待播清單移除 + 從最愛中移除 + 加入最愛 + 加入播放清單 + 下載 + 分享 + + + 原始順序 + 標題 + 藝人 + 專輯 + 長度 + 排序 + 找不到曲目 + 篩選 + 批次操作 + 全部下載 + 下載 %1$d 首 + 全部加入待播清單 + 將 %1$d 首加入待播清單 + 全部設為下一首播放 + 將 %1$d 首設為下一首播放 + 全部加入播放清單 + 將 %1$d 首加入播放清單 + 全部加入 Jam + 將 %1$d 首加入 Jam + +%1$d 位 + + + 沒有可載入的 URL + 返回上一頁 + 前往下一頁 + + + 篩選封鎖清單... + 沒有已封鎖的項目 + 沒有符合「%1$s」的已封鎖項目 + 曲目 + 曲目 + 加入封鎖清單 + 從封鎖清單移除 + + + 沒有曲目中繼資料 + 無法解析串流 URL + 無法解析串流 URL。請先嘗試播放此曲目。 + + + 已儲存的曲目 + 你 + + + 追蹤中 + 熱門曲目 + 播放熱門曲目 + 將熱門曲目加入待播清單 + 相關藝人 + 精選播放清單 + 無法載入藝人總覽 + 無法取得追蹤者人數 + %1$sK 位追蹤者 + %1$sM 位追蹤者 + %1$sB 位追蹤者 + + %1$d 位追蹤者 + + + + 喜歡 + 取消喜歡 + 無曲目標題 + 無藝人 + 未知專輯 + 播放進度 + 音量 + 開啟隨機播放 + 關閉隨機播放 + 上一首 + 下一首 + 播放或暫停 + 重複播放:關閉 + 重複播放:單曲 + 重複播放:全部 + 靜音 + 取消靜音 + 更多選項 + 播放器選項 + 收合播放器 + 展開歌詞 + 現正播放 + 歌詞 + 替代來源 + 替代來源 + 下載 + 已下載 + 下載中... + 已經下載過了 + 正在重試下載... + 正在下載 %1$s + 播放將在 %1$s 後暫停。 + 在所選的分鐘數後暫停播放。 + 分鐘 + 請選擇 1 到 600 分鐘 + 取消定時器 + 開始 + 播放 URL 是以不同的串流設定解析的。醒目標示的串流符合你目前的偏好;使用中的 URL 會在下次要求串流時更新。 + 配對的音訊來源 + 正在載入配對的來源… + 播放本機快取檔案時,不需要配對的遠端來源。 + 沒有可用的配對來源。 + 此曲目沒有已快取的配對音訊來源。 + 串流詳細資料尚未快取。請先播放此曲目以載入資料。 + 無法載入曲目詳細資料。 + 可用的串流 + 此曲目正從本機快取播放,本次播放不會使用串流變體。 + 正在載入串流… + 此來源沒有可用的串流。 + 目前選取的串流 + 符合你的串流格式與品質偏好 + 使用中的播放 URL + 從本機快取播放 + 中繼資料曲目 + 標題 + ID + 專輯 + 信賴度 + 外部 URI + 專輯 ID + 專輯類型 + 專輯發行日期 + 專輯曲目數 + 專輯類型風格 + 長度 + 曲目編號 + 光碟編號 + 兒少不宜 + 熱門度 + ISRC + 縮圖 URL + + + 切換側邊欄 + + + 替代來源 + 目前沒有播放中的曲目 + 正在搜尋替代來源... + 找不到替代來源 + 信賴度:%1$d% + 使用中的來源 + + + 踢出 + 將對方移出這個 Jam + 踢出並禁止對方再次加入 + 移除推薦曲目 + 移除對方加入待播清單的所有曲目 + 重新排序 + 從待播清單移除 + %1$s • 由 %2$s 加入 + + + 未知 + 正在載入播放清單... + 新增曲目 + 將曲目加入播放清單 + 搜尋曲目... + 搜尋 + 找不到曲目 + 搜尋要加入的曲目 + 完成 + 新增 + 已新增 + + + 正在載入專輯... + + + 加入你的播放清單 + 搜尋 + 搜尋你的播放清單... + 正在載入你的播放清單... + 你還沒有自己的播放清單。\n建立一個來加入曲目吧。 + 建立播放清單 + 編輯播放清單 + 建立 + 名稱 + 播放清單名稱 + 說明 + 說明(選填) + 公開 + 所有人都能看到這個播放清單 + 協作 + 其他人可以將曲目加入這個播放清單 + 封面圖片 + 播放清單封面 + 沒有圖片 + 更換圖片 + 選擇圖片 + + + 安裝 + 取代 + 在 zip 檔案中找不到 plugin.json + 在 zip 檔案中找不到 manifest.zipline.json + plugin.json 格式無效:%1$s + 無法讀取外掛程式:%1$s + 無法安裝外掛程式:%1$s + %1$s 有較新的版本。安裝前請先確認更新。 + %1$s 已經安裝。安裝前請先確認取代。 + 內建外掛程式已包含在內,無法再次新增。 + 無法移除內建外掛程式。 + %1$s 宣告的 apiVersion 無效(%2$s)。應為與 %3$s 相容的語意化版本。 + Spotube 外掛程式 API 版本 %1$s 無效。 + %1$s 的目標為外掛程式 API %2$s,但 Spotube 支援的是不相容變更後的外掛程式 API %3$s。請安裝針對相同主要 API 版本建置的外掛程式。 + + + 外掛程式 API 不相容 + 要安裝外掛程式嗎? + %1$s 將加入你已安裝的外掛程式。 + 要更新外掛程式嗎? + 已安裝舊版(%1$s)。要更新至 %2$s 嗎? + 要取代已安裝的外掛程式嗎? + %1$s 使用了無效的語意化版本(%2$s),無法作為更新進行比較。仍要取代已安裝的外掛程式嗎? + 已安裝的版本(%1$s)無法以語意化版本進行比較。要以 %2$s 取代嗎? + 版本 %1$s 已經安裝。要以提供的版本取代現有的外掛程式嗎? + 已安裝的版本 %1$s 比提供的版本 %2$s 新。仍要取代嗎? + 要以提供的版本取代已安裝的外掛程式嗎? + + + %1$s: + 版本 + API 版本 + 作者 + 授權條款 + 權限 + 功能 + 儲存庫 + 聯絡方式 + 回報錯誤 + 支持 %1$s + + + 官方 + 已驗證 + GitHub 星星數 + GitHub 儲存庫 URL + + + 找不到發行版本 + 選擇發行版本 + 選擇一個發行版本 + 預先發行版 + 版本資訊 + + + 首頁 + 搜尋 + 本機 + 連線 + 裝置 + + + 隱藏視窗 + 顯示視窗 + 下一首 + 上一首 + 隨機播放 + 重複播放 + 重複播放:關閉 + 重複播放:單曲 + 重複播放:全部 + 音量:%1$d% + 音量 + + 音量 - + 靜音 + 取消靜音 + 喜歡此曲目 + 取消喜歡此曲目 + 結束 + + + 最小化 + 最大化 + 還原 + 關閉 + + + 播放 + 媒體播放控制項 + + + 瀏覽 + 現正播放 + 精選 + 已儲存的曲目 + 熱門曲目 + diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml new file mode 100644 index 00000000..d154c6a2 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -0,0 +1,920 @@ + + + + 播放队列 + 清空队列 + 筛选队列... + 队列为空 + + 搜索歌曲、艺人、专辑... + 清除全部历史记录 + 搜索... + + + 我的音乐库 + 音乐库 + 歌单 + 专辑 + 艺人 + 本地歌曲 + 下载 + 搜索已收藏的歌单… + 搜索已收藏的专辑… + 搜索已关注的艺人… + 搜索文件夹或歌曲… + 筛选音乐库 + 关闭筛选 + 创建歌单 + 刷新本地歌曲 + 切换到网格视图 + 切换到列表视图 + 暂无收藏的歌单 + 没有与“%1$s”匹配的歌单 + 暂无收藏的专辑 + 没有与“%1$s”匹配的专辑 + 暂无关注的艺人 + 没有与“%1$s”匹配的艺人 + + + 文件夹 + 文件夹 + %1$s • %2$s + + %1$d 首歌曲 + + + %1$d 个文件夹 + + 返回文件夹 + 播放文件夹 + 需要音频访问权限 + 允许访问音频,以浏览设备上的音乐。 + 授予权限 + 未找到本地文件夹 + 此文件夹中没有歌曲 + 没有匹配的文件夹或歌曲 + 刷新本地媒体失败 + + + %1$d 个进行中 • %2$d 个已完成 + 清除已完成 + 暂无下载 + 正在下载 %1$d% + 排队中 + 已完成 + 已取消 + 失败 + 失败:%1$s + 取消下载 + 重试下载 + 移除下载 + + + 艺人 + 已拉黑 + 喜欢的歌曲 + 你收藏的歌曲 + 未知艺人 + 歌曲标题 + 艺人名称 + + 已选择 + + 设置 + + 插件 + 语言和地区 + 外观 + 播放 + 下载 + 缓存 + 桌面 + 更新 + 支持 + 关于 + + 关闭 + 保存 + 取消 + + 语言 + 应用界面语言。当前:%1$s + 跟随系统 + 国家/地区 + 用于内容和可用性的国家/地区。当前:%1$s + %1$s (%2$s) + + 主题 + 选择 Spotube 如何匹配系统外观。当前:%1$s + 浅色 + 深色 + 跟随系统 + + 材质 + 应用的背景材质。当前:%1$s + 水润 + 冰晶 + 铬金属 + 亚克力 + 玻璃 + + 强调色 + 当前配色:%1$s + 强调色 + 选择一种强调配色,并在浅色和深色主题下预览。 + 浅色 + 深色 + 预览 + 按钮 + 妖精绿 + 电光紫 + 海洋青 + 日落橙 + 玫瑰园 + 午夜蓝 + 金属灰 + + 外观与风格 + 材质和强调色。当前:%1$s · %2$s + 外观与风格 + 先选择一种材质,再选择与之搭配的强调色。每个选项都是精心挑选的组合。 + 材质 + 强调色 + 次要 + + 配色 + Millennium 强调配色。当前:%1$s + 配色 + 选择一种 Millennium 强调配色,并在浅色和深色下预览。 + 配色 + Millennium 配色和强调色。当前:%1$s + 蓝色 + 橙色 + 青柠 + + 自定义下载文件夹 + 当前文件夹:%1$s + 留空则使用默认音乐文件夹。 + C:\Music\Spotube + 本地媒体文件夹 + 桌面端要扫描的自定义文件夹。已添加:%1$d + 尚未添加自定义文件夹 + 用于本地音乐库的自定义文件夹。 + 管理 + 添加文件夹 + 移除文件夹 + 未添加自定义文件夹。 + 从扫描列表中移除 %1$s + 下载文件夹和音乐缓存文件夹会自动包含在内。 + 你的下载文件夹会自动包含在内。 + 音乐缓存文件夹会自动包含在内。 + 正在保存… + 无法保存文件夹。你的更改仍然保留,请重试。 + + %1$d 个文件夹 + + 下载音乐格式 + 下载时首选的编解码器和封装格式。当前:%1$s + 下载音质 + + 流媒体音乐格式 + 在线播放时首选的编解码器和封装格式。当前:%1$s + 在线播放音质 + 当前:%1$s + 启用音乐缓存 + 将在线播放的音频保存到本地,加快重复播放速度。 + 缓存文件夹 + 当前:%1$s + 当前:默认应用缓存 + 缓存大小上限 + 无限制 + 当前上限:%1$d MB + 设置缓存的最大大小(MB)。输入 0 或留空表示无限制。 + 500 + 大小必须为 0 或正数。 + 启用无尽播放 + 队列播放完毕后,继续播放推荐的歌曲。 + 播放代理服务器端口 + 允许远程控制 + 允许同一网络中的其他设备在连接后控制播放。 + 远程控制设备名称 + 其他设备发现本设备时看到的名称。当前:%1$s + 设置一个易于识别的名称,网络中的其他设备发现此 Spotube 实例时会显示该名称。 + 例如:客厅的 Spotube + 设备主机名 + 播放代理服务器使用的端口。当前:%1$d + 请选择 1 到 65535 之间的端口。 + 14769 + 请输入有效的整数。 + 端口必须在 1 到 65535 之间。 + + 黑名单 + 管理已拉黑的歌曲和艺人 + + 最小化到托盘 + 窗口最小化时,让 Spotube 在系统托盘中继续运行。 + Discord 动态状态 + 在桌面端的 Discord 中显示当前播放状态。 + + 自动检查更新 + 在后台检查 Spotube 新版本。 + + 管理插件 + 管理你的插件 + 默认%1$s插件 + 元数据 + 音频 + 歌词 + 听歌记录 + 未选择插件 + 更改 + 选择 + 已选择 + 清除选择 + 没有可用的插件 + %1$s 插件 + 清除 + + 配置 + 插件管理器 + 请输入 URL + URL 必须以 http:// 或 https:// 开头 + 插件下载失败 + 尚未安装任何插件 + 在上方粘贴下载 URL 或选择文件。 + + 已安装 %1$d 个插件 + + 安装插件 + https://example.com/plugin.smplug + 下载 + 从文件安装 (.smplug) + 已启用 + v%1$s + 内置 + 移除插件 + 登录 + 退出登录 + 从 URL 下载 + 从文件安装 + 安装 + + • %1$s + 请求的权限 + 此插件不需要任何特殊权限。 + 已安装与待安装对比 + 已安装 + 待安装 + API %1$s → %2$s + 持久存储 + 在磁盘上读写数据 + 网络请求 + 通过互联网收发数据 + WebView + 在应用内显示网页内容 + 一起听 + MQTT 代理服务器 + 代理服务器主机 + %1$s:%2$d + 代理服务器端口 + 使用 TLS + 用户名(可选) + 密码(可选) + 客户端 ID 前缀 + 用于识别此 Spotube 实例 + 加密与代理服务器的连接 + 创建房间前,检查当前的代理服务器设置。 + 测试连接 + 正在测试… + 已连接,耗时 %1$d 毫秒 + 失败:%1$s + 占位代理服务器 — 如需自托管,请配置你自己的服务器 + 连接 + 凭据 + 身份 + 诊断 + + + 一起听 + 创建 + 加入 + 创建一个房间,然后把房间码分享给朋友。 + 创建房间 + 加入房间 + 正在连接… + 房间码 + ABC123 + 代理服务器:%1$s + 请先在设置中配置代理服务器,才能创建或加入房间。 + 代理服务器设置 + 已连接 + 正在重新连接… + 复制房间码 + 已复制! + 分享 + 参与者(%1$d) + 房主 + 听众 + 正在等待听众加入… + 移除 + 封禁 + 管理 + 离开房间 + 无法连接 + 关闭 + 创建一起听会话失败 + 加入一起听会话失败 + 请输入由字母和数字组成的六位房间码。 + 用房间码加入我的 Spotube 一起听:%1$s + + 转到专辑 + 睡眠定时器 + 歌曲详情 + 播放 + 暂停 + 当前没有播放内容 + 添加到队列 + + 忽略 + 更新 + Spotube %1$s 现已推出 + 前往 Spotube 下载页面查看此版本的新功能。 + %1$s %2$s 现已推出 + 当前已安装:%1$s + 前往插件页面查看此更新的新功能。 + + 跳过 + 下一步 + 返回 + 开始使用 + Spotube + 音乐,无拘无束。 + 一款开源播放器,带你探索音乐世界。\n由你探索,由你定义。 + 让这个项目延续下去。 + Spotube 由一群热爱它的人打造,而不是一门订阅生意。如果它已成为你日常生活的一部分,一点点支持就能帮助我们给予它应有的用心呵护。\n\n感谢你为独立软件留出一席之地。 + 先安装一个曲库插件来查找歌曲和歌单,再选择一个音频插件来播放它们。歌词和听歌记录是可选的。在下方发现插件,查看其权限并安装,然后在此处选择它。 + 发现插件 + 你的音源 + 重试 + 更多插件 + 未发现新插件。你也可以通过上方的链接或 .smplug 文件安装插件。 + 插件发现功能暂不可用。请重试,或通过链接或文件安装。 + 欢迎 + 支持 + 插件 + 指南 + + 如何使用 Spotube + 只需四个简单步骤,即可开始聆听。 + 登录 + 连接你的 Spotify 帐号,或选择你自己的曲库插件。 + 搜索并播放 + 查找任意歌曲、专辑或艺人,然后点击播放。 + 离线收听 + 下载歌曲,无网络时也能继续播放。 + 跟随歌词 + 播放歌曲时,从播放器中打开同步歌词。 + + + 你的音乐。你的音源。你做主。 + 一款免费、开源的音乐播放器,由一群热爱音乐的社区成员打造。 + 开源 + 无广告 + 无遥测 + 插件驱动 + 出发吧 + + + 支持者让我们走得更远 + 用热情打造,因你而延续。 + Spotube 100% 免费、自由且开源 — 无广告、无订阅、无追踪。每一项功能的存在,都是因为像你这样的人选择帮助代码持续前行。 + Patreon + 每月赞助 + 支持持续的开发和基础设施 + Buy Me a Coffee + 请杯咖啡 + 用一笔小额打赏,为深夜编码加油 + Open Collective + 社区基金 + 透明、由社区管理的资金支持 + 衷心感谢你对开放音乐的信任。 + 支持完全自愿 — 无论如何 Spotube 都将保持免费。 + 赞助 + 打开 %1$s + + + 支持 Spotube + 支持 Spotube + Spotube 免费、自由且开源 — 无广告、无订阅、无追踪。如果它为你带来了快乐,请考虑支持这个项目。 + 你可以在“设置 → 支持”中关闭这些提醒。 + 以后再说 + 支持提醒 + 每 7 天温馨提醒一次支持本项目。 + + + 模块化架构 + 选择你的音乐来源。 + Spotube 将曲库发现、音频流和同步歌词分开处理。在下方配置各个引擎,或随时切换。 + 元数据引擎 + 为音乐发现、搜索、歌曲列表和歌单提供支持。 + 音频引擎 + 获取并解码要播放的音频流。 + 歌词引擎 + 实时传输逐句同步的卡拉 OK 歌词。 + 听歌记录引擎 + 将听歌记录同步到 Last.fm 或 ListenBrainz。 + 已启用 + 内置 + 就绪 + 可选 + 选择一个引擎 + 已安装的引擎 + 设为默认 + NewPipe YouTube 和 LRCLib 歌词已内置,开箱即可使用。 + 随时可以在“设置 → 插件”中管理或添加新的社区插件。 + + + 复古与现代融合的架构 + 纯粹高保真,零中间商 + 看看 Spotube 如何直接在你的设备上协调去中心化播放。 + 直接点对点 • 256 KBPS OPUS + 蓝调时刻 + Spotube · 播放器演示 + 一点音乐,献给那些间隙时光。 + 1. 去中心化发现 + 查询直接发送到你选择的曲库插件。Spotube 绝不分析你的喜好。 + 2. 直接解析音频流 + 音频直接从内容服务器传输到你的设备。没有代理中转,也没有远程数据收集。 + 3. 本地 DSP 与播放 + 在本地解码和缓存,提供均衡器控制和可选的离线下载。 + 4. 同步歌词与一起听 + 卡拉 OK 歌词跟随播放进度,并支持 LAN 远程控制和一起听。 + 没有中间服务器。播放直接在你的设备上进行 — 隐私从设计开始。 + + + 选择设备 + %1$s:“%2$s” + 播放 + 添加到队列 + 下一首播放 + 本设备 + 在本设备上 + 远程设备 + 远程设备(%1$s) + 在已连接的设备上 + + + 连接失败 + 连接已断开 + 发送失败 + 发现设备失败:%1$s + 连接失败:%1$s + + + 设备 + 刷新 + 断开连接 + 正在连接... + 正在搜索网络中的设备... + 未找到设备 + 请确保另一台设备已在设置中启用“允许远程控制”。 + 未知设备 + 正在解析... + + + 远程控制 + 已断开连接 + 连接错误:%1$s + 未知歌曲 + 未知艺人 + 音量 + 从队列中移除 + 更多选项 + + + 在哪里播放? + 你正在进行一起听会话。请选择在哪里播放此合集。 + 在此播放 + 推荐到一起听 + + + 未配置一起听代理服务器 + 代理服务器主机为空 + 连接失败 + 连接已断开 + + + 代理服务器 + anonymous + + + 已添加到一起听队列 + + 已将 %1$d 首歌曲添加到一起听队列 + + + + 远程控制请求 + “%1$s”请求控制此设备上的播放。 + 拒绝 + 允许 + 始终允许 + + + 搜索结果 + 全部 + 歌曲 + 用户 + 没有可用的搜索类型 + 输入内容开始搜索 + 输入内容开始搜索歌曲 + 未找到结果 + 查看全部 + 正在搜索 + 正在加载歌曲... + 正在加载专辑... + 正在加载艺人... + 正在加载歌单... + 正在加载用户... + 未找到专辑 + 未找到艺人 + 未找到歌单 + 未找到用户 + 正在翻箱倒柜找唱片... + 正在探索音乐宇宙... + 正在预热音箱... + 正在擦拭黑胶唱片... + 正在请教音乐之神... + 正在调试天线... + 正在翻阅唱片... + 加载搜索功能失败 + 搜索失败 + 搜索歌曲失败 + 搜索专辑失败 + 搜索艺人失败 + 搜索歌单失败 + 搜索用户失败 + + + 浏览 + + + 同步 + 纯文本 + 暂无同步歌词 + 暂无纯文本歌词 + + + %1$s(%2$s 封装) + %1$d kbps + %1$s kHz • %2$d 声道 + 未选择元数据插件 + + + 返回 + 重试 + 关闭对话框 + 正在加载... + 未知 + 未知错误 + + + 用户 + 歌单 + + + 非常抱歉 + 出了点问题。你的体验对我们很重要,我们正在全力修复。请重试,或查看下方的错误详情。 + 错误详情 + 复制到剪贴板 + 查看完整日志 + 完整错误日志 + + + 随机播放 + 随机播放 + 关注 + 取消关注 + 编辑歌单 + 创建者:%1$s + + + 歌曲选项 + 添加到一起听 + 开启电台 + 下一首播放 + 移到下一首 + 从队列中移除 + 从收藏中移除 + 收藏 + 添加到歌单 + 下载 + 分享 + + + 默认 + 标题 + 艺人 + 专辑 + 时长 + 排序 + 未找到歌曲 + 筛选 + 批量操作 + 全部下载 + 下载 %1$d 首 + 全部添加到队列 + 将 %1$d 首添加到队列 + 全部下一首播放 + 下一首播放 %1$d 首 + 全部添加到歌单 + 将 %1$d 首添加到歌单 + 全部添加到一起听 + 将 %1$d 首添加到一起听 + 另外 %1$d 位 + + + 没有要加载的 URL + 在浏览历史中后退 + 在浏览历史中前进 + + + 筛选黑名单... + 黑名单为空 + 没有与“%1$s”匹配的黑名单项目 + 歌曲 + 歌曲 + 加入黑名单 + 从黑名单中移除 + + + 没有歌曲元数据 + 无法解析音频流 URL + 无法解析音频流 URL。请先尝试播放这首歌曲。 + + + 收藏的歌曲 + 你 + + + 已关注 + 热门歌曲 + 播放热门歌曲 + 将热门歌曲添加到队列 + 相关艺人 + 精选歌单 + 加载艺人概览失败 + 粉丝数不可用 + %1$sK 位粉丝 + %1$sM 位粉丝 + %1$sB 位粉丝 + + %1$d 位粉丝 + + + + 喜欢 + 取消喜欢 + 无歌曲标题 + 无艺人 + 未知专辑 + 进度 + 音量 + 开启随机播放 + 关闭随机播放 + 上一首 + 下一首 + 播放或暂停 + 循环模式:关闭 + 循环模式:单曲循环 + 循环模式:全部循环 + 静音 + 取消静音 + 更多选项 + 播放器选项 + 收起播放器 + 展开歌词 + 正在播放 + 歌词 + 备用音源 + 备用音源 + 下载 + 已下载 + 正在下载... + 已下载过 + 正在重试下载... + 正在下载 %1$s + 将在 %1$s后暂停播放。 + 在选定的分钟数后暂停播放。 + 分钟 + 请选择 1 到 600 分钟 + 取消定时 + 开始 + 播放 URL 是使用不同的流媒体设置解析的。高亮显示的音频流符合你当前的偏好;当前使用的 URL 将在下次请求音频流时刷新。 + 匹配的音源 + 正在加载匹配的音源… + 播放本地缓存文件时,无需匹配远程音源。 + 没有可用的匹配音源。 + 此歌曲没有缓存的匹配音源。 + 音频流详情尚未缓存。请先播放这首歌曲以加载详情。 + 加载歌曲详情失败。 + 可用的音频流 + 此歌曲正从本地缓存播放,本次播放不使用音频流变体。 + 正在加载音频流… + 此音源没有可用的音频流。 + 当前选择的音频流 + 符合你的流媒体格式和音质偏好 + 当前播放 URL + 从本地缓存播放 + 元数据歌曲 + 标题 + ID + 专辑 + 匹配度 + 外部 URI + 专辑 ID + 专辑类型 + 专辑发行日期 + 专辑歌曲数 + 专辑流派 + 时长 + 曲目编号 + 碟片编号 + 含露骨内容 + 热度 + ISRC + 缩略图 URL + + + 切换侧边栏 + + + 备用音源 + 当前没有正在播放的歌曲 + 正在搜索备用音源... + 未找到备用音源 + 匹配度:%1$d% + 当前音源 + + + 踢出 + 将其移出会话 + 踢出并禁止其重新加入 + 移除推荐 + 移除其添加到队列中的所有歌曲 + 调整顺序 + 从队列中移除 + %1$s • 由 %2$s 添加 + + + 未知 + 正在加载歌单... + 添加歌曲 + 添加歌曲到歌单 + 搜索歌曲... + 搜索 + 未找到歌曲 + 搜索要添加的歌曲 + 完成 + 添加 + 已添加 + + + 正在加载专辑... + + + 添加到你的歌单 + 搜索 + 搜索你的歌单... + 正在加载你的歌单... + 你还没有创建任何歌单。\n创建一个歌单来添加歌曲吧。 + 创建歌单 + 编辑歌单 + 创建 + 名称 + 歌单名称 + 简介 + 简介(可选) + 公开 + 任何人都可以看到此歌单 + 协作 + 其他人可以向此歌单添加歌曲 + 封面图片 + 歌单封面 + 无图片 + 更换图片 + 选择图片 + + + 安装 + 替换 + 在 zip 文件中未找到 plugin.json + 在 zip 文件中未找到 manifest.zipline.json + plugin.json 格式无效:%1$s + 读取插件失败:%1$s + 安装插件失败:%1$s + %1$s 有新版本可用。请在安装前确认更新。 + %1$s 已安装。请在安装前确认替换。 + 内置插件已包含在应用中,无法再次添加。 + 内置插件无法移除。 + %1$s 声明了无效的 apiVersion(%2$s)。应为与 %3$s 兼容的语义化版本。 + Spotube 插件 API 版本 %1$s 无效。 + %1$s 面向插件 API %2$s,但 Spotube 支持的是存在破坏性变更的插件 API %3$s。请安装针对相同主版本 API 构建的插件。 + + + 插件 API 不兼容 + 安装插件? + %1$s 将被添加到你已安装的插件中。 + 更新插件? + 已安装旧版本(%1$s)。要更新到 %2$s 吗? + 替换已安装的插件? + %1$s 使用了无效的语义化版本(%2$s),无法作为更新进行比较。仍要替换已安装的插件吗? + 已安装的版本(%1$s)无法按语义化版本进行比较。要将其替换为 %2$s 吗? + 版本 %1$s 已安装。要用提供的副本替换现有插件吗? + 已安装的版本 %1$s 比提供的版本 %2$s 更新。仍要替换吗? + 要用提供的副本替换已安装的插件吗? + + + %1$s: + 版本 + API 版本 + 作者 + 许可证 + 权限 + 功能 + 代码仓库 + 联系方式 + 报告问题 + 支持 %1$s + + + 官方 + 已验证 + GitHub 星标 + GitHub 仓库 URL + + + 未找到发布版本 + 选择版本 + 选择一个发布版本 + 预发布版 + 更新说明 + + + 首页 + 搜索 + 本机 + 连接 + 设备 + + + 隐藏窗口 + 显示窗口 + 下一首 + 上一首 + 随机播放 + 循环 + 循环:关闭 + 循环:单曲 + 循环:全部 + 音量:%1$d% + 音量 + + 音量 - + 静音 + 取消静音 + 喜欢这首歌 + 取消喜欢这首歌 + 退出 + + + 最小化 + 最大化 + 还原 + 关闭 + + + 播放 + 媒体播放控制 + + + 浏览 + 正在播放 + 精选 + 收藏的歌曲 + 热门歌曲 + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 00000000..a5481e2e --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,977 @@ + + + + Queue + Clear Queue + Filter queue... + No queue entries + + Search songs, artists, albums... + Clear all history + Search... + + + Your Library + Library + Playlists + Albums + Artists + Local Tracks + Downloads + Search saved playlists… + Search saved albums… + Search saved artists… + Search folders or tracks… + Filter library + Close filter + Create playlist + Refresh local tracks + Switch to grid view + Switch to list view + No saved playlists + No playlists match “%1$s” + No saved albums + No albums match “%1$s” + No saved artists + No artists match “%1$s” + + + Folders + Folder + %1$s • %2$s + + %1$d track + %1$d tracks + + + %1$d folder + %1$d folders + + Back to folders + Play folder + Audio access required + Allow audio access to browse music on your device. + Grant access + No local folders found + Add a folder with music to play it right here. No plugin needed. + Add folder + No tracks in this folder + No matching folders or tracks + Failed to refresh local media + + + %1$d active • %2$d completed + Saved to %1$s + Clear completed + No downloads yet + Downloading %1$d% + Queued + Completed + Cancelled + Failed + Failed: %1$s + Cancel download + Retry download + Remove download + + + Artist + Blacklisted + Liked Tracks + Your saved songs + Unknown artist + Track title + Artist name + + Selected + + Settings + + Plugins + Language and Region + Appearance + Playback + Downloads + Caching + Desktop + Updates + Support + About + + Close + Save + Cancel + + Language + Language for the app interface. Current: %1$s + System default + Country + Regional country for content and availability. Current: %1$s + %1$s (%2$s) + + Theme + Choose how Spotube matches your system appearance. Current: %1$s + Light + Dark + System + + Surface + Background material of the app. Current: %1$s + Watery + Icy + Chrome + Acrylic + Glass + + Accent color + Current palette: %1$s + Accent color + Choose an accent palette and preview it in both light and dark themes. + Light + Dark + Preview + Button + Green Goblin + Electric Violet + Oceanic Cyan + Sunset Orange + Rose Garden + Midnight Blue + Metallic Slate + + Look & feel + Surface material and accent. Current: %1$s · %2$s + Look & feel + Pick a surface material, then an accent that pairs with it. Every option is a curated combination. + Surface + Accent + Secondary + + Palette + Millennium accent palette. Current: %1$s + Palette + Choose a Millennium accent palette and preview it in light and dark. + Palette + Millennium palette and accent. Current: %1$s + Blue + Orange + Lime + + Download folder override + Current folder: %1$s + Current folder: %1$s (default) + Leave this empty to use the default music folder. + C:\Music\Spotube + Local media folders + Custom folders to scan on desktop. Added: %1$d + No custom folder added yet + Custom folders for your local music library. + Manage + Add folder + Remove folder + No custom folders added. + Remove %1$s from the scan list + Download and music cache folders are included automatically. + Your download folder is included automatically. + The music cache folder is included automatically. + Saving… + Couldn’t save folders. Your changes are still here; try again. + + %1$d folder + %1$d folders + + Download music format + Preferred codec and container for downloads. Current: %1$s + Download music quality + + Streaming music format + Preferred codec and container for music streaming. Current: %1$s + Streaming music quality + Current: %1$s + Enable music caching + Store streamed audio locally to speed up repeat playback. + Cache folder + Current: %1$s + Current: default application cache + Cache size limit + No limit + Current limit: %1$d MB + Set the maximum cache size in MB. Enter 0 or leave blank for no limit. + 500 + Size must be 0 or a positive number. + Enable endless playback + Keep playback going with recommended tracks when your queue ends. + Playback proxy server port + Allow remote control + Let other devices on the same network control playback when they connect. + Remote control device name + The name other devices see when discovering this one. Current: %1$s + Choose a friendly name to show to other devices on your network when they discover this Spotube instance. + e.g. Living Room Spotube + device hostname + Port used by the playback proxy server. Current: %1$d + Choose a port between 1 and 65535. + 14769 + Enter a valid whole number. + Port must be between 1 and 65535. + + Blacklist + Manage blacklisted tracks and artists + + Minimize to tray + Keep Spotube running in the system tray when the window is minimized. + Discord rich presence + Show your current playback status in Discord on desktop. + + Automatically check for updates + Look for new Spotube releases in the background. + + Manage Plugins + Manage your plugins + Default %1$s Plugin + Metadata + Audio + Lyrics + Scrobble + No plugin selected + Change + Select + Selected + Clear selection + No plugins available + %1$s plugin + Clear + + Configure + Plugin Manager + Please enter a URL + URL must start with http:// or https:// + Failed to download plugin + No plugins installed yet + Paste a download URL or pick a file above. + No metadata plugin selected + Spotube needs a metadata plugin to browse, search and show your music. Install one to get started. + You already have plugins installed, but none is set as your metadata source. Pick one to start browsing. + Discover a metadata plugin + Review its permissions and install it + Select it as your metadata source + Install a plugin + Choose a plugin + Play local music + Your local tracks and downloads work without a plugin. + + %1$d plugin installed + %1$d plugins installed + + Add a plugin from a link or a file on your device. + Pick a .smplug file from your device + or + Plugin details + Install a Plugin + https://example.com/plugin.smplug + Download + Install from file (.smplug) + Active + v%1$s + Built-in + Remove plugin + Login + Logout + Download from URL + Install from file + Install + + • %1$s + Permissions requested + This plugin requires no special permissions. + Installed vs supplied + Installed + Supplied + API %1$s → %2$s + Persistent Storage + Read and write data to disk + Network Requests + Send and receive data over the internet + WebView + Display web content inside the app + Group Jam + MQTT Broker + Broker host + %1$s:%2$d + Broker port + Use TLS + Username (optional) + Password (optional) + Client ID prefix + Used to identify this Spotube instance + Encrypt the broker connection + Check the current broker settings before starting a room. + Test connection + Testing… + Connected in %1$d ms + Failed: %1$s + Placeholder broker — configure your own to self-host + Connection + Credentials + Identity + Diagnostics + + + Group Jam + Create + Join + Create a room, then share its code with your friends. + Create room + Join room + Connecting… + Room code + ABC123 + Broker: %1$s + Set up a broker in Settings to host or join a room. + Broker settings + Connected + Reconnecting… + Copy code + Copied! + Share + Participants (%1$d) + Host + Listener + Waiting for listeners… + Remove + Ban + Manage + Leave room + Couldn’t connect + Dismiss + Failed to create jam session + Failed to join jam session + Enter a six-character room code using letters and digits. + Join my Spotube Jam with code: %1$s + + Go to album + Sleep timer + Track details + Play + Pause + Nothing playing + Add to queue + + Ignore + Update + Spotube %1$s is available + See what’s new in this release on the Spotube downloads page. + %1$s %2$s is available + Currently installed: %1$s + See what’s new in this update from the plugin’s page. + + Skip + Next + Back + Get Started + Spotube + Music, without the strings. + An open-source player for a world of music.\nYours to explore. Yours to make your own. + Keep this project alive. + Spotube is built by people, not a subscription business. If it becomes part of your everyday life, a little support helps us give it the care it deserves.\n\nThank you for making room for independent software. + Start with a catalog plugin to find songs and playlists. Choose an audio plugin to play them. Lyrics and listening history are optional. Discover a plugin below, review its permissions, install it, then select it here. + Discover plugins + Your sources + Try again + More plugins + No new plugins found. You can also install one from a link or a .smplug file above. + Plugin discovery is unavailable right now. Try again, or install from a link or file. + Welcome + Support + Plugins + Guide + + How to use Spotube + Four quick steps and you’re listening. + Sign in + Connect your Spotify account, or pick your own catalog plugin. + Search and play + Find any song, album, or artist, then press play. + Listen offline + Download tracks to keep playing without a connection. + Follow the lyrics + Open synced lyrics from the player while a track plays. + + + Your music. Your sources. Your rules. + A free, open-source music player built by a community that simply loves music. + Open source + No ads + No telemetry + Plugin powered + Let’s Go + + + Supporters keep us going + Built with passion. Kept alive by you. + Spotube is 100% free, libre, and open source — no ads, no subscriptions, no tracking. Every feature exists because people like you chose to help keep the code flowing. + Patreon + Monthly Sponsor + Support recurring development and infrastructure + Buy Me a Coffee + One-Time Coffee + Fuel a late-night coding session with a quick tip + Open Collective + Community Fund + Transparent, community-governed financial backing + Thank you from the bottom of our hearts for believing in open music. + Supporting is completely optional — Spotube stays free either way. + Sponsor + Open %1$s + + + Support Spotube + Support Spotube + Spotube is free, libre and open source — no ads, no subscriptions, no tracking. If it brings you joy, consider supporting the project. + You can turn these reminders off in Settings → Support. + Maybe later + Support reminders + Show a gentle reminder to support the project every 7 days. + + + Modular Architecture + Choose where your music comes from. + Spotube separates catalog discovery, audio streaming, and synced lyrics. Configure your engines below or switch them anytime. + Metadata Engine + Powers music discovery, search, track listings, and playlists. + Audio Engine + Fetches and decodes the audio streams that play. + Lyrics Engine + Streams time-synced karaoke lyrics in real time. + Scrobble Engine + Records listening history to Last.fm or ListenBrainz. + Active + Built-in + Ready + Optional + Select an engine + Installed Engines + Set as default + NewPipe YouTube and LRCLib Lyrics are built-in and ready to stream out of the box. + Manage or add new community plugins anytime in Settings → Plugins. + + + Retro-Modern Architecture + Pure Hi-Fi, zero middlemen + Here’s how Spotube orchestrates decentralized playback directly on your machine. + DIRECT PEER-TO-PEER • 256 KBPS OPUS + Blue hour + Spotube · player demonstration + A little music for the moments in between. + 1. Decentralized Discovery + Queries go straight to your chosen catalog plugin. Spotube never profiles your taste. + 2. Direct Stream Resolution + Audio streams directly from content servers to your machine. No proxy hops or remote data harvesting. + 3. Local DSP & Playback + Decoded and cached locally with equalizer controls and optional offline download. + 4. Synced Lyrics & Jam + Karaoke lyrics track the playback timestamp, with LAN remote control and Group Jam. + No middleman servers. Playback happens right on your device — private by design. + + + Choose a device + %1$s: \"%2$s\" + Play + Add to queue + Play next + This Device + On this device + Remote Device + Remote Device (%1$s) + On the connected device + + + Connection failed + Connection lost + The other device declined the request, or has remote control turned off. + Send failed + Discovery failed: %1$s + Failed to connect: %1$s + + + Devices + Refresh + Disconnect + Connecting... + Searching for devices on the network... + No devices found + Make sure the other device has \"Allow remote control\" enabled in settings. + Unknown Device + Resolving... + Connected + Available devices + This device + Connected + Open remote + Search again + Still looking for more devices... + + + Remote Control + Disconnected + Connection error: %1$s + Unknown Track + Unknown Artist + Volume + Remove from queue + More options + Playing on %1$s + If asked, allow this device on the other Spotube first. + You\'re no longer controlling the other device. Pick it again to reconnect. + Back to devices + Nothing playing + Start something on the other device, or pick a track from its queue. + + + Play Where? + You have an active jam session. Choose where to play this collection. + Play here + Suggest to Jam + + + No jam broker configured + Broker host is empty + Connection failed + Connection lost + + + Broker + anonymous + + + Added to the jam queue + + Added %1$d to the jam queue + Added %1$d to the jam queue + + + + Remote Control Request + \"%1$s\" wants to control playback on this device. + Deny + Allow + Allow Always + + + Search results + All + Tracks + Users + No search types available + Start typing to search + Start typing to search tracks + No results found + No results + See all + Searching + Loading tracks... + Loading albums... + Loading artists... + Loading playlists... + Loading users... + No albums found + No artists found + No playlists found + No users found + Digging through the crates... + Searching the cosmos... + Warming up the speakers... + Dusting off the vinyl... + Consulting the music gods... + Tuning the antennas... + Flipping through the records... + Failed to load search capabilities + Failed to search + Failed to search tracks + Failed to search albums + Failed to search artists + Failed to search playlists + Failed to search users + + + Browse + + + Synced + Plain + No synced lyrics available + No plain lyrics available + + + %1$s in %2$s + %1$d kbps + %1$s kHz • %2$d ch + No metadata plugin selected + + + Back + Retry + Close dialog + Loading... + Unknown + Unknown error + + + User + Playlist + + + Sign in to continue + This part of Spotube needs you to be signed in with your music provider. Sign in once and everything will show up here. + %1$s needs you to sign in before it can show this. Sign in once and everything will show up here. + Sign in + + + We\'re Truly Sorry + Something isn\'t working right now. Your experience matters to us, and we\'re doing our best to fix this. Please try again or check the error details below. + Error Details + Copy to clipboard + View full log + Full Error Log + + + Shuffle + Shuffle play + Follow + Unfollow + Edit playlist + By %1$s + + + Track options + Add to Jam + Start radio + Play next + Move to next + Remove from queue + Remove from favorites + Save as favorite + Add to playlist + Download + Share + + + Original + Title + Artist + Album + Duration + Sort + No tracks found + Filter + Bulk actions + Download All + Download %1$d + Add All to Queue + Add %1$d to Queue + Play All Next + Play %1$d Next + Add All to Playlist + Add %1$d to Playlist + Add All to Jam + Add %1$d to Jam + +%1$d more + + + No URL to load + Go back to browser history + Go forward to browser history + + + Filter blacklist... + No blacklisted items + No blacklisted items match \'%1$s\' + Tracks + Track + Add to blacklist + Remove from blacklist + + + No track metadata + Could not resolve stream URL + Could not resolve stream URL. Try playing the track first. + The server refused the download (HTTP %1$d) + The server sent an incomplete download. Try again. + Only a video is available for this track, so it wasn\'t downloaded + + + Saved Tracks + You + + + Following + Top Tracks + Play top tracks + Add top tracks to queue + Related Artists + Featured Playlists + Failed to load artist overview + Followers unavailable + %1$sK followers + %1$sM followers + %1$sB followers + + %1$d follower + %1$d followers + + + + Like + Unlike + No-op track title + No-op artists + Unknown Album + Seek + Volume + Enable shuffle + Disable shuffle + Previous + Next + Play or pause + Loop mode off + Loop mode one + Loop mode all + Mute + Unmute + More options + Player options + Collapse player + Expand lyrics + Now Playing + Lyrics + Alternative Source + Alternative source + Download + Downloaded + Downloading... + Already downloaded + Retrying download... + Downloading %1$s + Playback will pause in %1$s. + Pause playback after the selected number of minutes. + Minutes + Choose between 1 and 600 minutes + Cancel timer + Start + The playback URL was resolved with different streaming settings. The highlighted stream matches your current preference; the active URL refreshes on the next stream request. + Matched audio source + Loading matched source… + A matched remote source is not needed while playing the local cached file. + No matched source is available. + No matched audio source is cached for this track. + Stream details are not cached yet. Play the track first to load them. + Failed to load track details. + Available streams + This track is being served from the local cache. Stream variants are not used for this playback. + Loading streams… + No streams are available for this source. + Currently selected stream + Matches your streaming format & quality preference + Active playback URL + Served from local cache + Metadata track + Title + ID + Album + Confidence + External URI + Album ID + Album type + Album release date + Album track count + Album genres + Duration + Track number + Disc number + Explicit + Popularity + ISRC + Thumbnail URLs + + + Toggle Sidebar + + + Alternative Sources + No track currently playing + Searching for alternative sources... + No alternative sources found + Confidence: %1$d% + Active source + + + Kick + Remove them from the session + Kick and prevent them from rejoining + Remove suggestions + Remove every track they added to the queue + Reorder + Remove from queue + %1$s • Added by %2$s + + + Unknown + Loading playlist... + Add Tracks + Add Tracks to Playlist + Search tracks... + Search + No tracks found + Search for tracks to add + Done + Add + Added + + + Loading album... + + + Add to Your Playlist + Search + Search your playlists... + Loading your playlists... + You don\'t own any playlists yet.\nCreate one to add tracks. + Create Playlist + Edit Playlist + Create + Name + Playlist name + Description + Description (optional) + Public + Anyone can see this playlist + Collaborative + Others can add tracks to this playlist + Cover Image + Playlist cover + No image + Change Image + Pick Image + + + Install + Replace + plugin.json not found in the zip file + manifest.zipline.json not found in the zip file + Invalid plugin.json format: %1$s + Failed to read plugin: %1$s + Failed to install plugin: %1$s + A newer version of %1$s is available. Confirm the update before installing. + %1$s is already installed. Confirm replacing it before installing. + Built-in plugins are already included and can\'t be added again. + Built-in plugins can\'t be removed. + %1$s declares an invalid apiVersion (%2$s). Expected semantic versioning compatible with %3$s. + Spotube plugin API version %1$s is invalid. + %1$s targets plugin API %2$s, but Spotube supports breaking plugin API %3$s. Install a plugin built for the same breaking API version. + + + Plugin API not compatible + Install plugin? + %1$s will be added to your installed plugins. + Update plugin? + An older version is installed (%1$s). Update to %2$s? + Replace installed plugin? + %1$s uses an invalid semantic version (%2$s) and can\'t be compared as an update. Replace the installed plugin anyway? + The installed version (%1$s) can\'t be compared using semantic versioning. Replace it with %2$s? + Version %1$s is already installed. Replace the existing plugin with the supplied copy? + Installed version %1$s is newer than the supplied version %2$s. Replace it anyway? + Replace the installed plugin with the supplied copy? + + + %1$s: + Version + API Version + Author + License + Capabilities + Abilities + Repository + Contact + Report Bugs + Support %1$s + + + Official + Verified + GitHub stars + GitHub repository URL + github.com + + + No releases found + Select Release + Select a release + Pre-release + Release Notes + + + Home + Search + On Device + Connect + Devices + + Hosting · %1$d listener + Hosting · %1$d listeners + + + In a Jam · %1$d listener + In a Jam · %1$d listeners + + + + Hide Window + Show Window + Next Track + Previous Track + Shuffle + Loop + Loop: OFF + Loop: ONE + Loop: ALL + Volume: %1$d% + Volume + + Volume - + Mute + Unmute + Like Track + Unlike Track + Exit + + + Minimize + Maximize + Restore + Close + + + Playback + Media playback controls + + + Browse + Now Playing + Featured + Saved Tracks + Top Tracks + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt new file mode 100644 index 00000000..37a15a6f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt @@ -0,0 +1,274 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.ui.NavDisplay +import org.spotube.spotube.core.DebugConfig +import org.spotube.spotube.core.i18n.AppLocaleProvider +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.navigation.TOP_LEVEL_ROUTES +import org.spotube.spotube.core.navigation.navPopTransitionSpec +import org.spotube.spotube.core.navigation.navPredictivePopTransitionSpec +import org.spotube.spotube.core.navigation.navPushTransitionSpec +import org.spotube.spotube.core.navigation.rememberNavigationState +import org.spotube.spotube.core.navigation.toEntries +import org.spotube.spotube.core.ui.component.LocalSharedTransitionScope +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.library.LibraryTab +import org.spotube.spotube.modules.settings.SettingsRepository +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.shell.AppShell +import org.spotube.spotube.modules.support.LocalSupportPrompt +import org.spotube.spotube.modules.support.SupportDialog +import org.spotube.spotube.modules.support.SupportReminderIntervalMillis +import org.spotube.spotube.modules.update.AppUpdateDialog +import org.spotube.spotube.modules.update.AppUpdateService +import org.spotube.spotube.modules.update.PluginUpdateDialog +import org.spotube.spotube.modules.update.PluginUpdateService +import org.spotube.spotube.modules.webview.WebViewScreen +import org.spotube.spotube.modules.welcome.WelcomeRepository +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxHome +import org.spotube.spotube.resources.iconsax.IconsaxMirrorScreenRegular +import org.spotube.spotube.resources.iconsax.IconsaxMusicDashboard +import org.spotube.spotube.resources.iconsax.IconsaxMusicLibrary +import org.spotube.spotube.resources.iconsax.IconsaxSearch +import org.spotube.spotube.resources.iconsax.IconsaxSetting2 +import org.spotube.spotube.resources.iconsax.IconsaxSoundTwotone +import org.spotube.spotube.resources.iconsax.User +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlin.time.Clock +import org.koin.compose.koinInject +import org.koin.compose.navigation3.koinEntryProvider +import org.koin.core.annotation.KoinExperimentalAPI +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_screen_title +import spotube.composeapp.generated.resources.library_tab_albums +import spotube.composeapp.generated.resources.library_tab_artists +import spotube.composeapp.generated.resources.library_tab_downloads +import spotube.composeapp.generated.resources.library_tab_local_tracks +import spotube.composeapp.generated.resources.library_tab_playlists +import spotube.composeapp.generated.resources.library_toolbar_compact_title +import spotube.composeapp.generated.resources.nav_devices +import spotube.composeapp.generated.resources.nav_group_connect +import spotube.composeapp.generated.resources.nav_group_on_device +import spotube.composeapp.generated.resources.nav_home +import spotube.composeapp.generated.resources.nav_search +import spotube.composeapp.generated.resources.settings_screen_title + +interface NavigationItem { + data class Tab( + val title: StringResource, val icon: ImageVector, val route: Routes, + val data: T? = null + ) : NavigationItem + + data class Group( + val title: StringResource, val items: List> + ) : NavigationItem +} + +private val tabs = listOf( + NavigationItem.Tab(Res.string.nav_home, Iconsax.IconsaxHome, Routes.Home), + NavigationItem.Tab(Res.string.nav_search, Iconsax.IconsaxSearch, Routes.Search) +) + +val sidebarTabs = tabs + listOf( + NavigationItem.Group( + Res.string.library_toolbar_compact_title, + items = listOf( + NavigationItem.Tab( + Res.string.library_tab_playlists, + Iconsax.IconsaxMusicDashboard, + Routes.Library, + LibraryTab.Playlists + ), + NavigationItem.Tab(Res.string.library_tab_albums, Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums), + NavigationItem.Tab(Res.string.library_tab_artists, Iconsax.User, Routes.Library, LibraryTab.Artists) + ) + ), + NavigationItem.Group( + Res.string.nav_group_on_device, + items = listOf( + NavigationItem.Tab( + Res.string.library_tab_local_tracks, + Iconsax.IconsaxMusicLibrary, + Routes.Library, + LibraryTab.LocalTracks + ), + NavigationItem.Tab( + Res.string.library_tab_downloads, + Iconsax.IconsaxDirectboxReceive, + Routes.Library, + LibraryTab.Downloads + ) + ) + ), + NavigationItem.Group( + Res.string.nav_group_connect, + items = listOf( + NavigationItem.Tab(Res.string.nav_devices, Iconsax.IconsaxMirrorScreenRegular, Routes.Devices), + NavigationItem.Tab(Res.string.jam_screen_title, Iconsax.IconsaxSoundTwotone, Routes.Jam) + ) + ) +) +val bottombarTabs = tabs + listOf( + NavigationItem.Tab(Res.string.library_toolbar_compact_title, Iconsax.IconsaxMusicLibrary, Routes.Library), + NavigationItem.Tab(Res.string.settings_screen_title, Iconsax.IconsaxSetting2, Routes.Settings) +) + +@OptIn(KoinExperimentalAPI::class) +@Composable +fun App( + content: @Composable () -> Unit = {} +) { + val settingsRepository: SettingsRepository = koinInject() + val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings()) + val appUpdateService: AppUpdateService = koinInject() + val pluginUpdateService: PluginUpdateService = koinInject() + val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle() + val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle() + + val welcomeRepository: WelcomeRepository = koinInject() + val showOnboarding by welcomeRepository.shouldShowOnboarding.collectAsStateWithLifecycle() + + var showSupportDialog by remember { mutableStateOf(false) } + var supportDialogIsReminder by remember { mutableStateOf(false) } + + // Nudge the user to support the project at most once every seven days, unless they opted + // out in Settings. The first eligible launch after onboarding only records the baseline, so + // the prompt never appears right after the onboarding donation page. + LaunchedEffect(showOnboarding) { + if (showOnboarding) return@LaunchedEffect + // Read the persisted value rather than the collected state, whose initial value is the + // default (0) until DataStore has emitted once. + val current = settingsRepository.getCurrentSettings() + val now = Clock.System.now().toEpochMilliseconds() + // Development override: show the reminder on every launch, regardless of timing or the + // "Support reminders" toggle. + if (DebugConfig.FORCE_SUPPORT_REMINDER) { + supportDialogIsReminder = true + showSupportDialog = true + settingsRepository.updateSettings( + current.copy(lastSupportReminderEpochMs = now)) + return@LaunchedEffect + } + if (!current.supportRemindersEnabled) return@LaunchedEffect + val last = current.lastSupportReminderEpochMs + if (last == 0L) { + settingsRepository.updateSettings( + current.copy(lastSupportReminderEpochMs = now)) + } else if (now - last >= SupportReminderIntervalMillis) { + supportDialogIsReminder = true + showSupportDialog = true + settingsRepository.updateSettings( + current.copy(lastSupportReminderEpochMs = now)) + } + } + + // The app always renders the shell + NavDisplay; onboarding is just the start destination. + // When it is completed the destination flips to Home and the navigation state is rebuilt. + val startRoute = if (showOnboarding) Routes.Welcome else Routes.Home + + val navigationState = rememberNavigationState( + startRoute = startRoute, topLevelRoutes = TOP_LEVEL_ROUTES + ) + val navigator = remember(navigationState) { + Navigator(navigationState) + } + + // Below the navigation state, so switching languages keeps the current screen. + AppLocaleProvider(userSettings.language) { + SpotubeTheme(settings = userSettings) { + CompositionLocalProvider(LocalSupportPrompt provides { + supportDialogIsReminder = false + showSupportDialog = true + }) { + Box(modifier = Modifier.fillMaxSize()) { + val currentRoute = + navigationState.backStacks[navigationState.topLevelRoute]?.last() + + Box(Modifier.fillMaxSize()) { + AppShell(navigator, navigationState) { + SharedTransitionLayout { + CompositionLocalProvider( + LocalSharedTransitionScope provides this@SharedTransitionLayout + ) { + Column { + NavDisplay( + modifier = Modifier.fillMaxSize(), + onBack = navigator::pop, + entries = navigationState.toEntries(koinEntryProvider()), + transitionSpec = navPushTransitionSpec, + popTransitionSpec = navPopTransitionSpec, + predictivePopTransitionSpec = navPredictivePopTransitionSpec + ) + } + } + } + } + when (currentRoute) { + Routes.WebView -> WebViewScreen(koinInject()) + else -> {} + } + } + } + content() + availableAppUpdate?.let { update -> + AppUpdateDialog( + update = update, + onDismiss = appUpdateService::dismissUpdate, + onIgnore = appUpdateService::ignoreUpdate, + onUpdate = { + runCatching { openUrlInBrowser("https://spotube.org/downloads/") } + appUpdateService.dismissUpdate() + }) + } + availablePluginUpdate?.let { update -> + PluginUpdateDialog( + update = update, + onDismiss = pluginUpdateService::dismissUpdate, + onIgnore = pluginUpdateService::ignoreUpdate, + onUpdate = pluginUpdateService::installUpdate) + } + SupportDialog( + visible = showSupportDialog, + onDismissRequest = { showSupportDialog = false }, + isReminder = supportDialogIsReminder) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/Platform.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/Platform.kt new file mode 100644 index 00000000..5207160f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/Platform.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube + +enum class PlatformType { + Android, IOS, Windows, Linux, MacOS, Unknown +} + +interface Platform { + val name: String + val type: PlatformType +} + +expect fun getPlatform(): Platform + +expect fun openUrlInBrowser(url: String) + +fun Platform.isDesktop(): Boolean { + return type == PlatformType.Windows || + type == PlatformType.Linux || + type == PlatformType.MacOS +} + +fun Platform.isMobile(): Boolean { + return type == PlatformType.Android || + type == PlatformType.IOS +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt new file mode 100644 index 00000000..b1e5941d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core + +/** + * Development-only switches. These are intentionally kept out of [org.spotube.spotube.modules.settings.UserSettings] + * so they can never leak into user-facing configuration. + */ +object DebugConfig { + /** + * Flip to `true` while developing to always open the Welcome/Onboarding screen, even after + * it has been completed (so you don't have to wipe app data to see it again). It still + * dismisses for the session when you finish or skip it. + * + * Keep it `false` when committing. + */ + const val FORCE_SHOW_ONBOARDING: Boolean = false + + /** + * Flip to `true` while developing to show the support reminder dialog on the next eligible + * app launch, ignoring both the 7-day interval and the "Support reminders" opt-out toggle. + * It still waits until onboarding is dismissed, and only fires once per launch. + * + * Keep it `false` when committing. + */ + const val FORCE_SUPPORT_REMINDER: Boolean = false +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.kt new file mode 100644 index 00000000..7e67b35d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.audioplayer + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlin.time.Duration + +interface AudioPlayerInterface { + // // Playback + suspend fun play() + suspend fun pause() + suspend fun stop() + suspend fun seekTo(position: Duration) + suspend fun loop(state: LoopState) + suspend fun shuffle(enabled: Boolean) + + // Playlist management + suspend fun load(playlist: List, autoPlay: Boolean, startPosition: Int) + suspend fun addMediaItem(mediaItem: MediaItem) + suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) + suspend fun removeMediaItem(mediaItem: MediaItem) + suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) + suspend fun skipToNext() + suspend fun skipToPrevious() + suspend fun jumpTo(index: Int) + + // State as flows StateFlow + + val playerStateFlow : StateFlow + val currentMediaItemFlow : StateFlow + val playlistFlow : StateFlow> + val durationFlow : StateFlow + val positionFlow : StateFlow + val bufferingPositionFlow : StateFlow + val loopStateFlow : StateFlow + val shuffleModeFlow : StateFlow + val playbackSpeedFlow : StateFlow + val volumeFlow : StateFlow + val completionFlow : Flow + val errorFlow : Flow + + suspend fun setVolume(volume: Float) + suspend fun setPlaybackSpeed(speed: Float) + + fun isDisposed(): Boolean + fun dispose() // Clean up resources when done. The player should not be used after this is called. +} + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +expect class AudioPlayer(context: Any) : AudioPlayerInterface { + val context: Any // Optional context for platform-specific implementations (e.g., Android Context) + override suspend fun play() + override suspend fun pause() + override suspend fun stop() + override suspend fun seekTo(position: Duration) + override suspend fun loop(state: LoopState) + override suspend fun shuffle(enabled: Boolean) + override suspend fun load( + playlist: List, + autoPlay: Boolean, + startPosition: Int + ) + + override suspend fun addMediaItem(mediaItem: MediaItem) + override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) + override suspend fun removeMediaItem(mediaItem: MediaItem) + override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) + override suspend fun skipToNext() + override suspend fun skipToPrevious() + override suspend fun jumpTo(index: Int) + override val playerStateFlow: StateFlow + override val currentMediaItemFlow: StateFlow + override val playlistFlow: StateFlow> + override val durationFlow: StateFlow + override val positionFlow: StateFlow + override val bufferingPositionFlow: StateFlow + override val loopStateFlow: StateFlow + override val shuffleModeFlow: StateFlow + override val playbackSpeedFlow: StateFlow + override val volumeFlow: StateFlow + override val completionFlow: Flow + override val errorFlow: Flow + override suspend fun setVolume(volume: Float) + override suspend fun setPlaybackSpeed(speed: Float) + override fun isDisposed(): Boolean + override fun dispose() +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt new file mode 100644 index 00000000..8840f8b3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.audioplayer + +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Duration + +data class MediaItem( + val title: String, + val artist: String, + val album: String, + val duration: Duration, + val coverURL: String, + val url: String, + val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, + val coverBytes: ByteArray? = null) + +enum class LoopState { + NONE, ONE, ALL; + + fun next(): LoopState { + return when (this) { + NONE -> ONE + ONE -> ALL + ALL -> NONE + } + } +} + +enum class PlayerState { + IDLE, BUFFERING, READY, PLAYING, PAUSED, COMPLETED +} + +@Serializable +sealed interface QueueEntry { + val url: String + val addedBy: String + + /** Remote artwork URL, for consumers that can only take a link (remote control, media session). */ + val artworkUrl: String? + + /** + * Single source of truth for a queue entry's artwork, in a form Coil can load directly: + * a URL `String` for streaming tracks, embedded cover `ByteArray` for local ones. + * Every UI surface (players, queue, lists) must use this instead of reading thumbnails itself. + */ + val artworkModel: Any? + get() = (this as? LocalTrack)?.coverBytes?.takeIf { it.isNotEmpty() } ?: artworkUrl + + @Serializable + @SerialName("streaming") + data class StreamingTrack( + val track: MetadataTrack, + override val url: String, + val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, + override val addedBy: String = "") : QueueEntry { + override val artworkUrl: String? + get() = track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url + } + + @Serializable + @SerialName("local") + data class LocalTrack( + val name: String, + val artists: List, + val duration: Long, + val album: String?, + val coverBytes: ByteArray?, + override val url: String, + override val addedBy: String = "") : QueueEntry { + override val artworkUrl: String? get() = null + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as LocalTrack + + if (duration != other.duration) return false + if (name != other.name) return false + if (artists != other.artists) return false + if (album != other.album) return false + if (!coverBytes.contentEquals(other.coverBytes)) return false + if (url != other.url) return false + if (addedBy != other.addedBy) return false + + return true + } + + override fun hashCode(): Int { + var result = duration.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + artists.hashCode() + result = 31 * result + (album?.hashCode() ?: 0) + result = 31 * result + (coverBytes?.contentHashCode() ?: 0) + result = 31 * result + url.hashCode() + result = 31 * result + addedBy.hashCode() + return result + } + } +} + +@Serializable +sealed interface QueueCollectionEntry { + val id: String + + @Serializable + @SerialName("playlist") + data class Playlist( + override val id: String) : QueueCollectionEntry + + @Serializable + @SerialName("album") + data class Album( + override val id: String) : QueueCollectionEntry + + @Serializable + @SerialName("saved_tracks") + data object SavedTracks : QueueCollectionEntry { + override val id: String = "saved_tracks" + } +} + +@Serializable +data class PersistedQueueState( + val entries: List, + val currentIndex: Int, + val currentCollectionEntry: QueueCollectionEntry? = null, + val collectionHistory: List = emptyList()) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueue.kt new file mode 100644 index 00000000..51d47129 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueue.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.audioplayer + +import kotlinx.coroutines.flow.StateFlow + +interface AudioPlayerQueue { + val queueFlow: StateFlow> + val currentQueueEntryFlow: StateFlow + val currentCollectionEntryFlow: StateFlow + val collectionHistoryFlow: StateFlow> + + suspend fun load( + entries: List, + autoPlay: Boolean = true, + startPosition: Int = 0, + collectionEntry: QueueCollectionEntry? = null) + suspend fun addToQueue(entry: QueueEntry) + suspend fun addAllToQueue(entries: List, collectionEntry: QueueCollectionEntry? = null) + suspend fun addAllAfterCurrent(entries: List) + suspend fun removeFromQueue(entry: QueueEntry) + suspend fun removeFromQueueByMediaUrl(mediaUrl: String) + suspend fun move(fromIndex: Int, toIndex: Int) + suspend fun jumpTo(index: Int, autoPlay: Boolean = true) + suspend fun reloadCurrent() + suspend fun clear() + suspend fun getQueue(): List + suspend fun getCurrentQueueEntry(): QueueEntry? + suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? + suspend fun getCollectionHistory(): List + + fun isPlaylistPlaying(playlistId: String? = null): Boolean { + val entry = currentCollectionEntryFlow.value as? QueueCollectionEntry.Playlist ?: return false + return playlistId == null || entry.id == playlistId + } + + fun isAlbumPlaying(albumId: String? = null): Boolean { + val entry = currentCollectionEntryFlow.value as? QueueCollectionEntry.Album ?: return false + return albumId == null || entry.id == albumId + } + + fun isSavedTracksPlaying(): Boolean { + return currentCollectionEntryFlow.value is QueueCollectionEntry.SavedTracks + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueRepository.kt new file mode 100644 index 00000000..2b03d8c8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueRepository.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.audioplayer + +import androidx.datastore.preferences.core.edit +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.db.DatabaseKeys +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json + +interface QueueStateRepository { + suspend fun getPersistedState(): PersistedQueueState? + suspend fun saveState(state: PersistedQueueState) + suspend fun clearState() +} + +class AudioPlayerQueueRepository(private val database: Database) : QueueStateRepository { + private val json = Json { + ignoreUnknownKeys = true + } + + override suspend fun getPersistedState(): PersistedQueueState? { + return database.audioPlayerQueueDataStore.data.map { preferences -> + val payload = preferences[DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY] + payload?.let { + runCatching { json.decodeFromString(it) } + .getOrNull() + } + }.first() + } + + override suspend fun saveState(state: PersistedQueueState) { + database.audioPlayerQueueDataStore.edit { preferences -> + preferences[DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY] = json.encodeToString(state) + } + } + + override suspend fun clearState() { + database.audioPlayerQueueDataStore.edit { preferences -> + preferences.remove(DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt new file mode 100644 index 00000000..fb283055 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -0,0 +1,504 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.audioplayer + +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.plugin.PluginProvider +import org.spotube.spotube.modules.settings.SettingsProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import kotlin.random.Random +import kotlin.time.Duration.Companion.milliseconds + +class DeviceAudioPlayerQueue( + private val audioPlayer: AudioPlayerInterface, + private val settingsProvider: SettingsProvider, + private val repository: QueueStateRepository, + private val pluginProvider: PluginProvider, + private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent { + + private val logger by injectLogger() + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val entryByMediaUrl = MutableStateFlow>(emptyMap()) + private val currentCollectionEntryState = MutableStateFlow(null) + private val collectionHistoryState = MutableStateFlow>(emptyList()) + + private var isRestoringPersistedState = true + + override val queueFlow: StateFlow> = combine( + audioPlayer.playlistFlow, + entryByMediaUrl) { playlist, knownEntries -> + playlist.map { mediaItem -> + knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry() + } + }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + override val currentQueueEntryFlow: StateFlow = combine( + audioPlayer.currentMediaItemFlow, + entryByMediaUrl) { mediaItem, knownEntries -> + mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() } + }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + + override val currentCollectionEntryFlow: StateFlow = + currentCollectionEntryState.asStateFlow() + + override val collectionHistoryFlow: StateFlow> = + collectionHistoryState.asStateFlow() + + init { + logger.d { "Initializing DeviceAudioPlayerQueue" } + scope.launch { + logger.d { "Starting restoration of persisted queue state" } + restorePersistedState() + isRestoringPersistedState = false + logger.d { "Persisted queue state restoration completed" } + } + + scope.launch { + audioPlayer.playlistFlow.collect { + logger.d { "Playlist changed, persisting queue state" } + persistQueueStateIfReady() + } + } + + scope.launch { + audioPlayer.currentMediaItemFlow.collect { + logger.d { "Current media item changed, persisting queue state" } + persistQueueStateIfReady() + } + } + + scope.launch { + combine( + audioPlayer.currentMediaItemFlow, + audioPlayer.playlistFlow, + audioPlayer.loopStateFlow) { currentMedia, playlist, loopState -> + Triple(currentMedia, playlist, loopState) + }.collect { (currentMedia, playlist, loopState) -> + if (currentMedia == null || playlist.isEmpty()) return@collect + if (loopState == LoopState.ALL) return@collect + + val currentIndex = playlist.indexOfFirst { it.url == currentMedia.url } + if (currentIndex < 0) return@collect + + val tracksAfterCurrent = playlist.size - currentIndex - 1 + if (tracksAfterCurrent <= 0) { + handleQueueCompletion() + } + } + } + } + + override suspend fun load( + entries: List, + autoPlay: Boolean, + startPosition: Int, + collectionEntry: QueueCollectionEntry?) { + logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" } + val mediaItems = entries.mapNotNull { it.toMediaItem() } + if (mediaItems.isEmpty()) { + logger.w { "load: no valid media items after filtering blanks" } + } + val normalizedStartPosition = + if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex) + logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" } + entryByMediaUrl.value = mediaItems.associate { it.url to entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } + updateCollectionContext(collectionEntry) + audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition) + persistQueueStateIfReady() + logger.i { "Queue loaded successfully" } + } + + override suspend fun addToQueue(entry: QueueEntry) { + val mediaItem = entry.toMediaItem() ?: run { + logger.w { "addToQueue: skipping entry with blank url" } + return + } + logger.d { "Adding entry to queue: ${mediaItem.title}" } + entryByMediaUrl.update { it + (mediaItem.url to entry) } + audioPlayer.addMediaItem(mediaItem) + persistQueueStateIfReady() + logger.d { "Entry added successfully" } + } + + override suspend fun addAllToQueue(entries: List, collectionEntry: QueueCollectionEntry?) { + if (entries.isEmpty()) { + logger.d { "addAllToQueue called with empty list, skipping" } + return + } + + logger.i { "Adding ${entries.size} entries to queue" } + val mediaItems = entries.mapNotNull { it.toMediaItem() } + if (mediaItems.isEmpty()) { + logger.w { "addAllToQueue: all entries had blank urls, skipping" } + return + } + val validEntries = entries.zip(mediaItems).map { (entry) -> + entry + } + entryByMediaUrl.update { + it + mediaItems.zip(validEntries).associate { (mediaItem, entry) -> mediaItem.url to entry } + } + + updateCollectionContext(collectionEntry) + + mediaItems.forEach { mediaItem -> + audioPlayer.addMediaItem(mediaItem) + } + + persistQueueStateIfReady() + logger.d { "All entries added successfully" } + } + + override suspend fun addAllAfterCurrent(entries: List) { + if (entries.isEmpty()) { + logger.d { "addAllAfterCurrent called with empty list, skipping" } + return + } + + logger.i { "Adding ${entries.size} entries after current position" } + val mediaItems = entries.mapNotNull { it.toMediaItem() } + if (mediaItems.isEmpty()) { + logger.w { "addAllAfterCurrent: all entries had blank urls, skipping" } + return + } + val validEntries = entries.zip(mediaItems).map { (entry) -> entry } + entryByMediaUrl.update { + it + mediaItems.zip(validEntries).associate { (mediaItem, entry) -> mediaItem.url to entry } + } + + mediaItems.forEach { mediaItem -> + audioPlayer.insertMediaItemAtNextIndex(mediaItem) + } + + persistQueueStateIfReady() + logger.d { "All entries added after current successfully" } + } + + override suspend fun removeFromQueue(entry: QueueEntry) { + val mediaUrl = entry.toMediaItem()?.url ?: return + removeFromQueueByMediaUrl(mediaUrl) + } + + override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) { + val mediaItem = audioPlayer.playlistFlow.value.firstOrNull { it.url == mediaUrl } + if (mediaItem == null) { + logger.w { "Attempted to remove non-existent media item: $mediaUrl" } + return + } + logger.d { "Removing media item from queue: ${mediaItem.title}" } + audioPlayer.removeMediaItem(mediaItem) + entryByMediaUrl.update { it - mediaUrl } + persistQueueStateIfReady() + logger.d { "Media item removed successfully" } + } + + override suspend fun move(fromIndex: Int, toIndex: Int) { + logger.d { "Moving queue item from index $fromIndex to $toIndex" } + audioPlayer.moveMediaItem(fromIndex, toIndex) + persistQueueStateIfReady() + logger.d { "Queue item moved successfully" } + } + + override suspend fun jumpTo(index: Int, autoPlay: Boolean) { + val queueSize = audioPlayer.playlistFlow.value.size + if (queueSize == 0) { + logger.w { "jumpTo called with empty queue" } + return + } + + val normalizedIndex = index.coerceIn(0, queueSize - 1) + logger.d { "Jumping to queue index $index (normalized: $normalizedIndex), autoPlay=$autoPlay" } + audioPlayer.jumpTo(normalizedIndex) + if (autoPlay) { + audioPlayer.play() + } + persistQueueStateIfReady() + } + + override suspend fun reloadCurrent() { + val current = currentQueueEntryFlow.value ?: run { + logger.w { "reloadCurrent called with no current entry" } + return + } + val queue = queueFlow.value + + // Using separate current index entry as the current track's source url has changed so it won't match + val index = queue.indexOfFirst { entry -> + when (entry) { + is QueueEntry.StreamingTrack if current is QueueEntry.StreamingTrack -> + entry.track.id == current.track.id + + is QueueEntry.LocalTrack if current is QueueEntry.LocalTrack -> + entry.url == current.url && entry.name == current.name + + else -> false + } + } + if (index < 0) { + logger.w { "reloadCurrent: current entry not found in queue" } + return + } + logger.i { "Reloading current track at queue index $index" } + jumpTo(index, autoPlay = true) + } + + override suspend fun clear() { + logger.i { "Clearing queue and collection history" } + entryByMediaUrl.value = emptyMap() + currentCollectionEntryState.value = null + collectionHistoryState.value = emptyList() + audioPlayer.load(emptyList(), autoPlay = false, startPosition = 0) + repository.clearState() + logger.i { "Queue cleared successfully" } + } + + override suspend fun getQueue(): List { + val knownEntries = entryByMediaUrl.value + return audioPlayer.playlistFlow.value.map { mediaItem -> + knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry() + } + } + + override suspend fun getCurrentQueueEntry(): QueueEntry? { + val mediaItem = audioPlayer.currentMediaItemFlow.value ?: return null + return entryByMediaUrl.value[mediaItem.url] ?: mediaItem.toFallbackQueueEntry() + } + + override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? { + return currentCollectionEntryState.value + } + + override suspend fun getCollectionHistory(): List { + return collectionHistoryState.value + } + + private var isFetchingRecommendations = false + + private suspend fun handleQueueCompletion() { + if (isFetchingRecommendations) return + + val settings = settingsProvider.settingsState.value ?: return + if (!settings.enableEndlessPlayback) return + + val queue = queueFlow.value + val streamingTracks = queue.filterIsInstance() + if (streamingTracks.isEmpty()) return + + val seedTracks = if (streamingTracks.size <= 5) { + streamingTracks + } else { + val shuffled = streamingTracks.shuffled(Random) + shuffled.take(5) + } + + val seedTrackIds = seedTracks.map { it.track.id } + if (seedTrackIds.isEmpty()) return + + isFetchingRecommendations = true + logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" } + + val metadataService = pluginProvider.selectedMetadataPlugin.value ?: run { + logger.w { "Endless playback: no metadata plugin available" } + isFetchingRecommendations = false + return + } + + try { + val recommendations = metadataService.use { + metadataTrackAPI.recommendationsBasedOnTracks(seedTrackIds, limit = 20) + } + + if (recommendations.isEmpty()) { + logger.w { "Endless playback: no recommendations returned" } + isFetchingRecommendations = false + return + } + + val blacklistedTrackIds = blacklistRepository.blacklistedTracks.first().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.blacklistedArtists.first().map { it.id }.toSet() + + val filteredRecommendations = recommendations.filter { track -> + val trackBlacklisted = track.id in blacklistedTrackIds + val artistBlacklisted = track.artists.any { it.id in blacklistedArtistIds } + !trackBlacklisted && !artistBlacklisted + } + + if (filteredRecommendations.isEmpty()) { + logger.w { "Endless playback: all recommendations were blacklisted" } + isFetchingRecommendations = false + return + } + + val newEntries = filteredRecommendations.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + + logger.i { "Endless playback: adding ${newEntries.size} recommended tracks to queue" } + addAllToQueue(newEntries) + } catch (e: Exception) { + logger.e(e) { "Endless playback: failed to fetch recommendations" } + } finally { + isFetchingRecommendations = false + } + } + + private suspend fun restorePersistedState() { + val state = repository.getPersistedState() + if (state == null) { + logger.d { "No persisted state found" } + return + } + + logger.i { "Restoring persisted state with ${state.entries.size} entries, currentIndex=${state.currentIndex}" } + currentCollectionEntryState.value = state.currentCollectionEntry + collectionHistoryState.value = state.collectionHistory.ifEmpty { + state.currentCollectionEntry?.let(::listOf) ?: emptyList() + } + + if (state.entries.isEmpty()) { + logger.d { "Persisted state has no entries" } + return + } + + val mediaItems = state.entries.mapNotNull { it.toMediaItem() } + if (mediaItems.isEmpty()) { + logger.w { "restorePersistedState: all entries had blank urls" } + return + } + entryByMediaUrl.value = mediaItems.associate { it.url to state.entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } + val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex) + logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" } + audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition) + logger.i { "Persisted state restored successfully" } + } + + private suspend fun persistQueueStateIfReady() { + if (isRestoringPersistedState) { + logger.v { "Skipping persist: still restoring persisted state" } + return + } + + val queueEntries = getQueue() + if (queueEntries.isEmpty()) { + logger.d { "Persisting queue state: clearing state (queue is empty)" } + repository.clearState() + return + } + + val playlist = audioPlayer.playlistFlow.value + val currentMedia = audioPlayer.currentMediaItemFlow.value + val currentIndex = currentMedia?.let { current -> + playlist.indexOfFirst { it.url == current.url }.takeIf { it >= 0 } + } ?: 0 + val normalizedCurrentIndex = currentIndex.coerceIn(0, queueEntries.lastIndex) + + logger.d { "Persisting queue state: ${queueEntries.size} entries, currentIndex=$currentIndex (normalized: $normalizedCurrentIndex)" } + repository.saveState( + PersistedQueueState( + entries = queueEntries, + currentIndex = normalizedCurrentIndex, + currentCollectionEntry = currentCollectionEntryState.value, + collectionHistory = collectionHistoryState.value) + ) + } + + private fun updateCollectionContext(collectionEntry: QueueCollectionEntry?) { + if (collectionEntry != null) { + logger.d { "Updating collection context: ${collectionEntry.id}" } + } else { + logger.d { "Clearing collection context" } + } + + currentCollectionEntryState.value = collectionEntry + + if (collectionEntry == null) return + + collectionHistoryState.update { history -> + val updated = listOf(collectionEntry) + history.filterNot { it == collectionEntry } + logger.v { "Collection history updated, size now: ${updated.size}" } + updated + } + } + + private suspend fun QueueEntry.toMediaItem(): MediaItem? { + return when (this) { + is QueueEntry.StreamingTrack -> { + val resolvedUrl = url.ifBlank { buildStreamingUrl(track.id, protocol) } + if (resolvedUrl.isBlank()) return null + MediaItem( + title = track.title, + artist = track.artists.joinToString(", ") { artist -> artist.name }, + album = track.album?.title.orEmpty(), + duration = track.durationMs.milliseconds, + coverURL = artworkUrl.orEmpty(), + url = resolvedUrl, + protocol = protocol) + } + + is QueueEntry.LocalTrack -> { + if (url.isBlank()) return null + MediaItem( + title = name, + artist = artists.joinToString(", "), + album = album.orEmpty(), + duration = duration.milliseconds, + coverURL = "", + url = url, + coverBytes = coverBytes?.takeIf { it.isNotEmpty() }) + } + } + } + + private fun MediaItem.toFallbackQueueEntry(): QueueEntry { + return QueueEntry.LocalTrack( + name = title, + artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() }, + duration = duration.inWholeMilliseconds, + album = album.ifBlank { null }, + coverBytes = null, + url = url) + } + + private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String { + val port: Int = + settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first() + val baseUrl = "http://127.0.0.1:$port" + return when (protocol) { + StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId" + StreamProtocol.PROGRESSIVE -> "${baseUrl.trimEnd('/')}/stream/$trackId" + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt new file mode 100644 index 00000000..272aa8fe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.db + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import org.spotube.spotube.core.paths.Paths +import okio.FileSystem +import okio.Path.Companion.toPath +import okio.SYSTEM + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +class Database(val paths: Paths) { + /** + * True when Spotube had already written its own data before this process started. Captured + * synchronously at construction time — the earliest point, before any eager service can + * create a fresh store — so it reliably tells a brand-new install apart from an upgrade of + * an existing one (used to skip the first-run onboarding on upgrades). + */ + val isExistingInstall: Boolean = runCatching { + val dataDirPath = paths.getApplicationDataDirPath() + EXISTING_INSTALL_PROBE_FILES.any { fileName -> + FileSystem.SYSTEM.exists("$dataDirPath/$fileName".toPath()) + } + }.getOrDefault(false) + + val settingsDataStore: DataStore by lazy { + PreferenceDataStoreFactory.createWithPath( + produceFile = { "${paths.getApplicationDataDirPath()}/spotube.preferences_pb".toPath() } + ) + } + val pluginsDataStore: DataStore by lazy { + PreferenceDataStoreFactory.createWithPath( + produceFile = { "${paths.getApplicationDataDirPath()}/spotube_plugins.preferences_pb".toPath() } + ) + } + + val matchedTracksDataStore: DataStore by lazy { + PreferenceDataStoreFactory.createWithPath( + produceFile = { "${paths.getApplicationDataDirPath()}/spotube_matched_tracks.preferences_pb".toPath() } + ) + } + + val audioPlayerQueueDataStore: DataStore by lazy { + PreferenceDataStoreFactory.createWithPath( + produceFile = { "${paths.getApplicationDataDirPath()}/spotube_audio_player_queue.preferences_pb".toPath() } + ) + } + + val localMediaDataStore: DataStore by lazy { + PreferenceDataStoreFactory.createWithPath( + produceFile = { "${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath() } + ) + } + + val blacklistDataStore: DataStore by lazy { + PreferenceDataStoreFactory.createWithPath( + produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() } + ) + } + + val windowStateDataStore: DataStore by lazy { + PreferenceDataStoreFactory.createWithPath( + produceFile = { "${paths.getApplicationDataDirPath()}/spotube_window.preferences_pb".toPath() } + ) + } + + private companion object { + /** Preference files written by previous launches; their presence means an upgrade. */ + val EXISTING_INSTALL_PROBE_FILES = listOf( + "spotube.preferences_pb", + "spotube_plugins.preferences_pb", + "spotube_matched_tracks.preferences_pb", + "spotube_audio_player_queue.preferences_pb", + "spotube_local_media.preferences_pb", + "spotube_blacklist.preferences_pb", + "spotube_cookies.preferences_pb") + } +} + +object DatabaseKeys { + val PLUGINS_STATE_KEY = stringPreferencesKey("plugins_state") + val AUDIO_PLAYER_QUEUE_STATE_KEY = stringPreferencesKey("audio_player_queue_state") + val LOCAL_MEDIA_CACHE_STATE_KEY = stringPreferencesKey("local_media_cache_state") + val LOCAL_MEDIA_LAST_SCAN_AT_KEY = longPreferencesKey("local_media_last_scan_at") + val WINDOW_STATE_KEY = stringPreferencesKey("window_state") +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/deeplink/ExternalUriHandler.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/deeplink/ExternalUriHandler.kt new file mode 100644 index 00000000..c2ba2b1e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/deeplink/ExternalUriHandler.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.deeplink + +/** + * Cross-platform receiver for URIs handed to the app by the operating system + * (deep links). Platform entry points (Android activity intents, desktop command + * line / open-URI handler, iOS `onOpenURL`) call [onNewUri]; the main composable + * installs a [listener] once composition starts. + * + * Follows the Compose Multiplatform deep linking docs pattern: URIs arriving + * before a listener is installed are cached and delivered as soon as one is set. + */ +object ExternalUriHandler { + private var cached: String? = null + + var listener: ((uri: String) -> Unit)? = null + set(value) { + field = value + if (value != null) { + cached?.let(value::invoke) + cached = null + } + } + + fun onNewUri(uri: String) { + if (uri.isBlank()) return + val currentListener = listener + if (currentListener != null) { + currentListener(uri) + } else { + cached = uri + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/EnrichKoin.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/EnrichKoin.kt new file mode 100644 index 00000000..7ae4266c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/EnrichKoin.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.di + +import androidx.compose.runtime.Composable +import co.touchlab.kermit.Logger +import org.koin.compose.koinInject +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koin.core.parameter.parametersOf + +inline fun KoinComponent.injectLogger(): Lazy { + return inject { parametersOf(T::class.simpleName ?: "Unknown") } +} + +@Composable +inline fun rememberLogger(): Logger { + val tag = T::class.simpleName ?: "Unknown" + return koinInject { parametersOf(tag) } +} + +@Composable +fun rememberLogger(tag: String): Logger { + return koinInject { parametersOf(tag) } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt new file mode 100644 index 00000000..b0491bc4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.di + +import co.touchlab.kermit.Logger +import co.touchlab.kermit.koin.KermitKoinLogger +import org.koin.core.context.startKoin +import org.koin.dsl.KoinAppDeclaration + +fun initKoin(configuration: KoinAppDeclaration? = null) { + startKoin { + configuration?.invoke(this) + logger( + KermitKoinLogger(Logger.withTag("koin")) + ) + modules( + sharedModules, + platformModules + ) + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt new file mode 100644 index 00000000..d7668946 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt @@ -0,0 +1,305 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.di + +import org.koin.core.module.dsl.onClose +import org.spotube.spotube.core.network.HttpClientFactory +import co.touchlab.kermit.Logger +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.AudioPlayerQueueRepository +import org.spotube.spotube.core.audioplayer.DeviceAudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueStateRepository +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.discovery.DeviceDiscoveryService +import org.spotube.spotube.core.discord.DiscordRpcService +import org.spotube.spotube.core.jam.JamRoomClient +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.navigation.navigationModule +import org.spotube.spotube.core.remote.RemoteControlClient +import org.spotube.spotube.core.remote.RemoteControlHandler +import org.spotube.spotube.core.remote.RemoteControlService +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.scrobble.ScrobbleManager +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.server.AlternativeTracksRepository +import org.spotube.spotube.core.server.CacheManager +import org.spotube.spotube.core.server.LocalServer +import org.spotube.spotube.core.server.MatchedTracksRepository +import org.spotube.spotube.core.server.TrackSourceRepository +import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.core.webview.WebViewController +import org.spotube.spotube.modules.album.AlbumRepository +import org.spotube.spotube.modules.album.AlbumViewModel +import org.spotube.spotube.modules.artist.ArtistRepository +import org.spotube.spotube.modules.artist.ArtistViewModel +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.blacklist.BlacklistViewModel +import org.spotube.spotube.modules.devices.DevicesViewModel +import org.spotube.spotube.modules.devices.RemoteControlViewModel +import org.spotube.spotube.modules.shell.ConnectStatusViewModel +import org.spotube.spotube.modules.jam.JamViewModel +import org.spotube.spotube.modules.downloads.DownloadManager +import org.spotube.spotube.modules.downloads.DownloadsViewModel +import org.spotube.spotube.modules.home.HomeScreenRepository +import org.spotube.spotube.modules.home.HomeScreenViewModel +import org.spotube.spotube.modules.plugin.MetadataAuthViewModel +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.album.LibraryAlbumsViewModel +import org.spotube.spotube.modules.library.artist.LibraryArtistsViewModel +import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksViewModel +import org.spotube.spotube.modules.library.local_tracks.LocalFolderViewModel +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFoldersConfig +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator +import org.spotube.spotube.modules.library.playlist.LibraryPlaylistsViewModel +import org.spotube.spotube.modules.lyrics.LyricsViewModel +import org.spotube.spotube.modules.playlist.AddTracksToPlaylistViewModel +import org.spotube.spotube.modules.playlist.PlaylistRepository +import org.spotube.spotube.modules.playlist.PlaylistViewModel +import org.spotube.spotube.modules.plugin.AudioPluginSource +import org.spotube.spotube.modules.plugin.PluginManager +import org.spotube.spotube.modules.plugin.PluginProvider +import org.spotube.spotube.modules.plugin.PluginViewModel +import org.spotube.spotube.modules.plugin.guard.MetadataPluginGuardViewModel +import org.spotube.spotube.modules.plugin.ScrobblePluginSource +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel +import org.spotube.spotube.modules.search.SearchRepository +import org.spotube.spotube.modules.search.SearchScreenViewModel +import org.spotube.spotube.modules.update.AppUpdateService +import org.spotube.spotube.modules.update.PluginUpdateService +import org.spotube.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.modules.settings.SettingsRepository +import org.spotube.spotube.modules.settings.UserSettingsSource +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.JamSettingsViewModel +import org.spotube.spotube.modules.settings.LocalMediaFoldersViewModel +import org.spotube.spotube.modules.shell.AppShellViewModel +import org.spotube.spotube.modules.shell.PlayerOptionsViewModel +import org.spotube.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel +import org.spotube.spotube.modules.shell.player_queue.PlayerQueueContentViewModel +import org.spotube.spotube.modules.welcome.WelcomeRepository +import org.spotube.spotube.modules.welcome.WelcomeViewModel +import org.koin.core.module.Module +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.createdAtStart +import org.koin.core.module.dsl.singleOf +import org.koin.core.module.dsl.viewModel +import org.koin.core.module.dsl.viewModelOf +import org.koin.core.module.dsl.withOptions +import org.koin.dsl.module + +expect val platformModules: Module + +val sharedModules = module { + includes(navigationModule) + singleOf(::Database) + single { HttpClientFactory() } withOptions { onClose { it?.close() } } + + singleOf(::WebViewController) + + // Shell + viewModelOf(::AppShellViewModel) + viewModelOf(::PlayerOptionsViewModel) + viewModelOf(::PlayerQueueContentViewModel) + viewModelOf(::AlternativeTrackContentViewModel) + + // Home + singleOf(::HomeScreenRepository) + viewModelOf(::HomeScreenViewModel) + viewModelOf(::MetadataAuthViewModel) + + // Search + singleOf(::SearchRepository) + viewModelOf(::SearchScreenViewModel) + + // Library + singleOf(::LibraryState) + singleOf(::LibraryRepository) + singleOf(::LocalMediaCacheRepository) + singleOf(::LocalMediaFoldersConfig) + singleOf(::LocalMediaLibraryCoordinator) + viewModelOf(::LibraryPlaylistsViewModel) + viewModelOf(::LibraryAlbumsViewModel) + viewModelOf(::LibraryArtistsViewModel) + viewModelOf(::LibraryLocalTracksViewModel) + viewModel { (folderPath: String) -> LocalFolderViewModel(folderPath, get(), get()) } + + // Plugin system + singleOf(::PluginManager) { + bind() + bind() + bind() + } + viewModelOf(::PluginViewModel) + viewModelOf(::MetadataPluginGuardViewModel) + + // Settings + singleOf(::SettingsRepository) { bind() } + viewModelOf(::SettingsViewModel) { bind() } + viewModelOf(::JamSettingsViewModel) + viewModel { + val repository = get() + LocalMediaFoldersViewModel(repository.userSettings) { folders -> + val current = repository.getCurrentSettings() + repository.updateSettings(current.copy(localMediaFolders = folders)) + } + } + + // Welcome / Onboarding + singleOf(::WelcomeRepository) + viewModelOf(::WelcomeViewModel) + + // Updates + single { AppUpdateService(get(), get()) } withOptions { createdAtStart() } + single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() } + + // Downloads + singleOf(::DownloadManager) + viewModelOf(::DownloadsViewModel) + + // Playlist + singleOf(::PlaylistRepository) + viewModel { (playlistId: String) -> + PlaylistViewModel( + playlistId = playlistId, + repository = get(), + savedTracksRepository = get(), + libraryRepository = get(), + playbackHelper = get(), + audioPlayerQueue = get(), + blacklistRepository = get(), + shareService = get(), + downloadManager = get(), + remotePlaybackController = get()) + } + viewModel { (playlistId: String) -> AddTracksToPlaylistViewModel(playlistId, get(), get()) } + + // Saved Tracks + singleOf(::SavedTracksRepository) + viewModel { + SavedTracksViewModel( + repository = get(), + playbackHelper = get(), + audioPlayerQueue = get(), + blacklistRepository = get(), + libraryRepository = get(), + shareService = get(), + downloadManager = get(), + remotePlaybackController = get()) + } + + // Artist + singleOf(::ArtistRepository) + viewModel { (artistId: String) -> + ArtistViewModel( + artistId = artistId, + repository = get(), + libraryRepository = get(), + savedTracksRepository = get(), + audioPlayerQueue = get(), + blacklistRepository = get(), + shareService = get(), + downloadManager = get(), + remotePlaybackController = get()) + } + + // Blacklist + singleOf(::BlacklistRepository) + viewModelOf(::BlacklistViewModel) + viewModel { DevicesViewModel(get()) } + viewModelOf(::RemoteControlViewModel) + viewModelOf(::ConnectStatusViewModel) + viewModel { + JamViewModel( + jamRoomService = get(), + shareService = get(), + settingsProvider = get()) + } + + // Album + singleOf(::AlbumRepository) + viewModel { (albumId: String) -> + AlbumViewModel( + albumId = albumId, + repository = get(), + savedTracksRepository = get(), + playbackHelper = get(), + libraryRepository = get(), + audioPlayerQueue = get(), + blacklistRepository = get(), + shareService = get(), + downloadManager = get(), + remotePlaybackController = get()) + } + + // Lyrics + viewModelOf(::LyricsViewModel) + + // Local playback proxy server + single { + CollectionPlaybackHelper( + albumRepository = get(), + playlistRepository = get(), + savedTracksRepository = get(), + artistRepository = get(), + audioPlayerQueue = get(), + blacklistRepository = get()) + } + singleOf(::MatchedTracksRepository) { bind() } + single { + StreamingUrlRepository( + pluginManager = get(), + audioPlayerQueue = get(), + matchedTracksRepository = get(), + settingsRepository = get()) + } + singleOf(::CacheManager) + singleOf(::AlternativeTracksRepository) + singleOf(::LocalServer) withOptions { + createdAtStart() + } + single { RemoteControlHandler(get(), get(), get(), get()) } + single { RemoteControlClient(get()) } + singleOf(::DeviceDiscoveryService) + single { RemoteControlService(get(), get(), get()) } withOptions { + createdAtStart() + } + single { RemotePlaybackController(get(), get(), get(), get(), get()) } + singleOf(::JamRoomClient) + single { JamRoomService(get(), get(), get(), get()) } + singleOf(::AudioPlayerQueueRepository) { bind() } + single { + DeviceAudioPlayerQueue(get(), get(), get(), get(), get()) + } + + single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() } + + // Scrobbling + single { ScrobbleManager(get(), get(), get()) } withOptions { createdAtStart() } + + factory { (tag: String?) -> + if (tag != null) { + Logger.withTag(tag) + } else { + // Default logger if no tag is provided + Logger + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discord/DiscordRpcService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discord/DiscordRpcService.kt new file mode 100644 index 00000000..03942de1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discord/DiscordRpcService.kt @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.discord + +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.settings.SettingsProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import uniffi.compose_app.DiscordRpcClient +import kotlin.time.Clock + +class DiscordRpcService( + private val audioPlayer: AudioPlayerInterface, + private val settingsProvider: SettingsProvider) : KoinComponent, AutoCloseable { + + private val logger by injectLogger() + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var observerJob: Job? = null + + private val clientId = "1176718791388975124" + private var rpcClient: DiscordRpcClient? = null + private var lastConnectionAttempt: Long = 0 + private var connectionFailed: Boolean = false + private val connectionCooldownMs = 30_000L // 30 seconds cooldown between connection attempts + + init { + start() + } + + private fun start() { + logger.d { "Starting Discord RPC service" } + observerJob?.cancel() + observerJob = scope.launch { + combine( + settingsProvider.settingsState, + audioPlayer.playerStateFlow, + audioPlayer.currentMediaItemFlow, + audioPlayer.positionFlow, + audioPlayer.durationFlow) { settings, playerState, mediaItem, position, duration -> + DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration) + }.collect { state -> + handleStateChange(state) + } + } + } + + private fun handleStateChange(state: DiscordRpcState) { + if (!state.enabled) { + disconnectClient() + return + } + + when (state.playerState) { + PlayerState.PLAYING -> { + val mediaItem = state.mediaItem ?: return + ensureConnected() + updatePresence( + title = mediaItem.title, + artist = mediaItem.artist, + album = mediaItem.album, + coverUrl = mediaItem.coverURL, + positionMs = state.position.inWholeMilliseconds, + durationMs = state.duration.inWholeMilliseconds) + } + PlayerState.PAUSED -> { + val mediaItem = state.mediaItem ?: return + ensureConnected() + updatePresence( + title = mediaItem.title, + artist = mediaItem.artist, + album = mediaItem.album, + coverUrl = mediaItem.coverURL, + positionMs = state.position.inWholeMilliseconds, + durationMs = state.duration.inWholeMilliseconds) + } + PlayerState.IDLE, PlayerState.COMPLETED -> { + clearPresence() + } + PlayerState.BUFFERING, PlayerState.READY -> { + } + } + } + + private fun ensureConnected() { + val client = rpcClient + if (client != null) { + try { + if (client.isConnected()) return + logger.w { "Discord RPC connection lost, will retry after cooldown" } + client.disconnect() + rpcClient = null + connectionFailed = true + lastConnectionAttempt = currentTimeMillis() + return + } catch (e: Exception) { + logger.w { "Discord RPC connection check failed, will retry after cooldown" } + rpcClient = null + connectionFailed = true + lastConnectionAttempt = currentTimeMillis() + return + } + } + + // Check cooldown period + val now = currentTimeMillis() + if (connectionFailed && (now - lastConnectionAttempt) < connectionCooldownMs) { + return + } + + lastConnectionAttempt = now + try { + val newClient = DiscordRpcClient(clientId) + newClient.connect() + rpcClient = newClient + connectionFailed = false + logger.i { "Connected to Discord RPC" } + } catch (e: Exception) { + connectionFailed = true + logger.d { "Discord not available, will retry in ${connectionCooldownMs / 1000}s" } + } + } + + private fun currentTimeMillis(): Long { + return Clock.System.now().toEpochMilliseconds() + } + + private fun updatePresence( + title: String, + artist: String, + album: String, + coverUrl: String, + positionMs: Long, + durationMs: Long) { + val client = rpcClient ?: return + try { + client.updatePresence( + title = title, + artist = artist, + album = album, + coverUrl = coverUrl, + positionMs = positionMs, + durationMs = durationMs) + } catch (e: Exception) { + rpcClient = null + connectionFailed = true + lastConnectionAttempt = currentTimeMillis() + logger.d { "Discord RPC update failed, will retry after cooldown" } + } + } + + private fun clearPresence() { + val client = rpcClient ?: return + try { + client.clearPresence() + } catch (e: Exception) { + rpcClient = null + connectionFailed = true + lastConnectionAttempt = currentTimeMillis() + logger.d { "Discord RPC clear failed, will retry after cooldown" } + } + } + + private fun disconnectClient() { + try { + rpcClient?.disconnect() + rpcClient = null + logger.d { "Disconnected from Discord RPC" } + } catch (e: Exception) { + logger.e(e) { "Failed to disconnect from Discord RPC" } + } + } + + override fun close() { + observerJob?.cancel() + disconnectClient() + } + + private data class DiscordRpcState( + val enabled: Boolean, + val playerState: PlayerState, + val mediaItem: org.spotube.spotube.core.audioplayer.MediaItem?, + val position: kotlin.time.Duration, + val duration: kotlin.time.Duration) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.kt new file mode 100644 index 00000000..d67d8efb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.discovery + +/** + * The LAN address to publish in our mDNS advertisement, or null to let the platform decide. + * + * Only the desktop needs this: there the dns-sd library otherwise grabs the first site-local + * address of *any* interface, which on a dev box is often a Docker/VM bridge (e.g. minikube's + * 192.168.49.1) that other devices can't reach. + */ +internal expect fun preferredAdvertiseAddress(): String? diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt new file mode 100644 index 00000000..b0c0c2f2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.discovery + +import com.appstractive.dnssd.DiscoveryEvent +import com.appstractive.dnssd.NetService +import com.appstractive.dnssd.createNetService +import com.appstractive.dnssd.discoverServices +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlin.text.decodeToString + +data class DiscoveredDevice( + val name: String, + val type: String, + val host: String, + val port: Int, + val deviceId: String) { + val key: String get() = "$name$type".replace(".", "") +} + +sealed interface DiscoveryState { + /** [resolve] can block for seconds on desktop (JmDNS), so it's a suspend call that runs off the main thread. */ + data class Discovered(val device: DiscoveredDevice, val resolve: suspend () -> Unit) : DiscoveryState + data class Resolved(val device: DiscoveredDevice) : DiscoveryState + data class Removed(val device: DiscoveredDevice) : DiscoveryState +} + +class DeviceDiscoveryService { + companion object { + const val SERVICE_TYPE = "_spotube-ctrl._tcp" + const val TXT_DEVICE_ID = "deviceId" + } + + /** + * Runs on [Dispatchers.IO]: on desktop, cancelling the underlying flow calls the blocking + * `JmDNS.close()` (it waits up to 5s for its shutdown), which froze the UI whenever the + * collector was cancelled on the main thread. + */ + fun discover(): Flow = discoverServices(SERVICE_TYPE).map { event -> + when (event) { + is DiscoveryEvent.Discovered -> { + val device = DiscoveredDevice( + name = event.service.name, + type = event.service.type, + host = event.service.host, + port = event.service.port, + deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty()) + DiscoveryState.Discovered( + device = device, + resolve = { withContext(Dispatchers.IO) { event.resolve() } }) + } + + is DiscoveryEvent.Resolved -> { + val device = DiscoveredDevice( + name = event.service.name, + type = event.service.type, + host = event.service.host, + port = event.service.port, + deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty()) + DiscoveryState.Resolved(device = device) + } + + is DiscoveryEvent.Removed -> { + val device = DiscoveredDevice( + name = event.service.name, + type = event.service.type, + host = event.service.host, + port = event.service.port, + deviceId = "") + DiscoveryState.Removed(device = device) + } + } + }.flowOn(Dispatchers.IO) + + suspend fun advertise( + name: String, + port: Int, + deviceId: String, + registerTimeoutMs: Long = 5_000): NetService { + val service = createService(name, port, deviceId) + service.register(timeoutInMs = registerTimeoutMs) + return service + } + + fun createService( + name: String, + port: Int, + deviceId: String): NetService = createNetService( + type = SERVICE_TYPE, + name = name, + port = port, + addresses = preferredAdvertiseAddress()?.let { listOf(it) }, + txt = mapOf(TXT_DEVICE_ID to deviceId)) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.kt new file mode 100644 index 00000000..da6da28b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.discovery + +import androidx.compose.runtime.Composable + +/** + * Returns a lambda that requests the OS permission needed for local network + * discovery (mDNS/NSD). No-op on platforms where such a permission doesn't + * exist or is granted implicitly. + */ +@Composable +expect fun rememberLocalNetworkPermissionRequester(): () -> Unit \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/extras/CaseConverterExt.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/extras/CaseConverterExt.kt new file mode 100644 index 00000000..77b50482 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/extras/CaseConverterExt.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.extras + +fun String.kebabToTitleCase(): String { + return this.split("-").joinToString(" ") { it.replaceFirstChar { char -> char.uppercase() } } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.kt new file mode 100644 index 00000000..58af88e5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.i18n + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.key +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import org.spotube.spotube.modules.settings.SupportedLanguages + +/** + * Points the platform locale (which Compose resources resolve strings from) at [languageTag], + * or back to the system language when it is null, and returns the platform-specific + * composition local that carries it. + */ +@Composable +internal expect fun provideAppLocale(languageTag: String?): ProvidedValue<*> + +/** + * Renders [content] in the app language picked in settings, overriding the system language + * unless [language] is [SupportedLanguages.SYSTEM]. + * + * Compose resources only re-resolve strings when the calling composable recomposes, so the + * content is keyed on the language: switching languages recreates the subtree. Keep state that + * must survive a language switch (e.g. navigation) above this provider. + */ +@Composable +fun AppLocaleProvider(language: SupportedLanguages, content: @Composable () -> Unit) { + val languageTag = language.languageTag + val layoutDirection = when { + language == SupportedLanguages.SYSTEM -> null + language.isRtl -> LayoutDirection.Rtl + else -> LayoutDirection.Ltr + } + val locales = listOfNotNull( + provideAppLocale(languageTag), + layoutDirection?.let { LocalLayoutDirection provides it }) + CompositionLocalProvider(*locales.toTypedArray()) { + key(languageTag) { + content() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt new file mode 100644 index 00000000..03231e66 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.MediaItem +import org.spotube.spotube.core.audioplayer.QueueEntry +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Jam messages exchanged over MQTT. + * + * - `state` topic: [QueueState] (retained, host -> everyone) + * - `cmd` topic: [PlaybackCommand], [Kick], [SuggestTrack], [SuggestPlaylist] + * (anyone -> host, except Kick which is host -> guest) + * - `presence/{clientId}` topic: [JamPresence] (retained, one per participant) + */ +@Serializable +sealed class JamMessage { + @Serializable + @SerialName("queueState") + data class QueueState( + val items: List, + val currentIndex: Int, + val shuffleEnabled: Boolean = false, + /** + * Whether guests should follow the host's current index. True when the + * host manually skipped/jumped or loaded a queue; false when the host + * merely auto-advanced because its song ended (guests stay put). + */ + val follow: Boolean = false, + /** Room-wide play/pause; guests follow every change of it. */ + val isPlaying: Boolean = false) : JamMessage() + + @Serializable + @SerialName("playbackCommand") + data class PlaybackCommand(val command: PlaybackCmd) : JamMessage() + + @Serializable + @SerialName("suggestTrack") + data class SuggestTrack( + val mediaItem: JamMediaItem, + val addedBy: String = "") : JamMessage() + + @Serializable + @SerialName("suggestPlaylist") + data class SuggestPlaylist( + val tracks: List, + val addedBy: String = "") : JamMessage() + + @Serializable + @SerialName("kick") + data class Kick( + val participantId: String, + val reason: String = "kicked") : JamMessage() + + @Serializable + @SerialName("leave") + data class Leave(val reason: String = "user_left") : JamMessage() +} + +/** + * Playback commands. Queue navigation and play/pause are global — seek, + * volume, shuffle and loop are local to each participant. + */ +@Serializable +sealed class PlaybackCmd { + @Serializable + @SerialName("setPlaying") + data class SetPlaying(val playing: Boolean) : PlaybackCmd() + + @Serializable + @SerialName("skipNext") + data object SkipNext : PlaybackCmd() + + @Serializable + @SerialName("skipPrevious") + data object SkipPrevious : PlaybackCmd() + + @Serializable + @SerialName("jumpTo") + data class JumpTo(val index: Int) : PlaybackCmd() +} + +/** Retained per-participant presence entry (with an MQTT Last Will for leave). */ +@Serializable +data class JamPresence( + val clientId: String, + val displayName: String, + val isHost: Boolean, + val left: Boolean = false) + +@Serializable +data class JamMediaItem( + val url: String, + val trackId: String = "", + val title: String, + val artist: String, + val album: String, + val durationMs: Long, + val coverUrl: String, + val protocol: String, + val addedBy: String = "") { + companion object { + fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) { + is QueueEntry.StreamingTrack -> JamMediaItem( + url = "", + trackId = entry.track.id, + title = entry.track.title, + artist = entry.track.artists.joinToString(", ") { it.name }, + album = entry.track.album?.title.orEmpty(), + durationMs = entry.track.durationMs, + coverUrl = entry.track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url + .orEmpty(), + protocol = entry.protocol.name, + addedBy = entry.addedBy) + + is QueueEntry.LocalTrack -> JamMediaItem( + url = entry.url, + trackId = "", + title = entry.name, + artist = entry.artists.joinToString(", "), + album = entry.album.orEmpty(), + durationMs = entry.duration, + coverUrl = "", + protocol = "PROGRESSIVE", + addedBy = entry.addedBy) + } + + fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem( + url = "", + trackId = track.id, + title = track.title, + artist = track.artists.joinToString(", ") { it.name }, + album = track.album?.title.orEmpty(), + durationMs = track.durationMs, + coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url + .orEmpty(), + protocol = "PROGRESSIVE") + + fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem( + url = item.url, + title = item.title, + artist = item.artist, + album = item.album, + durationMs = item.duration.inWholeMilliseconds, + coverUrl = item.coverURL, + protocol = item.protocol.name) + + fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem( + title = item.title, + artist = item.artist, + album = item.album, + duration = kotlin.time.Duration.parse("${item.durationMs}ms"), + coverURL = item.coverUrl, + url = item.url, + protocol = org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol + .valueOf(item.protocol.ifBlank { "PROGRESSIVE" })) + } +} + +@Serializable +data class JamParticipant( + val id: String, + val displayName: String, + val isHost: Boolean) + +@Serializable +enum class JamRole { + Host, + Guest, +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt new file mode 100644 index 00000000..85085f0f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt @@ -0,0 +1,322 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import co.touchlab.kermit.Logger +import com.ditchoom.buffer.Charset +import com.ditchoom.buffer.codec.asReadBuffer +import com.ditchoom.buffer.toReadBuffer +import com.ditchoom.mqtt.client.ConnectionState +import com.ditchoom.mqtt.client.MqttClient +import com.ditchoom.mqtt.connection.MqttConnectionOptions +import com.ditchoom.mqtt.controlpacket.OpaquePublishPayloadCodec +import com.ditchoom.mqtt.controlpacket.QualityOfService +import com.ditchoom.mqtt.controlpacket.TopicName +import com.ditchoom.mqtt.controlpacket.WillConfig +import com.ditchoom.mqtt5.controlpacket.ConnectionRequest +import org.spotube.spotube.modules.settings.JamBroker +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_error_broker_host_empty +import spotube.composeapp.generated.resources.jam_error_connection_failed +import spotube.composeapp.generated.resources.jam_error_connection_lost +import spotube.composeapp.generated.resources.jam_error_no_broker +import spotube.composeapp.generated.resources.settings_jam_broker_test_ok +import org.koin.core.component.KoinComponent +import kotlin.time.Duration.Companion.seconds + +/** + * Thin facade over the Ditchoom MQTT 5 client for one jam room. + * + * Topics (room code `C`): + * - `spotube/jam/{C}/state` retained — [JamMessage.QueueState] (host -> everyone) + * - `spotube/jam/{C}/cmd` volatile — commands/suggestions (everyone -> host, host -> guest) + * - `spotube/jam/{C}/presence/{clientId}` retained — [JamPresence], with a Last Will + * (`left = true`) so a dropped client disappears from the room automatically. + * + * The library keeps the connection alive (auto-reconnect + backoff); this class only + * re-establishes the room subscription and re-publishes presence after a reconnect. + */ +class JamRoomClient : KoinComponent { + private val log = Logger.withTag("JamRoomClient") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private var client: MqttClient? = null + + private var roomCode: String? = null + private var localPresence: JamPresence? = null + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _connectionError = MutableStateFlow(null) + val connectionError: StateFlow = _connectionError.asStateFlow() + + private val _state = MutableSharedFlow(replay = 1, extraBufferCapacity = 8) + val state: SharedFlow = _state.asSharedFlow() + + private val _commands = MutableSharedFlow(extraBufferCapacity = 32) + val commands: SharedFlow = _commands.asSharedFlow() + + private val _presence = MutableStateFlow>(emptyMap()) + val presence: StateFlow> = _presence.asStateFlow() + + // ---------- Public API ---------- + + /** One-off connection check used by the settings screen. Returns latency description. */ + suspend fun testConnection(broker: JamBroker): Result { + if (broker.host.isBlank()) return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_broker_host_empty))) + val started = kotlin.time.TimeSource.Monotonic.markNow() + return runCatching { + val client = startClient(broker, clientId = "${broker.clientIdPrefix}-test") + try { + withTimeout(broker.connectionTimeoutSeconds.seconds) { + client.awaitConnectivity() + } + getString(Res.string.settings_jam_broker_test_ok, started.elapsedNow().inWholeMilliseconds) + } finally { + runCatching { client.shutdown(sendDisconnect = true, drain = false) } + } + } + } + + /** Connects to [code] on [broker] and starts routing room messages. */ + suspend fun connect( + broker: JamBroker, + code: String, + clientId: String, + displayName: String, + isHost: Boolean): Result { + disconnect() + if (broker.host.isBlank()) { + return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_no_broker))) + } + return runCatching { + roomCode = code + localPresence = JamPresence( + clientId = clientId, + displayName = displayName, + isHost = isHost, + left = false) + + val mqtt = startClient(broker, clientId) + client = mqtt + withTimeout(broker.connectionTimeoutSeconds.seconds) { + mqtt.awaitConnectivity() + } + _connectionError.value = null + log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" } + + mqtt.connectionState + .onEach { onConnectionStateChanged(it) } + .launchIn(scope) + Unit + }.onFailure { e -> + log.w(e) { "Failed to connect to jam broker" } + _connectionError.value = e.message ?: getString(Res.string.jam_error_connection_failed) + _isConnected.value = false + runCatching { client?.shutdown(sendDisconnect = true, drain = false) } + client = null + } + } + + suspend fun publishState(state: JamMessage.QueueState) { + val code = roomCode ?: return + publishJson(stateTopic(code), json.encodeToString(JamMessage.QueueState.serializer(), state), retain = true) + } + + suspend fun publishCommand(message: JamMessage) { + val code = roomCode ?: return + publishJson(cmdTopic(code), json.encodeToString(JamMessage.serializer(), message), retain = false) + } + + /** Publishes our own (retained) presence. Re-published after every reconnect. */ + suspend fun publishPresence() { + val code = roomCode ?: return + val presence = localPresence ?: return + publishJson( + presenceTopic(code, presence.clientId), + json.encodeToString(JamPresence.serializer(), presence), + retain = true) + } + + /** Marks us as the host in presence (host takeover). */ + suspend fun claimHost() { + val presence = localPresence ?: return + localPresence = presence.copy(isHost = true) + publishPresence() + } + + /** Graceful leave: publish `left = true` before disconnecting. */ + suspend fun leavePresence() { + val presence = localPresence ?: return + val code = roomCode ?: return + runCatching { + publishJson( + presenceTopic(code, presence.clientId), + json.encodeToString(JamPresence.serializer(), presence.copy(left = true)), + retain = true) + } + } + + suspend fun disconnect() { + roomCode = null + localPresence = null + _isConnected.value = false + _presence.value = emptyMap() + val current = client + client = null + runCatching { current?.shutdown(sendDisconnect = true, drain = false) } + } + + // ---------- Internals ---------- + + private suspend fun startClient(broker: JamBroker, clientId: String): MqttClient { + val connection = MqttConnectionOptions.SocketConnection( + host = broker.host, + port = broker.port, + tlsEnabled = broker.useTls, + connectionTimeout = broker.connectionTimeoutSeconds.seconds) + val code = roomCode ?: "unset" + val will = WillConfig.Enabled( + topic = TopicName.fromOrThrow(presenceTopic(code, clientId)), + payload = json.encodeToString( + JamPresence.serializer(), + JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true)).toReadBuffer(Charset.UTF8), + qos = QualityOfService.AT_LEAST_ONCE, + retain = true) + val request = ConnectionRequest( + clientId = clientId, + keepAliveSeconds = broker.keepAliveSeconds, + cleanStart = true, + userName = broker.username, + password = broker.password, + will = will) + val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true) + val brokerRef = persistence.addBroker(connection, request) + return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence) + } + + private fun onConnectionStateChanged(state: ConnectionState) { + when (state) { + is ConnectionState.Connected -> { + _isConnected.value = true + _connectionError.value = null + scope.launch { + // Every connection (initial + reconnects) must re-establish the + // broker-side subscription (clean session) and re-publish our + // retained presence to clear any Last Will. Re-subscribing with + // the same filter replaces the previous dispatcher handler. + subscribeRoom() + publishPresence() + } + } + + ConnectionState.Disconnected, ConnectionState.Handshaking -> { + _isConnected.value = false + } + + else -> { + _isConnected.value = false + scope.launch { + val message = getString(Res.string.jam_error_connection_lost) + if (!_isConnected.value) _connectionError.value = message + } + } + } + } + + private suspend fun subscribeRoom() { + val mqtt = client ?: return + val code = roomCode ?: return + val operation = mqtt.subscribe( + roomFilter(code), + OpaquePublishPayloadCodec, + QualityOfService.AT_LEAST_ONCE) { publish, payload -> + route(publish.topic.toString(), payload) + } + runCatching { operation.subAck.await() } + .onFailure { log.w(it) { "Subscribe ack failed for room $code" } } + } + + private fun route(topic: String, payload: com.ditchoom.mqtt.controlpacket.OpaquePublishPayload) { + val text = runCatching { + val buffer = payload.handle.asReadBuffer() + buffer.readString(buffer.remaining(), Charset.UTF8) + }.getOrElse { e -> + log.w(e) { "Failed to read jam payload on $topic" } + return + } + + runCatching { + when { + topic.endsWith("/state") -> { + _state.tryEmit(json.decodeFromString(JamMessage.QueueState.serializer(), text)) + } + + topic.endsWith("/cmd") -> { + _commands.tryEmit(json.decodeFromString(JamMessage.serializer(), text)) + } + + topic.contains("/presence/") -> { + val presence = json.decodeFromString(JamPresence.serializer(), text) + _presence.value = _presence.value + (presence.clientId to presence) + } + } + }.onFailure { e -> + log.w(e) { "Failed to decode jam message on $topic: $text" } + } + } + + private suspend fun publishJson(topic: String, payload: String, retain: Boolean) { + val mqtt = client ?: return + runCatching { + mqtt.publish( + topicName = topic, + qos = QualityOfService.AT_LEAST_ONCE, + payload = payload.toReadBuffer(Charset.UTF8), + retain = retain) + }.onFailure { e -> + log.w(e) { "Failed to publish to $topic" } + } + } + + private fun stateTopic(code: String) = "spotube/jam/$code/state" + private fun cmdTopic(code: String) = "spotube/jam/$code/cmd" + private fun presenceTopic(code: String, clientId: String) = "spotube/jam/$code/presence/$clientId" + private fun roomFilter(code: String) = "spotube/jam/$code/#" +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt new file mode 100644 index 00000000..f8d0f19b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import kotlin.random.Random + +/** + * Six-character room codes shared verbally / by text. Codes are opaque keys used + * to namespace the MQTT topics of a jam room — they carry no connection details. + * + * The alphabet excludes look-alike characters (I, O, 0, 1) so codes are easy to + * read aloud and retype. + */ +object JamRoomCode { + const val LENGTH = 6 + private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + + fun generate(): String = buildString(LENGTH) { + repeat(LENGTH) { + append(ALPHABET[Random.nextInt(ALPHABET.length)]) + } + } + + /** Uppercases, strips separators/whitespace and truncates to [LENGTH]. */ + fun normalize(input: String): String = input + .uppercase() + .filter { it.isLetterOrDigit() } + .take(LENGTH) + + fun isValid(code: String): Boolean = + code.length == LENGTH && code.all { it in ALPHABET } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt new file mode 100644 index 00000000..f317e6ec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt @@ -0,0 +1,590 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import co.touchlab.kermit.Logger +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlin.random.Random +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_error_invalid_code +import spotube.composeapp.generated.resources.jam_error_no_broker +import kotlin.time.Clock + +/** + * A jam session over MQTT (star topology, host-authoritative queue). + * + * Sync rules: + * - The queue list and current index are global. [skipNext]/[skipPrevious]/[jumpTo] + * from anyone are applied by the host, then broadcast via the retained state topic. + * - Play/pause is global: anyone's play/pause is sent to the host, which applies it + * and broadcasts it; guests follow every change of the host's play state. + * - Seek, volume and loop are local to each device — never broadcast. + * - Shuffle is host-only; guests mirror the host's shuffle setting. + * - Guests can only add to the queue (suggest); the host applies suggestions. + * - If the host leaves, the participant with the lowest client id takes over. + */ +private data class PlaybackBroadcast( + val queue: List, + val current: QueueEntry?, + val shuffle: Boolean, + val isPlaying: Boolean) + +class JamRoomService( + private val jamClient: JamRoomClient, + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val settingsRepository: SettingsRepository) { + private val log = Logger.withTag("JamRoomService") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + /** + * Play/pause intent. Transient states (buffering between tracks, loading, completion) + * keep the last value, so they never pause or resume the room. + */ + private val playIntent: StateFlow = audioPlayer.playerStateFlow + .mapNotNull { state -> + when (state) { + PlayerState.PLAYING -> true + PlayerState.PAUSED -> false + else -> null + } + } + .stateIn(scope, SharingStarted.Eagerly, audioPlayer.playerStateFlow.value == PlayerState.PLAYING) + + private val _role = MutableStateFlow(null) + val role: StateFlow = _role.asStateFlow() + + private val _participants = MutableStateFlow>(emptyList()) + val participants: StateFlow> = _participants.asStateFlow() + + private val _roomCode = MutableStateFlow(null) + val roomCode: StateFlow = _roomCode.asStateFlow() + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _connectionError = MutableStateFlow(null) + val connectionError: StateFlow = _connectionError.asStateFlow() + + private val _shuffleEnabled = MutableStateFlow(false) + val shuffleEnabled: StateFlow = _shuffleEnabled.asStateFlow() + + private var localClientId: String = "" + private var localDisplayName: String = "" + + /** Display name of the local participant. */ + val participantDisplayName: String + get() = localDisplayName + + /** Stable id of the local participant (stamped on items this device adds). */ + val participantClientId: String + get() = localClientId + + private var hostBroadcastJob: Job? = null + + /** Guest side: last queue snapshot applied to the local player. */ + private var lastAppliedItems: List = emptyList() + private var lastAppliedIndex = -1 + + /** + * Host side: a song completed, so index changes within this window are + * auto-advance (guests must not follow). The completion event, the player + * state change and the media transition arrive as separate flow emissions, + * so the window covers the whole sequence instead of a single flag. + */ + private var autoAdvanceDeadlineMs = 0L + + /** Guest side: the room's play state from the last host broadcast (null until the first one). */ + private var lastHostIsPlaying: Boolean? = null + + /** Host side: client ids banned for this session. */ + private val bannedClientIds = mutableSetOf() + + private var leaving = false + + init { + jamClient.isConnected + .onEach { _isConnected.value = it } + .launchIn(scope) + jamClient.connectionError + .onEach { _connectionError.value = it } + .launchIn(scope) + jamClient.state + .onEach { onRemoteState(it) } + .launchIn(scope) + jamClient.commands + .onEach { onCommand(it) } + .launchIn(scope) + jamClient.presence + .onEach { onPresence(it) } + .launchIn(scope) + // A naturally-completed song means the host's next index change is an + // auto-advance — guests must NOT follow those, only manual skips. + audioPlayer.completionFlow + .onEach { autoAdvanceDeadlineMs = now() + AUTO_ADVANCE_WINDOW_MS } + .launchIn(scope) + // A guest's own play/pause (UI, notification, headset) goes to the host, which + // applies it for the whole room. Intent that matches the room's state came from + // following the host, so it isn't echoed back. + playIntent + .onEach { playing -> + val roomPlaying = lastHostIsPlaying + if (_role.value == JamRole.Guest && !leaving && roomPlaying != null && playing != roomPlaying) { + publishCommand(PlaybackCmd.SetPlaying(playing)) + } + } + .launchIn(scope) + } + + // ---------- Session lifecycle ---------- + + suspend fun createRoom(): Result { + val broker = settingsRepository.userSettings.value.jamBroker + if (broker.host.isBlank()) { + return Result.failure(IllegalStateException(getString(Res.string.jam_error_no_broker))) + } + val code = JamRoomCode.generate() + localClientId = newClientId(broker) + val name = participantName("Host") + localDisplayName = name + + return jamClient.connect( + broker = broker, + code = code, + clientId = localClientId, + displayName = name, + isHost = true).map { + _role.value = JamRole.Host + _roomCode.value = code + _participants.value = listOf(JamParticipant(localClientId, name, isHost = true)) + lastAppliedItems = emptyList() + lastAppliedIndex = -1 + bannedClientIds.clear() + leaving = false + autoAdvanceDeadlineMs = 0L + lastHostIsPlaying = null + startHostBroadcast() + persistLastCode(code) + code + } + } + + suspend fun joinRoom(code: String): Result { + val broker = settingsRepository.userSettings.value.jamBroker + if (broker.host.isBlank()) { + return Result.failure(IllegalStateException(getString(Res.string.jam_error_no_broker))) + } + val normalized = JamRoomCode.normalize(code) + if (!JamRoomCode.isValid(normalized)) { + return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_invalid_code))) + } + localClientId = newClientId(broker) + val name = participantName("Guest") + localDisplayName = name + + return jamClient.connect( + broker = broker, + code = normalized, + clientId = localClientId, + displayName = name, + isHost = false).map { + _role.value = JamRole.Guest + _roomCode.value = normalized + _participants.value = emptyList() + lastAppliedItems = emptyList() + lastAppliedIndex = -1 + leaving = false + lastHostIsPlaying = null + persistLastCode(normalized) + } + } + + suspend fun leaveRoom() { + leaving = true + stopHostBroadcast() + runCatching { jamClient.leavePresence() } + jamClient.disconnect() + _role.value = null + _roomCode.value = null + _participants.value = emptyList() + _shuffleEnabled.value = false + _isConnected.value = false + lastAppliedItems = emptyList() + lastAppliedIndex = -1 + bannedClientIds.clear() + lastHostIsPlaying = null + } + + // ---------- Controls (called from the UI) ---------- + + fun skipNext() { + publishCommand(PlaybackCmd.SkipNext) + } + + fun skipPrevious() { + publishCommand(PlaybackCmd.SkipPrevious) + } + + fun jumpTo(index: Int) { + publishCommand(PlaybackCmd.JumpTo(index)) + } + + /** Host-only. Applied locally; the queue broadcast carries the new shuffle flag. */ + fun toggleShuffle() { + if (_role.value != JamRole.Host) return + scope.launch { + runCatching { audioPlayer.shuffle(!_shuffleEnabled.value) } + } + } + + suspend fun suggestTrack(track: MetadataTrack) { + jamClient.publishCommand( + JamMessage.SuggestTrack( + mediaItem = JamMediaItem.fromTrack(track), + addedBy = localClientId) + ) + } + + suspend fun suggestPlaylist(tracks: List) { + if (tracks.isEmpty()) return + jamClient.publishCommand( + JamMessage.SuggestPlaylist( + tracks = tracks.map(JamMediaItem::fromTrack), + addedBy = localClientId) + ) + } + + suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") { + if (_role.value != JamRole.Host) return + jamClient.publishCommand(JamMessage.Kick(participantId, reason)) + } + + suspend fun banParticipant(participantId: String) { + if (_role.value != JamRole.Host) return + bannedClientIds += participantId + kickParticipant(participantId, "banned by host") + } + + // ---------- Host: broadcast ---------- + + private fun startHostBroadcast() { + if (hostBroadcastJob?.isActive == true) return + hostBroadcastJob = scope.launch { + combine( + audioPlayerQueue.queueFlow, + audioPlayerQueue.currentQueueEntryFlow, + audioPlayer.shuffleModeFlow, + playIntent) { queue, current, shuffle, isPlaying -> + PlaybackBroadcast(queue, current, shuffle, isPlaying) + } + .onEach { broadcast -> + if (_role.value != JamRole.Host) return@onEach + val index = if (broadcast.current != null) { + broadcast.queue.indexOfFirst { it.matchesEntry(broadcast.current) } + } else { + -1 + } + _shuffleEnabled.value = broadcast.shuffle + jamClient.publishState( + JamMessage.QueueState( + items = broadcast.queue.map(JamMediaItem::fromQueueEntry), + currentIndex = index.coerceAtLeast(0), + shuffleEnabled = broadcast.shuffle, + follow = now() > autoAdvanceDeadlineMs, + isPlaying = broadcast.isPlaying) + ) + } + .launchIn(this) + } + } + + private fun stopHostBroadcast() { + hostBroadcastJob?.cancel() + hostBroadcastJob = null + } + + // ---------- Guest: apply remote state ---------- + + private suspend fun onRemoteState(state: JamMessage.QueueState) { + if (_role.value != JamRole.Guest) return + if (leaving) return + + _shuffleEnabled.value = state.shuffleEnabled + runCatching { audioPlayer.shuffle(state.shuffleEnabled) } + + // Play/pause is room-wide: follow every change of the host's play state. The room + // state is recorded first, so the resulting local intent isn't sent back as a command. + if (state.isPlaying != lastHostIsPlaying) { + lastHostIsPlaying = state.isPlaying + if (state.isPlaying != playIntent.value) { + runCatching { if (state.isPlaying) audioPlayer.play() else audioPlayer.pause() } + .onFailure { log.w(it) { "Failed to follow the host's play state" } } + } + } + + val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } + + if (items != lastAppliedItems) { + val previous = lastAppliedItems + lastAppliedItems = items + val guestCurrent = audioPlayerQueue.currentQueueEntryFlow.value + val guestIndex = items.indexOfFirst { it.matchesEntry(guestCurrent) } + + // The host only appended items (e.g. accepted suggestions): merge + // them in without resetting playback or the guest's position. + if (previous.isNotEmpty() && items.size > previous.size && + items.take(previous.size) == previous && guestIndex >= 0 + ) { + lastAppliedIndex = guestIndex + val appended = items.drop(previous.size) + runCatching { + audioPlayerQueue.addAllToQueue(appended.map { it.toQueueEntry() }) + }.onFailure { log.w(it) { "Failed to append jam queue items" } } + return + } + + // Full re-sync. Keep the guest's current track when it still exists + // in the synced queue; otherwise take the host's position. + val startIndex = if (guestIndex >= 0) guestIndex + else state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)) + lastAppliedIndex = startIndex + runCatching { + audioPlayerQueue.load( + entries = items.map { it.toQueueEntry() }, + autoPlay = state.isPlaying, + startPosition = startIndex) + }.onFailure { log.w(it) { "Failed to apply jam queue" } } + return + } + + // Same queue content: only follow the host when it moved manually + // (skip/jump). Natural auto-advance keeps everyone where they are. + if (state.follow && state.currentIndex != lastAppliedIndex) { + lastAppliedIndex = state.currentIndex + runCatching { + audioPlayerQueue.jumpTo(state.currentIndex.coerceAtLeast(0), autoPlay = false) + }.onFailure { log.w(it) { "Failed to follow jam queue index" } } + } + } + + // ---------- Incoming commands ---------- + + private suspend fun onCommand(message: JamMessage) { + when (message) { + is JamMessage.PlaybackCommand -> { + if (_role.value != JamRole.Host) return + applyCommand(message.command) + } + + is JamMessage.SuggestTrack -> { + if (_role.value == JamRole.Host) { + acceptSuggestion(listOf(message.mediaItem.copy(addedBy = message.addedBy))) + } + } + + is JamMessage.SuggestPlaylist -> { + if (_role.value == JamRole.Host) { + acceptSuggestion(message.tracks.map { it.copy(addedBy = message.addedBy) }) + } + } + + is JamMessage.Kick -> { + if (_role.value == JamRole.Guest && message.participantId == localClientId) { + log.i { "Kicked from jam room: ${message.reason}" } + leaveRoom() + } + } + + else -> Unit + } + } + + private suspend fun applyCommand(command: PlaybackCmd) { + when (command) { + // Applied locally; the host broadcast carries the new play state to every guest + is PlaybackCmd.SetPlaying -> runCatching { + if (command.playing) audioPlayer.play() else audioPlayer.pause() + } + PlaybackCmd.SkipNext -> runCatching { audioPlayer.skipToNext() } + PlaybackCmd.SkipPrevious -> runCatching { audioPlayer.skipToPrevious() } + is PlaybackCmd.JumpTo -> runCatching { audioPlayer.jumpTo(command.index) } + } + } + + private suspend fun acceptSuggestion(items: List) { + if (items.isEmpty()) return + log.i { "Accepting ${items.size} suggested item(s) into the jam queue" } + runCatching { + audioPlayerQueue.addAllToQueue(items.map { it.toQueueEntry() }) + } + } + + // ---------- Presence & host takeover ---------- + + private fun onPresence(all: Map) { + if (_role.value == null) return + val live = all.values.filter { !it.left } + _participants.value = live + .sortedBy { it.clientId } + .map { JamParticipant(it.clientId, it.displayName, it.isHost) } + + // Host-side: auto-kick banned participants that rejoin. + if (_role.value == JamRole.Host) { + live.filter { it.clientId in bannedClientIds }.forEach { banned -> + scope.launch { kickParticipant(banned.clientId, "banned by host") } + } + return + } + + val host = live.firstOrNull { it.isHost } + if (host != null) return + + // Host left: the lowest client id takes over (deterministic, clock-free). + val candidate = live.minByOrNull { it.clientId } ?: return + if (candidate.clientId != localClientId) return + + scope.launch { + delay(HOST_TAKEOVER_DELAY_MS) + val stillNoHost = jamClient.presence.value.values.none { !it.left && it.isHost } + if (!stillNoHost || leaving || _role.value != JamRole.Guest) return@launch + log.i { "Taking over as jam host (previous host left)" } + _role.value = JamRole.Host + jamClient.claimHost() + startHostBroadcast() + } + } + + // ---------- Helpers ---------- + + private fun publishCommand(command: PlaybackCmd) { + scope.launch { + jamClient.publishCommand(JamMessage.PlaybackCommand(command)) + } + } + + private fun participantName(fallbackPrefix: String): String { + val configured = settingsRepository.userSettings.value.jamParticipantName + return configured.ifBlank { "$fallbackPrefix-${Random.nextInt(1000, 9999)}" } + } + + private fun newClientId(broker: org.spotube.spotube.modules.settings.JamBroker): String { + val suffix = buildString(6) { + val chars = "0123456789abcdef" + repeat(6) { append(chars[Random.nextInt(chars.length)]) } + } + return "${broker.clientIdPrefix.ifBlank { "spotube" }}-$suffix" + } + + private fun persistLastCode(code: String) { + scope.launch { + runCatching { + val settings = settingsRepository.userSettings.value + if (settings.lastJamCode != code) { + settingsRepository.updateSettings(settings.copy(lastJamCode = code)) + } + } + } + } + + private fun JamMediaItem.toQueueEntry(): QueueEntry = when { + trackId.isNotBlank() -> QueueEntry.StreamingTrack( + track = MetadataTrack( + id = trackId, + title = title, + durationMs = durationMs, + trackNumber = null, + discNumber = null, + artists = listOf( + MetadataArtist.Basic(id = "", name = artist, thumbnails = emptyList(), externalUri = null) + ), + album = null, + // Keep the cover art flowing to participants — the wire carries it + // as coverUrl, so mirror it back into the reconstructed thumbnails. + thumbnails = coverUrl.takeIf { it.isNotBlank() } + ?.let { url -> listOf(Thumbnail(url = url, width = 0, height = 0)) }, + explicit = null, + popularity = null, + isrcCode = null, + externalUri = null), + url = "", + protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } + .getOrDefault(StreamProtocol.PROGRESSIVE), + addedBy = addedBy) + + else -> QueueEntry.LocalTrack( + name = title, + artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() }, + duration = durationMs, + album = album.ifBlank { null }, + coverBytes = null, + url = url, + addedBy = addedBy) + } + + private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean { + if (entry == null) return false + return when (entry) { + is QueueEntry.StreamingTrack -> + trackId.isNotBlank() && entry.track.id == trackId + + is QueueEntry.LocalTrack -> + url.isNotBlank() && entry.url == url && entry.name == title + } + } + + private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean = when { + this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> + this.track.id == other.track.id + + this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack -> + this.url == other.url && this.name == other.name + + else -> false + } + + private fun now(): Long = Clock.System.now().toEpochMilliseconds() + + companion object { + private const val HOST_TAKEOVER_DELAY_MS = 1_500L + private const val AUTO_ADVANCE_WINDOW_MS = 2_000L + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavTransitions.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavTransitions.kt new file mode 100644 index 00000000..4a793b51 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavTransitions.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.ui.unit.IntOffset +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.Scene + +private const val TRANSITION_DURATION_MILLIS = 300 +private const val FADE_THROUGH_OUT_MILLIS = 90 +private const val FADE_THROUGH_SCALE = 0.94f +private const val SLIDE_FRACTION = 0.25f +private const val BEHIND_SLIDE_FRACTION = 0.1f +private val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) + +// NavEntry.contentKey defaults to key.toString() +private val topLevelContentKeys = TOP_LEVEL_ROUTES.map { it.toString() }.toSet() + +/** + * The tab a scene belongs to: the last top-level route in its stack. Switching tabs changes + * this, while pushing/popping pages inside a tab doesn't. + */ +private fun Scene.tabRoot(): Any? = + (previousEntries + entries).lastOrNull { it.contentKey in topLevelContentKeys }?.contentKey + +private fun AnimatedContentTransitionScope>.isTabSwitch(): Boolean = + initialState.tabRoot() != targetState.tabRoot() + +/** Material fade-through: the old tab fades out, the new one fades in while scaling up slightly. */ +private fun fadeThrough(): ContentTransform { + val enter = fadeIn( + tween(TRANSITION_DURATION_MILLIS - FADE_THROUGH_OUT_MILLIS, FADE_THROUGH_OUT_MILLIS, EmphasizedDecelerate) + ) + scaleIn( + tween(TRANSITION_DURATION_MILLIS - FADE_THROUGH_OUT_MILLIS, FADE_THROUGH_OUT_MILLIS, EmphasizedDecelerate), + initialScale = FADE_THROUGH_SCALE + ) + return enter togetherWith fadeOut(tween(FADE_THROUGH_OUT_MILLIS)) +} + +/** + * Pushed/popped pages inside a tab: the page on top slides + fades while the page behind + * drifts slightly the other way. On pop the leaving page must stay on top (negative target + * z-index), otherwise the page being revealed is drawn over it and its exit is never seen. + */ +private fun AnimatedContentTransitionScope>.stackTransition( + forward: Boolean +): ContentTransform { + if (isTabSwitch()) return fadeThrough() + + val slide = tween(TRANSITION_DURATION_MILLIS, easing = EmphasizedDecelerate) + val enter: EnterTransition + val exit: ExitTransition + if (forward) { + enter = slideInHorizontally(slide) { (it * SLIDE_FRACTION).toInt() } + + fadeIn(tween(TRANSITION_DURATION_MILLIS * 2 / 3)) + exit = slideOutHorizontally(slide) { -(it * BEHIND_SLIDE_FRACTION).toInt() } + + fadeOut(tween(TRANSITION_DURATION_MILLIS / 2)) + } else { + enter = slideInHorizontally(slide) { -(it * BEHIND_SLIDE_FRACTION).toInt() } + + fadeIn(tween(TRANSITION_DURATION_MILLIS * 2 / 3)) + exit = slideOutHorizontally(slide) { (it * SLIDE_FRACTION).toInt() } + + fadeOut(tween(TRANSITION_DURATION_MILLIS * 2 / 3)) + } + return ContentTransform(enter, exit, targetContentZIndex = if (forward) 0f else -1f) +} + +val navPushTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = + { stackTransition(forward = true) } + +val navPopTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = + { stackTransition(forward = false) } + +val navPredictivePopTransitionSpec: AnimatedContentTransitionScope>.(Int) -> ContentTransform = + { stackTransition(forward = false) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationCommands.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationCommands.kt new file mode 100644 index 00000000..4cd57fb2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationCommands.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.navigation + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow + +// For providing navigation commands to the NavViewModel from other non-viewmodel classes like WebViewController +class NavigationCommands { + private val _navigationCommand = Channel(capacity = Channel.BUFFERED) + val navigationCommandFlow = _navigationCommand.receiveAsFlow() + + private val _navigationPopCommand = Channel(capacity = Channel.BUFFERED) + val navigationPopCommandFlow = _navigationPopCommand.receiveAsFlow() + + fun navigateTo(route: Routes) { + _navigationCommand.trySend(route) + } + + // Pops the last route. if route is provided, pops if the current route is the provided route, else pops unconditionally + fun pop(route: Routes? = null) { + _navigationPopCommand.trySend(route) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt new file mode 100644 index 00000000..0410a281 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.navigation + +import androidx.navigation3.runtime.NavKey +import org.spotube.spotube.modules.album.AlbumScreen +import org.spotube.spotube.modules.artist.ArtistScreen +import org.spotube.spotube.modules.blacklist.BlacklistScreen +import org.spotube.spotube.modules.devices.DevicesScreen +import org.spotube.spotube.modules.devices.RemoteControlScreen +import org.spotube.spotube.modules.jam.JamScreen +import org.spotube.spotube.modules.home.HomeScreen +import org.spotube.spotube.modules.library.LibraryScreen +import org.spotube.spotube.modules.library.local_tracks.LocalFolderScreen +import org.spotube.spotube.modules.library.local_tracks.LocalFolderViewModel +import org.spotube.spotube.modules.lyrics.LyricsScreen +import org.spotube.spotube.modules.playlist.PlaylistScreen +import org.spotube.spotube.modules.plugin.PluginScreen +import org.spotube.spotube.modules.plugin.guard.MetadataPluginGuard +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID +import org.spotube.spotube.modules.saved_tracks.SavedTracksScreen +import org.spotube.spotube.modules.search.SearchScreen +import org.spotube.spotube.modules.settings.SettingsScreen +import org.spotube.spotube.modules.settings.JamSettingsScreen +import org.spotube.spotube.modules.welcome.WelcomeScreen +import kotlinx.serialization.Serializable +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.module.dsl.singleOf +import org.koin.core.parameter.parametersOf +import org.koin.dsl.module +import org.koin.dsl.navigation3.navigation + +@Serializable +sealed interface Routes : NavKey { + @Serializable + data object Home : Routes + + @Serializable + data object Library : Routes + + @Serializable + data object Search : Routes + + @Serializable + data object Settings : Routes + + @Serializable + data object Plugins : Routes + + @Serializable + data object WebView : Routes + + @Serializable + data object Lyrics : Routes + + @Serializable + data class Playlist(val playlistId: String) : Routes + + @Serializable + data class Artist(val artistId: String) : Routes + + @Serializable + data class Album(val albumId: String) : Routes + + @Serializable + data object SavedTracks : Routes + + @Serializable + data object Blacklist : Routes + + @Serializable + data object RemoteControl : Routes + + @Serializable + data object Devices : Routes + + @Serializable + data object Jam : Routes + + @Serializable + data object JamSettings : Routes + + @Serializable + data object Welcome : Routes + + @Serializable + data class LocalFolder(val folderPath: String) : Routes +} + +@OptIn(KoinExperimentalAPI::class) +val navigationModule = module { + singleOf(::NavigationCommands) + navigation { + MetadataPluginGuard { + HomeScreen(viewModel = koinViewModel()) + } + } + navigation { + MetadataPluginGuard { + SearchScreen() + } + } + navigation { + // Not guarded as a whole: Local tracks and Downloads work without a plugin. + LibraryScreen() + } + navigation { + LocalFolderScreen( + viewModel = koinViewModel( + key = it.folderPath, + parameters = { parametersOf(it.folderPath) } + ), + onBack = { get().pop() }) + } + navigation { + SettingsScreen(settingsViewModel = koinViewModel()) + } + navigation { + PluginScreen(viewModel = koinViewModel()) + } + navigation {} + navigation { + MetadataPluginGuard(backButton = true) { + PlaylistScreen( + playlistId = it.playlistId, + viewModel = koinViewModel( + key = it.playlistId, + parameters = { parametersOf(it.playlistId) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + navigationCommands = get()) + } + } + navigation { + MetadataPluginGuard(backButton = true) { + ArtistScreen( + viewModel = koinViewModel( + key = it.artistId, + parameters = { parametersOf(it.artistId) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + navigationCommands = get()) + } + } + navigation { + MetadataPluginGuard(backButton = true) { + AlbumScreen( + albumId = it.albumId, + viewModel = koinViewModel( + key = it.albumId, + parameters = { parametersOf(it.albumId) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + navigationCommands = get()) + } + } + navigation { + LyricsScreen(viewModel = koinViewModel()) + } + navigation { + MetadataPluginGuard(backButton = true) { + SavedTracksScreen( + viewModel = koinViewModel( + key = SAVED_TRACKS_COLLECTION_ID, + parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + navigationCommands = get()) + } + } + navigation { + BlacklistScreen() + } + navigation { + DevicesScreen() + } + navigation { + RemoteControlScreen( + onDisconnect = { get().pop() } + ) + } + navigation { + JamScreen(navigationCommands = get()) + } + navigation { + JamSettingsScreen(viewModel = koinViewModel()) + } + navigation { + WelcomeScreen(viewModel = koinViewModel()) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt new file mode 100644 index 00000000..3dc7461e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSerializable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer +import androidx.savedstate.serialization.SavedStateConfiguration +import org.spotube.spotube.core.di.injectLogger +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import org.koin.core.component.KoinComponent + +val TOP_LEVEL_ROUTES: Set = setOf( + Routes.Home, + Routes.Search, + Routes.Library, + Routes.Settings, + // Bootstrap destination: needs its own back stack when the app starts on it. + Routes.Welcome) + +val serializersConfig = SavedStateConfiguration { + serializersModule = SerializersModule { + polymorphic(NavKey::class) { + subclass(Routes.Home::class, Routes.Home.serializer()) + subclass(Routes.Search::class, Routes.Search.serializer()) + subclass(Routes.Library::class, Routes.Library.serializer()) + subclass(Routes.Settings::class, Routes.Settings.serializer()) + subclass(Routes.Plugins::class, Routes.Plugins.serializer()) + subclass(Routes.WebView::class, Routes.WebView.serializer()) + subclass(Routes.Playlist::class, Routes.Playlist.serializer()) + subclass(Routes.Artist::class, Routes.Artist.serializer()) + subclass(Routes.Album::class, Routes.Album.serializer()) + subclass(Routes.Blacklist::class, Routes.Blacklist.serializer()) + subclass(Routes.Devices::class, Routes.Devices.serializer()) + subclass(Routes.Jam::class, Routes.Jam.serializer()) + subclass(Routes.Welcome::class, Routes.Welcome.serializer()) + subclass(Routes.LocalFolder::class, Routes.LocalFolder.serializer()) + } + } +} + +class NavigationState( + val startRoute: NavKey, + topLevelRoute: MutableState, + val backStacks: Map> +) : KoinComponent { + var topLevelRoute by topLevelRoute + + val stacksInUse: List + get() = if (topLevelRoute == startRoute) { + listOf(startRoute) + } else { + listOf(startRoute, topLevelRoute) + } + + internal val logger by injectLogger() + +} + +@Composable +fun rememberNavigationState( + startRoute: NavKey, + topLevelRoutes: Set +): NavigationState { + val topLevelRoute = rememberSerializable( + startRoute, + topLevelRoutes, + configuration = serializersConfig, + serializer = MutableStateSerializer(PolymorphicSerializer(NavKey::class)) + ) { + mutableStateOf(startRoute) + } + + val backStacks = topLevelRoutes.associateWith { key -> + rememberNavBackStack( + configuration = serializersConfig, + key + ) + } + + return remember(startRoute, topLevelRoutes) { + NavigationState( + startRoute = startRoute, + topLevelRoute = topLevelRoute, + backStacks = backStacks + ) + } +} + +@Composable +fun NavigationState.toEntries( + entryProvider: (NavKey) -> NavEntry +): SnapshotStateList> { + val decoratedEntries = backStacks.mapValues { (_, stack) -> + val decorators = listOf( + rememberSaveableStateHolderNavEntryDecorator()) + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider + ) + } + + val state = stacksInUse + .flatMap { decoratedEntries[it] ?: emptyList() } + .toMutableStateList() + logger.d { "Current entries: ${state.joinToString("->") { it.contentKey.toString() }}" } + return state +} + +class Navigator(val navigationState: NavigationState) : KoinComponent { + val logger by injectLogger() + fun navigate(route: NavKey) { + if (route in TOP_LEVEL_ROUTES) { + val targetStack = navigationState.backStacks[navigationState.topLevelRoute] + if (targetStack != null) { + // Clear everything except the very first entry (the root) + while (targetStack.size > 1) { + targetStack.removeLastOrNull() + } + } + navigationState.topLevelRoute = route + logger.d { "Navigating to top-level route: $route, cleared back stack: ${targetStack != null}" } + } else { + logger.d { "Navigating to route: $route" } + logger.d { + "Back stack before navigation: ${ + navigationState.backStacks[navigationState.topLevelRoute]?.joinToString( + "->" + ) + }" + } + val currentStack = navigationState.backStacks[navigationState.topLevelRoute] + if (currentStack?.lastOrNull() != route) { + currentStack?.add(route) + } + } + } + + fun pop() { + val currentStack = navigationState.backStacks[navigationState.topLevelRoute] + ?: error("Current back stack not found for route: ${navigationState.topLevelRoute}") + val currentRoute = currentStack.last() + if (currentRoute == navigationState.topLevelRoute) { + navigationState.topLevelRoute = navigationState.startRoute + } else { + currentStack.removeLastOrNull() + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt new file mode 100644 index 00000000..1157bec2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.network + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +/** + * The single place the app builds Ktor [HttpClient]s. + * + * It owns one client on the platform's default engine (Darwin on iOS, OkHttp on + * Android/desktop), so every client shares that engine's connection pool and + * no code path can accidentally pick an engine that doesn't work on a platform. + * + * - [default] and [json] are shared: use them directly and never close them. + * - [create] derives a client with extra configuration (timeouts, WebSockets, + * default request, …). It reuses the shared engine; the caller owns the + * returned client and may close it without affecting anyone else. + */ +class HttpClientFactory : AutoCloseable { + private val root = HttpClient() + + /** Plain client with no plugins installed. */ + val default: HttpClient get() = root + + /** Client that (de)serializes JSON bodies via [HttpJson]. */ + val json: HttpClient by lazy { + create { install(ContentNegotiation) { json(HttpJson) } } + } + + fun create(block: HttpClientConfig<*>.() -> Unit): HttpClient = root.config(block) + + override fun close() { + root.close() + } +} + +/** Lenient JSON used for HTTP bodies: remote APIs add fields over time. */ +val HttpJson = Json { + ignoreUnknownKeys = true + isLenient = true +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt new file mode 100644 index 00000000..91fe54a2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.newpipe + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import kotlinx.serialization.Serializable + +@Serializable +data class VideoSearchResult( + val id: String, + val title: String, + val url: String, + val uploader: String, + val durationMs: Long, + val thumbnailUrl: String +) + +@Serializable +data class VideoInfo( + val id: String, + val title: String, + val url: String, + val uploader: String, + val durationMs: Long, + val thumbnailUrl: String, + val audioStreams: List, + val videoStreams: List +) + +@Serializable +data class MediaStream( + val url: String, + val codec: String, + val container: String, + val bitrate: Int, + val isAudio: Boolean, + val isVideo: Boolean) + +/** Maps NewPipe's codec names onto the plugin's AudioFormat names (mp4a.40.2 → aac). */ +internal fun normalizeCodec(codec: String?): String { + val value = codec.orEmpty() + return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value +} + +/** Maps NewPipe's file suffixes onto the plugin's AudioFormat containers (m4a → mp4). */ +internal fun normalizeContainer(suffix: String): String = + if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt new file mode 100644 index 00000000..d624f10b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.newpipe + +enum class SearchKind { + /** YouTube Music "Songs" catalogue. */ + Songs, + + /** Regular YouTube videos; covers tracks that are not in the Music songs catalogue. */ + Videos +} + +expect class NewPipeService() { + suspend fun searchVideos(query: String, kind: SearchKind = SearchKind.Songs): List + suspend fun getVideoInfo(id: String): VideoInfo +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/paths/Paths.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/paths/Paths.kt new file mode 100644 index 00000000..07f002c0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/paths/Paths.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.paths + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +expect class Paths() { + fun getApplicationCacheDirPath(): String + fun getApplicationDataDirPath(): String + fun getUserDownloadsDirPath(): String + fun getMusicCacheDirPath(): String +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/playback/CollectionPlaybackHelper.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/playback/CollectionPlaybackHelper.kt new file mode 100644 index 00000000..6a425968 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/playback/CollectionPlaybackHelper.kt @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.playback + +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.album.AlbumRepository +import org.spotube.spotube.modules.artist.ArtistRepository +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.playlist.PlaylistRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.spotube.core.remote.RemoteCollectionType + +class CollectionPlaybackHelper( + private val albumRepository: AlbumRepository, + private val playlistRepository: PlaylistRepository, + private val savedTracksRepository: SavedTracksRepository, + private val artistRepository: ArtistRepository, + private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository) { + suspend fun playAlbum(albumId: String, shuffle: Boolean = false) { + if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return + val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it } + if (entries.isNotEmpty()) { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = QueueCollectionEntry.Album(albumId)) + } + } + + suspend fun addAlbumToQueue(albumId: String) { + val entries = fetchAllAlbumTracks(albumId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllToQueue( + entries, + collectionEntry = QueueCollectionEntry.Album(albumId)) + } + } + + suspend fun playAlbumNext(albumId: String) { + val entries = fetchAllAlbumTracks(albumId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + + suspend fun playAlbumFromTrack(albumId: String, track: MetadataTrack) { + val entries = fetchAllAlbumTracks(albumId) + if (entries.isEmpty()) return + + val queue = audioPlayerQueue.getQueue() + val queueIndex = queue.indexOfFirst { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true + } + if (queueIndex >= 0) { + audioPlayerQueue.jumpTo(queueIndex) + return + } + + val startPosition = entries.indexOfFirst { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true + }.coerceAtLeast(0) + + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = startPosition, + collectionEntry = QueueCollectionEntry.Album(albumId)) + } + + suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) { + if (!shuffle && audioPlayerQueue.isPlaylistPlaying(playlistId)) return + val entries = fetchAllPlaylistTracks(playlistId).let { if (shuffle) it.shuffled() else it } + if (entries.isNotEmpty()) { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = QueueCollectionEntry.Playlist(playlistId)) + } + } + + suspend fun addPlaylistToQueue(playlistId: String) { + val entries = fetchAllPlaylistTracks(playlistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllToQueue( + entries, + collectionEntry = QueueCollectionEntry.Playlist(playlistId)) + } + } + + suspend fun playPlaylistNext(playlistId: String) { + val entries = fetchAllPlaylistTracks(playlistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + + suspend fun playPlaylistFromTrack(playlistId: String, track: MetadataTrack) { + val entries = fetchAllPlaylistTracks(playlistId) + if (entries.isEmpty()) return + + val queue = audioPlayerQueue.getQueue() + val queueIndex = queue.indexOfFirst { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true + } + if (queueIndex >= 0) { + audioPlayerQueue.jumpTo(queueIndex) + return + } + + val startPosition = entries.indexOfFirst { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true + }.coerceAtLeast(0) + + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = startPosition, + collectionEntry = QueueCollectionEntry.Playlist(playlistId)) + } + + suspend fun playSavedTracks(shuffle: Boolean = false) { + if (!shuffle && audioPlayerQueue.isSavedTracksPlaying()) return + val entries = fetchAllSavedTracks().let { if (shuffle) it.shuffled() else it } + if (entries.isNotEmpty()) { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = QueueCollectionEntry.SavedTracks) + } + } + + suspend fun playArtistTopTracks(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = null) + } + } + + suspend fun addArtistTopTracksToQueue(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllToQueue(entries) + } + } + + suspend fun playArtistTopTracksNext(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + + suspend fun addSavedTracksToQueue() { + val entries = fetchAllSavedTracks() + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllToQueue( + entries, + collectionEntry = QueueCollectionEntry.SavedTracks) + } + } + + suspend fun playSavedTracksFromTrack(track: MetadataTrack) { + val entries = fetchAllSavedTracks() + if (entries.isEmpty()) return + + val queue = audioPlayerQueue.getQueue() + val queueIndex = queue.indexOfFirst { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true + } + if (queueIndex >= 0) { + audioPlayerQueue.jumpTo(queueIndex) + return + } + + val startPosition = entries.indexOfFirst { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true + }.coerceAtLeast(0) + + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = startPosition, + collectionEntry = QueueCollectionEntry.SavedTracks) + } + + private suspend fun fetchAllAlbumTracks(albumId: String): List { + val allTracks = mutableListOf() + var pagination = albumRepository.getAlbumTracks(albumId) + pagination?.items?.let { allTracks.addAll(it) } + + while (pagination?.nextPagination != null) { + pagination = albumRepository.getAlbumTracks(albumId, pagination.nextPagination) + pagination?.items?.let { allTracks.addAll(it) } + } + + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + + val filteredTracks = allTracks.filter { track -> + !isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds) + } + return filteredTracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + } + + private suspend fun fetchAllPlaylistTracks(playlistId: String): List { + val allTracks = mutableListOf() + var pagination = playlistRepository.getPlaylistTracks(playlistId) + pagination?.items?.let { allTracks.addAll(it) } + + while (pagination?.nextPagination != null) { + pagination = playlistRepository.getPlaylistTracks(playlistId, pagination.nextPagination) + pagination?.items?.let { allTracks.addAll(it) } + } + + val blacklistedTracks = blacklistRepository.getTracksSnapshot() + val blacklistedArtists = blacklistRepository.getArtistsSnapshot() + val blacklistedTrackIds = blacklistedTracks.map { it.id }.toSet() + val blacklistedArtistIds = blacklistedArtists.map { it.id }.toSet() + + val filteredTracks = allTracks.filter { track -> + !isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds) + } + + return filteredTracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + } + + /** + * Resolves the tracks of a collection without loading them into the local + * queue — used to suggest a collection into a jam session from a guest. + */ + suspend fun resolveCollectionTracks(type: RemoteCollectionType, id: String): List = + when (type) { + RemoteCollectionType.Playlist -> fetchAllPlaylistTracks(id).asTracks() + RemoteCollectionType.Album -> fetchAllAlbumTracks(id).asTracks() + RemoteCollectionType.ArtistTopTracks -> fetchArtistTopTracks(id).asTracks() + RemoteCollectionType.SavedTracks -> fetchAllSavedTracks().asTracks() + } + + private fun List.asTracks(): List = + mapNotNull { (it as? QueueEntry.StreamingTrack)?.track } + + private suspend fun fetchAllSavedTracks(): List { + val allTracks = mutableListOf() + var pagination = savedTracksRepository.getSavedTracks() + pagination?.items?.let { allTracks.addAll(it) } + + while (pagination?.nextPagination != null) { + pagination = savedTracksRepository.getSavedTracks(pagination.nextPagination) + pagination?.items?.let { allTracks.addAll(it) } + } + + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + + val filteredTracks = allTracks.filter { track -> + !isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds) + } + return filteredTracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + } + + private suspend fun fetchArtistTopTracks(artistId: String): List { + val tracks = artistRepository.topTracks(artistId).orEmpty() + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + return tracks + .filter { track -> !isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds) } + .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } + } + + private fun isTrackBlacklisted( + track: MetadataTrack, + blacklistedTrackIds: Set, + blacklistedArtistIds: Set): Boolean { + return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds } + } + + private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { + if (id.isNotBlank() && other.id.isNotBlank()) { + return id == other.id + } + + return title == other.title && + durationMs == other.durationMs && + album?.id == other.album?.id && + artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/ConnectionRequestDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/ConnectionRequestDialog.kt new file mode 100644 index 00000000..b5667d1f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/ConnectionRequestDialog.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.remote + +import org.spotube.millennium.composables.MillenniumAlertDialog +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.remote_request_allow +import spotube.composeapp.generated.resources.remote_request_allow_always +import spotube.composeapp.generated.resources.remote_request_deny +import spotube.composeapp.generated.resources.remote_request_message +import spotube.composeapp.generated.resources.remote_request_title + +@Composable +fun ConnectionRequestDialogHost() { + val handler: RemoteControlHandler = koinInject() + var pendingRequest by remember { mutableStateOf(null) } + + LaunchedEffect(handler) { + handler.incomingConnectionRequests.collect { request -> + pendingRequest = request + } + } + + pendingRequest?.let { request -> + MillenniumAlertDialog( + visible = true, + onDismissRequest = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) + pendingRequest = null + }, + title = { Text(stringResource(Res.string.remote_request_title)) }, + description = { + Text(stringResource(Res.string.remote_request_message, request.deviceName)) + }, + ) { + MillenniumButton( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways) + pendingRequest = null + }, + style = MillenniumButtonStyle.Quiet) { + Text(stringResource(Res.string.remote_request_allow_always)) + } + MillenniumButton( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) + pendingRequest = null + }, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.remote_request_deny)) + } + MillenniumButton( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow) + pendingRequest = null + }) { + Text(stringResource(Res.string.remote_request_allow)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt new file mode 100644 index 00000000..f6f474c3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt @@ -0,0 +1,245 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.remote + +import org.spotube.spotube.core.network.HttpClientFactory +import co.touchlab.kermit.Logger +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.websocket.CloseReason +import io.ktor.websocket.Frame +import io.ktor.websocket.DefaultWebSocketSession +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_error_connection_denied +import spotube.composeapp.generated.resources.devices_error_connection_failed +import spotube.composeapp.generated.resources.devices_error_connection_lost +import spotube.composeapp.generated.resources.devices_error_send_failed + +/** + * WebSocket client for controlling a remote Spotube instance. + * Connects to the remote device's `/control` endpoint and sends commands. + */ +class RemoteControlClient(httpClientFactory: HttpClientFactory) { + private val logger = Logger.withTag("RemoteControlClient") + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val httpClient = httpClientFactory.create { + install(WebSockets) + install(HttpTimeout) { + connectTimeoutMillis = 10_000 + requestTimeoutMillis = 30_000 + } + } + + private var session: DefaultWebSocketSession? = null + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _latestPlayerState = MutableStateFlow(null) + val latestPlayerState: StateFlow = _latestPlayerState.asStateFlow() + + private val _latestQueue = MutableStateFlow(null) + val latestQueue: StateFlow = _latestQueue.asStateFlow() + + /** + * [deviceName] identifies this controller to the host; [remoteDeviceName] is the + * host's advertised name, kept only so the UI can show what it is controlling. + */ + suspend fun connect( + host: String, + port: Int, + deviceId: String, + deviceName: String, + remoteDeviceName: String = "") { + if (_connectionState.value is ConnectionState.Connected) { + logger.w { "Already connected" } + return + } + + // Drop a stale attempt (e.g. one still waiting on the host's permission prompt). + closeSession() + _connectionState.value = ConnectionState.Connecting + try { + session = httpClient.webSocketSession { + url("ws://$host:$port/control") + header("X-Device-Id", deviceId) + header("X-Device-Name", deviceName) + } + + logger.i { "WebSocket connected to $host:$port, waiting for authorization..." } + + // Start receiving messages in a separate coroutine + scope.launch { + receiveLoop(host, port, remoteDeviceName) + } + } catch (e: Exception) { + logger.e(e) { "Failed to connect to $host:$port" } + closeSession() + _connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_failed) + } + } + + private suspend fun receiveLoop(host: String, port: Int, remoteDeviceName: String) { + val currentSession = session ?: return + logger.d { "Starting receive loop for $host:$port" } + try { + for (frame in currentSession.incoming) { + when (frame) { + is Frame.Text -> { + val text = frame.readText() + logger.d { "Received frame: $text" } + try { + val event = json.decodeFromString(RemoteControlEvent.serializer(), text) + logger.d { "Parsed event: $event" } + when (event) { + is RemoteControlEvent.Connected -> { + logger.i { "Connection authorized by server" } + _connectionState.value = ConnectionState.Connected(host, port, remoteDeviceName) + } + is RemoteControlEvent.WaitingForPermission -> { + logger.i { "Waiting for permission: ${event.message}" } + // Keep showing connecting state + } + else -> { + // Only store state updates after connection is established + if (_connectionState.value is ConnectionState.Connected) { + when (event) { + is RemoteControlEvent.PlayerState -> { + _latestPlayerState.value = event + } + is RemoteControlEvent.QueueUpdated -> { + _latestQueue.value = event + } + else -> {} + } + } + } + } + } catch (e: Exception) { + logger.w(e) { "Failed to parse message: $text" } + } + } + else -> {} + } + } + // Ktor consumes Close frames itself and just ends `incoming`, so the server + // closing the socket (denied request, remote control turned off, host quit) + // only shows up here. Without this the state stayed Connecting/Connected forever + // and every later connect attempt was swallowed. + val reason = currentSession.closeReason.await() + logger.i { "WebSocket closed by server: $reason" } + onServerClosed(currentSession, reason) + } catch (e: Exception) { + logger.e(e) { "Error in receive loop" } + if (session === currentSession) { + session = null + _connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_lost) + } + } + } + + private fun onServerClosed(closedSession: DefaultWebSocketSession, reason: CloseReason?) { + // disconnect() (or a newer connect()) already replaced this session; nothing to report. + if (session !== closedSession) return + session = null + val wasAuthorized = _connectionState.value is ConnectionState.Connected + _connectionState.value = when { + // The host refuses with VIOLATED_POLICY before authorizing: the user hit Deny + // (or dismissed the prompt), or remote control is turned off over there. + !wasAuthorized && reason?.knownReason == CloseReason.Codes.VIOLATED_POLICY -> + ConnectionState.Error(null, Res.string.devices_error_connection_denied) + wasAuthorized -> ConnectionState.Disconnected + else -> ConnectionState.Error(reason?.message?.ifBlank { null }, Res.string.devices_error_connection_failed) + } + } + + suspend fun sendCommand(command: RemoteControlCommand) { + val currentSession = session ?: run { + logger.w { "Not connected" } + return + } + + val envelope = CommandEnvelope( + commandId = randomShortId(), + command = command) + + try { + val text = json.encodeToString(CommandEnvelope.serializer(), envelope) + currentSession.send(Frame.Text(text)) + logger.d { "Sent command: $command" } + } catch (e: Exception) { + logger.e(e) { "Failed to send command" } + _connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_send_failed) + } + } + + suspend fun disconnect() { + closeSession() + _connectionState.value = ConnectionState.Disconnected + logger.i { "Disconnected" } + } + + /** Detaches before closing so the receive loop doesn't report this as a server close. */ + private suspend fun closeSession() { + val current = session ?: return + session = null + runCatching { current.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting")) } + } + + private fun randomShortId(): String { + val chars = "0123456789abcdef" + return buildString(8) { + repeat(8) { + append(chars[kotlin.random.Random.nextInt(chars.length)]) + } + } + } +} + +sealed interface ConnectionState { + data object Disconnected : ConnectionState + data object Connecting : ConnectionState + data class Connected(val host: String, val port: Int, val deviceName: String = "") : ConnectionState + /** + * [message] is the raw (untranslated) cause when available; [fallback] is the + * localized description to show when it isn't. + */ + data class Error(val message: String?, val fallback: StringResource) : ConnectionState +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt new file mode 100644 index 00000000..fc5a672d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt @@ -0,0 +1,457 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.remote + +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.PlayerState as AudioPlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.modules.settings.SettingsRepository +import io.ktor.server.websocket.WebSocketServerSession +import io.ktor.websocket.CloseReason +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlin.coroutines.resume +import kotlin.time.TimeSource +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.koin.core.component.KoinComponent + +class RemoteControlHandler( + private val settingsRepository: SettingsRepository, + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val collectionPlaybackHelper: CollectionPlaybackHelper) : KoinComponent { + val logger by injectLogger() + + val incomingConnectionRequests = MutableSharedFlow( + extraBufferCapacity = 16) + + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private val pendingRequestResolutions = mutableMapOf Unit>() + + suspend fun handleConnection(session: WebSocketServerSession) { + val settings = settingsRepository.userSettings.first() + if (!settings.allowRemoteControl) { + logger.w { "Rejecting remote control connection: remote control is disabled" } + session.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Remote control is disabled")) + return + } + + val deviceId = session.call.request.headers["X-Device-Id"] + val deviceName = session.call.request.headers["X-Device-Name"] ?: "Unknown" + + val isAllowed = deviceId != null && deviceId in settings.allowedRemoteDevices + + if (!isAllowed) { + // Send waiting for permission message + val waitingMessage = RemoteControlEvent.WaitingForPermission( + "Waiting for permission from $deviceName..." + ) + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), waitingMessage))) + + val request = ConnectionRequest( + deviceId = deviceId ?: "unknown", + deviceName = deviceName, + sessionId = session.call.request.headers["X-Request-Id"] + ?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}") + incomingConnectionRequests.emit(request) + val response = waitForRequestResolution(request.sessionId) + if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) { + session.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Connection denied")) + return + } + if (response == ConnectionRequestResponse.AllowAlways && deviceId != null) { + settingsRepository.updateSettings( + settings.copy( + allowedRemoteDevices = (settings.allowedRemoteDevices + deviceId).distinct() + ) + ) + logger.i { "Device $deviceId added to always-allowed devices" } + } + } + + // Send connected message + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Connected))) + logger.i { "Remote control connection established from $deviceName ($deviceId)" } + + // Broadcast initial player state so the controller shows current track info + broadcastState(session) + broadcastQueue(session) + + try { + // Periodically push player state so the controller's progress bar + // stays in sync even when no commands are being sent. + coroutineScope { + launch { + while (isActive) { + delay(1_000) + broadcastState(session) + } + } + handleControlLoop(session) + } + } catch (e: Exception) { + logger.w(e) { "Error in remote control session" } + } finally { + logger.d { "Closing session in finally block" } + session.close() + } + } + + fun resolveConnectionRequest(sessionId: String, response: ConnectionRequestResponse) { + pendingRequestResolutions.remove(sessionId)?.invoke(response) + } + + private suspend fun waitForRequestResolution(sessionId: String): ConnectionRequestResponse { + return suspendCancellableCoroutine { continuation -> + pendingRequestResolutions[sessionId] = { response -> + if (continuation.isActive) { + continuation.resume(response) + } + } + continuation.invokeOnCancellation { + pendingRequestResolutions.remove(sessionId) + } + } + } + + private suspend fun handleControlLoop(session: WebSocketServerSession) { + logger.d { "Starting control loop for session" } + for (frame in session.incoming) { + if (frame is Frame.Text) { + val text = frame.readText() + logger.d { "Received command: $text" } + try { + val envelope = json.decodeFromString(CommandEnvelope.serializer(), text) + handleCommand(session, envelope) + } catch (e: Exception) { + logger.w(e) { "Failed to parse remote control command" } + sendError(session, "Invalid command: ${e.message}") + } + } + } + logger.d { "Control loop exited normally" } + } + + private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) { + when (val command = envelope.command) { + is RemoteControlCommand.Play -> { + handleCollectionSource(command.source, RemoteCollectionAction.Play, command.shuffle) + } + is RemoteControlCommand.Pause -> { + audioPlayer.pause() + } + is RemoteControlCommand.TogglePlayPause -> { + val isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING + if (isPlaying) { + audioPlayer.pause() + waitForPlaybackState(expectPlaying = false) + } else { + audioPlayer.play() + waitForPlaybackState(expectPlaying = true) + } + } + is RemoteControlCommand.Seek -> { + audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms")) + } + is RemoteControlCommand.SetVolume -> { + audioPlayer.setVolume(command.volume) + } + is RemoteControlCommand.SkipNext -> { + audioPlayer.skipToNext() + } + is RemoteControlCommand.SkipPrevious -> { + audioPlayer.skipToPrevious() + } + is RemoteControlCommand.SetShuffle -> { + audioPlayer.shuffle(command.enabled) + } + is RemoteControlCommand.SetLoopMode -> { + val loopState = when (command.mode) { + "none" -> LoopState.NONE + "one" -> LoopState.ONE + "all" -> LoopState.ALL + else -> { + sendError(session, "Invalid loop mode: ${command.mode}") + return + } + } + audioPlayer.loop(loopState) + } + is RemoteControlCommand.AddToQueue -> { + handleCollectionSource(command.source, RemoteCollectionAction.AddToQueue) + } + is RemoteControlCommand.PlayNext -> { + handleCollectionSource(command.source, RemoteCollectionAction.PlayNext) + } + is RemoteControlCommand.PlayTrack -> { + audioPlayerQueue.load( + entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")), + autoPlay = true, + startPosition = 0, + collectionEntry = null) + } + is RemoteControlCommand.AddTrackToQueue -> { + audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = "")) + } + is RemoteControlCommand.PlayTrackNext -> { + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = command.track, url = ""))) + } + is RemoteControlCommand.AddTracksToQueue -> { + val entries = command.tracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + audioPlayerQueue.addAllToQueue(entries) + } + is RemoteControlCommand.PlayTracksNext -> { + val entries = command.tracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + audioPlayerQueue.addAllAfterCurrent(entries) + } + is RemoteControlCommand.PlayIndex -> { + audioPlayerQueue.jumpTo(command.index) + } + is RemoteControlCommand.RemoveFromQueue -> { + val queue = audioPlayerQueue.queueFlow.value + val entry = queue.firstOrNull { candidate -> + when (candidate) { + is QueueEntry.StreamingTrack -> candidate.track.id == command.mediaUrl + is QueueEntry.LocalTrack -> candidate.url == command.mediaUrl + } + } + if (entry != null) { + audioPlayerQueue.removeFromQueue(entry) + } else { + logger.w { "Remote remove: no matching queue entry for ${command.mediaUrl}" } + } + } + } + sendAck(session, envelope.commandId) + broadcastState(session) + broadcastQueue(session) + } + + private suspend fun sendAck(session: WebSocketServerSession, commandId: String) { + val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Ack(commandId)) + session.send(Frame.Text(text)) + } + + private suspend fun sendError(session: WebSocketServerSession, message: String) { + val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Error(message)) + session.send(Frame.Text(text)) + } + + /** + * Player state changes are applied asynchronously (e.g. ExoPlayer listener + * callbacks posted to the main looper), so after play/pause we poll until + * [playerStateFlow] reflects the expected state before broadcasting it back + * to the controller. Otherwise the client would see a stale (inverted) icon. + */ + private suspend fun waitForPlaybackState(expectPlaying: Boolean, timeoutMs: Long = 1_000) { + val timeoutAt = TimeSource.Monotonic.markNow() + timeoutMs.milliseconds + while (timeoutAt.hasNotPassedNow()) { + if ((audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) == expectPlaying) return + delay(25) + } + } + + /** + * Resolves a `spotube://` collection source URI (playlist/album/artist top + * tracks/saved tracks) and applies the requested action on the remote queue. + */ + private suspend fun handleCollectionSource( + source: String, + action: RemoteCollectionAction, + shuffle: Boolean = false) { + logger.d { "Remote collection $action for source: $source" } + when { + source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> { + val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id, shuffle) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id) + } + } + + source.startsWith(COLLECTION_ALBUM_PREFIX) -> { + val id = source.removePrefix(COLLECTION_ALBUM_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id, shuffle) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id) + } + } + + source.startsWith(COLLECTION_ARTIST_TOP_PREFIX) -> { + val id = source.removePrefix(COLLECTION_ARTIST_TOP_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playArtistTopTracks(id) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(id) + } + } + + source == COLLECTION_SAVED_TRACKS -> { + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks(shuffle) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() + RemoteCollectionAction.PlayNext -> { + // Saved tracks "play next" is not supported; add to queue instead + collectionPlaybackHelper.addSavedTracksToQueue() + } + } + } + + else -> logger.w { "Unknown remote collection source: $source" } + } + } + + private suspend fun broadcastState(session: WebSocketServerSession) { + val current = audioPlayerQueue.currentQueueEntryFlow.value + val state = RemoteControlEvent.PlayerState( + isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING, + positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds, + durationMs = audioPlayer.durationFlow.value.inWholeMilliseconds, + volume = audioPlayer.volumeFlow.value, + shuffleEnabled = audioPlayer.shuffleModeFlow.value, + loopMode = audioPlayer.loopStateFlow.value.name.lowercase(), + currentTrackId = current?.mediaKey(), + currentTrackTitle = current?.titleOrNull(), + currentTrackArtists = current?.artistsOrNull(), + currentTrackAlbum = current?.albumOrNull(), + currentTrackCoverUrl = current?.artworkUrl) + val text = json.encodeToString(RemoteControlEvent.serializer(), state) + session.send(Frame.Text(text)) + } + + private suspend fun broadcastQueue(session: WebSocketServerSession) { + val queue = audioPlayerQueue.queueFlow.value + val current = audioPlayerQueue.currentQueueEntryFlow.value + val currentIndex = if (current != null) { + queue.indexOfFirst { it.matchesCurrent(current) } + } else { + -1 + } + val event = RemoteControlEvent.QueueUpdated( + entries = queue.map { it.toRemoteQueueEntry() }, + currentIndex = currentIndex) + val text = json.encodeToString(RemoteControlEvent.serializer(), event) + session.send(Frame.Text(text)) + } + + private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean { + return when { + this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> { + this.track.id == current.track.id + } + + this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> { + this.url == current.url && this.name == current.name + } + + else -> false + } + } + + private fun QueueEntry.toRemoteQueueEntry(): RemoteQueueEntry = when (this) { + is QueueEntry.StreamingTrack -> RemoteQueueEntry( + mediaUrl = track.id, + trackId = track.id, + title = track.title, + artists = track.artists.joinToString(", ") { artist -> artist.name }, + album = track.album?.title, + coverUrl = artworkUrl, + durationMs = track.durationMs) + + is QueueEntry.LocalTrack -> RemoteQueueEntry( + mediaUrl = url, + trackId = url, + title = name, + artists = artists.joinToString(", "), + album = album, + coverUrl = null, + durationMs = duration) + } + + private fun QueueEntry.mediaKey(): String = when (this) { + is QueueEntry.StreamingTrack -> track.id + is QueueEntry.LocalTrack -> url + } + + private fun QueueEntry.titleOrNull(): String = when (this) { + is QueueEntry.StreamingTrack -> track.title + is QueueEntry.LocalTrack -> name + } + + private fun QueueEntry.artistsOrNull(): String = when (this) { + is QueueEntry.StreamingTrack -> track.artists.joinToString(", ") { artist -> artist.name } + is QueueEntry.LocalTrack -> artists.joinToString(", ") + } + + private fun QueueEntry.albumOrNull(): String? = when (this) { + is QueueEntry.StreamingTrack -> track.album?.title + is QueueEntry.LocalTrack -> album + } +} + +@Serializable +data class CommandEnvelope( + val commandId: String, + val command: RemoteControlCommand) + +private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/" +private const val COLLECTION_ALBUM_PREFIX = "spotube://album/" +private const val COLLECTION_ARTIST_TOP_PREFIX = "spotube://artist/" +private const val COLLECTION_SAVED_TRACKS = "spotube://saved_tracks" + +enum class RemoteCollectionAction { + Play, + AddToQueue, + PlayNext, +} + +data class ConnectionRequest( + val deviceId: String, + val deviceName: String, + val sessionId: String) + +enum class ConnectionRequestResponse { + Allow, + AllowAlways, + Deny, +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlProtocol.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlProtocol.kt new file mode 100644 index 00000000..f3d95cde --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlProtocol.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.remote + +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class RemoteControlCommand { + @Serializable + @SerialName("play") + data class Play(val source: String, val shuffle: Boolean = false) : RemoteControlCommand() + + @Serializable + @SerialName("pause") + data object Pause : RemoteControlCommand() + + @Serializable + @SerialName("togglePlayPause") + data object TogglePlayPause : RemoteControlCommand() + + @Serializable + @SerialName("seek") + data class Seek(val positionMs: Long) : RemoteControlCommand() + + @Serializable + @SerialName("setVolume") + data class SetVolume(val volume: Float) : RemoteControlCommand() + + @Serializable + @SerialName("skipNext") + data object SkipNext : RemoteControlCommand() + + @Serializable + @SerialName("skipPrevious") + data object SkipPrevious : RemoteControlCommand() + + @Serializable + @SerialName("setShuffle") + data class SetShuffle(val enabled: Boolean) : RemoteControlCommand() + + @Serializable + @SerialName("setLoopMode") + data class SetLoopMode(val mode: String) : RemoteControlCommand() + + @Serializable + @SerialName("addToQueue") + data class AddToQueue(val source: String) : RemoteControlCommand() + + @Serializable + @SerialName("playNext") + data class PlayNext(val source: String) : RemoteControlCommand() + + @Serializable + @SerialName("playTrack") + data class PlayTrack(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("addTrackToQueue") + data class AddTrackToQueue(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("playTrackNext") + data class PlayTrackNext(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("addTracksToQueue") + data class AddTracksToQueue(val tracks: List) : RemoteControlCommand() + + @Serializable + @SerialName("playTracksNext") + data class PlayTracksNext(val tracks: List) : RemoteControlCommand() + + @Serializable + @SerialName("playIndex") + data class PlayIndex(val index: Int) : RemoteControlCommand() + + @Serializable + @SerialName("removeFromQueue") + data class RemoveFromQueue(val mediaUrl: String) : RemoteControlCommand() +} + +@Serializable +sealed class RemoteControlEvent { + @Serializable + @SerialName("connected") + data object Connected : RemoteControlEvent() + + @Serializable + @SerialName("waitingForPermission") + data class WaitingForPermission(val message: String) : RemoteControlEvent() + + @Serializable + @SerialName("playerState") + data class PlayerState( + val isPlaying: Boolean, + val positionMs: Long, + val durationMs: Long, + val volume: Float, + val shuffleEnabled: Boolean, + val loopMode: String, + val currentTrackId: String?, + val currentTrackTitle: String?, + val currentTrackArtists: String?, + val currentTrackAlbum: String?, + val currentTrackCoverUrl: String?) : RemoteControlEvent() + + @Serializable + @SerialName("queueUpdated") + data class QueueUpdated( + val entries: List, + val currentIndex: Int) : RemoteControlEvent() + + @Serializable + @SerialName("ack") + data class Ack(val commandId: String) : RemoteControlEvent() + + @Serializable + @SerialName("error") + data class Error(val message: String) : RemoteControlEvent() +} + +@Serializable +data class RemoteQueueEntry( + val mediaUrl: String, + val trackId: String, + val title: String, + val artists: String, + val album: String?, + val coverUrl: String?, + val durationMs: Long) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt new file mode 100644 index 00000000..c0c19ae6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.remote + +import co.touchlab.kermit.Logger +import com.appstractive.dnssd.NetService +import org.spotube.spotube.core.discovery.DeviceDiscoveryService +import org.spotube.spotube.core.server.LocalServer +import org.spotube.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.random.Random + +/** + * Advertises this device on the local network via DNS-SD so that other Spotube + * instances can discover and control it. Advertises only while the + * "Allow remote control" setting is enabled and the local playback server is + * listening on the LAN (0.0.0.0). + * + * Registration is retried with backoff: NsdManager is flaky right after a cold + * start, and a single registration attempt is bounded by a short timeout so a + * stalled platform callback can't wedge a dispatcher thread for long. + */ +class RemoteControlService( + private val settingsRepository: SettingsRepository, + private val discoveryService: DeviceDiscoveryService, + private val localServer: LocalServer) { + private val log = Logger.withTag("RemoteControlService") + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _localDeviceId = MutableStateFlow("") + val localDeviceId: StateFlow = _localDeviceId.asStateFlow() + + private var advertisedService: NetService? = null + + /** A registration attempt that may have been left pending by the platform. */ + private var pendingService: NetService? = null + + private var registerJob: Job? = null + private var cleanupJob: Job? = null + + init { + // Ensure a stable device id exists and is persisted up front, so discovery + // can reliably filter out this device's own advertisement. + scope.launch { + _localDeviceId.value = resolveDeviceId() + } + scope.launch { + combine( + settingsRepository.userSettings, + localServer.port) { settings, port -> settings to port } + .distinctUntilChanged() + .collect { (settings, port) -> + if (settings.allowRemoteControl && port != null) { + if (registerJob?.isActive != true) { + registerJob = scope.launch { + registerLoop(settings.remoteControlDeviceName, port) + } + } + } else { + registerJob?.cancel() + registerJob = null + stopAdvertising() + } + } + } + } + + /** + * The service name this device advertises under, derived deterministically + * from settings so discovery can match it against the local advertisement. + */ + fun advertisedName(): String { + val deviceId = _localDeviceId.value.ifBlank { + settingsRepository.userSettings.value.remoteControlDeviceId + } + val configured = settingsRepository.userSettings.value.remoteControlDeviceName + return configured.ifBlank { "Spotube-${deviceId.take(6)}" } + } + + /** + * Kicks off (or restarts) the advertising loop. Used when the local-network + * permission is granted at runtime after earlier attempts failed. + */ + fun retryAdvertising() { + val settings = settingsRepository.userSettings.value + val port = localServer.port.value + if (!settings.allowRemoteControl || port == null) return + registerJob?.cancel() + // Clean up any in-flight registration the cancelled job may have leaked. + scheduleCleanup(pendingService) + pendingService = null + registerJob = scope.launch { + registerLoop(settings.remoteControlDeviceName, port) + } + } + + private suspend fun registerLoop(name: String, port: Int) { + val deviceId = resolveDeviceId() + _localDeviceId.value = deviceId + val serviceName = name.ifBlank { "Spotube-${deviceId.take(6)}" } + + var attempt = 0 + while (advertisedService == null && coroutineContext.isActive) { + attempt++ + // The user may have toggled the setting off during backoff. + if (!settingsRepository.userSettings.value.allowRemoteControl) return + val service = discoveryService.createService( + name = serviceName, + port = port, + deviceId = deviceId) + pendingService = service + try { + service.register(timeoutInMs = REGISTER_TIMEOUT_MS) + pendingService = null + advertisedService = service + log.i { "Advertising remote control service '$serviceName' on port $port (attempt $attempt)" } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + pendingService = null + log.w(e) { "Failed to advertise remote control service (attempt $attempt); retrying in ${retryDelayMs(attempt)}ms" } + // The library leaks the platform registration on timeout. Once the + // platform eventually completes it (success), isRegistered flips + // and unregister() will actually remove it — keep trying until then. + scheduleCleanup(service) + delay(retryDelayMs(attempt)) + } + } + } + + /** + * Repeatedly tries to unregister a service whose registration attempt failed. + * The library's `unregister()` is a no-op while the platform hasn't completed + * the registration, so poll until it has (or give up after a while). + */ + private fun scheduleCleanup(service: NetService?) { + if (service == null) return + cleanupJob?.cancel() + cleanupJob = scope.launch { + repeat(REGISTER_CLEANUP_TRIES) { + delay(1_000) + runCatching { service.unregister() } + } + } + } + + private suspend fun stopAdvertising() { + registerJob?.cancel() + registerJob = null + // Clean up any in-flight registration the cancelled job may have leaked. + scheduleCleanup(pendingService) + pendingService = null + if (advertisedService != null) { + runCatching { advertisedService?.unregister() } + advertisedService = null + log.i { "Stopped advertising remote control service" } + } + } + + private suspend fun resolveDeviceId(): String { + val settings = settingsRepository.userSettings.first() + if (settings.remoteControlDeviceId.isNotBlank()) { + return settings.remoteControlDeviceId + } + val generated = buildString(16) { + val chars = "0123456789abcdef" + repeat(16) { append(chars[Random.nextInt(chars.length)]) } + } + settingsRepository.updateSettings(settings.copy(remoteControlDeviceId = generated)) + return generated + } + + private fun retryDelayMs(attempt: Int): Long = when { + attempt >= 6 -> 5 * 60_000L + attempt >= 3 -> 30_000L + else -> 5_000L + } + + companion object { + // Generous enough that the library's timeout (which leaks the platform + // registration) rarely fires on a working system — registration callbacks + // normally arrive within a second. + private const val REGISTER_TIMEOUT_MS = 10_000L + + // How long to keep polling unregister() on a failed service, in seconds. + private const val REGISTER_CLEANUP_TRIES = 15 + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt new file mode 100644 index 00000000..3b37ff81 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt @@ -0,0 +1,435 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.remote + +import co.touchlab.kermit.Logger +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getPluralString +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_queue_added_track +import spotube.composeapp.generated.resources.jam_queue_added_tracks + +enum class PlaybackDestinationAction { + Play, + AddToQueue, + PlayNext, +} + +enum class RemoteCollectionType { + Playlist, + Album, + ArtistTopTracks, + SavedTracks, +} + +/** + * A playback request awaiting a destination choice (local device vs a connected + * remote device). [title] is the content label shown in the picker dialog. + */ +sealed interface PlaybackDestinationRequest { + val title: String + val action: PlaybackDestinationAction + + data class Collection( + override val title: String, + override val action: PlaybackDestinationAction, + val type: RemoteCollectionType, + val id: String, + val startTrack: MetadataTrack? = null, + val shuffle: Boolean = false) : PlaybackDestinationRequest + + data class Track( + override val title: String, + override val action: PlaybackDestinationAction, + val track: MetadataTrack) : PlaybackDestinationRequest + + data class Tracks( + override val title: String, + override val action: PlaybackDestinationAction, + val tracks: List) : PlaybackDestinationRequest +} + +/** + * Identifies a whole-collection action (e.g. "shuffle album X") while its tracks are being + * fetched and queued locally, so the button that started it can show a loading state. + */ +data class CollectionPlaybackKey( + val type: RemoteCollectionType, + val id: String, + val action: PlaybackDestinationAction, + val shuffle: Boolean = false) + +/** Loading flags for a collection screen's play / shuffle / add-to-queue buttons. */ +data class CollectionPlaybackUiState( + val isPlayLoading: Boolean = false, + val isShuffleLoading: Boolean = false, + val isAddToQueueLoading: Boolean = false) + +/** + * Routes playback actions (play / add to queue / play next) to either the local + * device or a connected remote device. When a remote device is connected the + * user is shown a destination picker; otherwise the action runs locally. + */ +class RemotePlaybackController( + private val remoteControlClient: RemoteControlClient, + private val collectionPlaybackHelper: CollectionPlaybackHelper, + private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository, + private val jamRoomService: JamRoomService) : KoinComponent { + private val logger = Logger.withTag("RemotePlaybackController") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _pendingRequest = MutableStateFlow(null) + val pendingRequest: StateFlow = _pendingRequest.asStateFlow() + + /** One-shot user-facing messages (e.g. "added to jam queue") for a snackbar host. */ + private val _events = MutableSharedFlow(extraBufferCapacity = 8) + val events: SharedFlow = _events.asSharedFlow() + + /** Collection actions currently running locally; see [CollectionPlaybackKey]. */ + private val _busyCollections = MutableStateFlow>(emptySet()) + val busyCollections: StateFlow> = _busyCollections.asStateFlow() + + fun collectionPlaybackUiState(type: RemoteCollectionType, id: String): Flow = + busyCollections.map { busy -> + CollectionPlaybackUiState( + isPlayLoading = CollectionPlaybackKey(type, id, PlaybackDestinationAction.Play) in busy, + isShuffleLoading = CollectionPlaybackKey(type, id, PlaybackDestinationAction.Play, shuffle = true) in busy, + isAddToQueueLoading = CollectionPlaybackKey(type, id, PlaybackDestinationAction.AddToQueue) in busy) + }.distinctUntilChanged() + + fun isRemoteConnected(): Boolean { + return remoteControlClient.connectionState.value is ConnectionState.Connected + } + + // ---------- Collection actions ---------- + + fun requestCollectionPlay( + type: RemoteCollectionType, + id: String, + title: String, + startTrack: MetadataTrack? = null, + shuffle: Boolean = false) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle)) + } + + fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.AddToQueue, type, id)) + } + + fun requestCollectionPlayNext(type: RemoteCollectionType, id: String, title: String) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.PlayNext, type, id)) + } + + // ---------- Single track actions ---------- + + fun requestTrackAddToQueue(track: MetadataTrack) { + request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.AddToQueue, track)) + } + + fun requestTrackPlayNext(track: MetadataTrack) { + request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.PlayNext, track)) + } + + // ---------- Bulk track actions ---------- + + fun requestTracksAddToQueue(tracks: List, title: String) { + if (tracks.isEmpty()) return + request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.AddToQueue, tracks)) + } + + fun requestTracksPlayNext(tracks: List, title: String) { + if (tracks.isEmpty()) return + request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.PlayNext, tracks)) + } + + // ---------- Picker resolution ---------- + + fun playLocally() { + val request = _pendingRequest.value ?: return + _pendingRequest.value = null + executeLocally(request) + } + + fun playOnRemote() { + val request = _pendingRequest.value ?: return + _pendingRequest.value = null + executeOnRemote(request) + } + + fun dismissPicker() { + _pendingRequest.value = null + } + + // ---------- Jam actions ---------- + + /** + * Adds a single track to the active jam queue. The host applies it to the + * local (shared) queue directly; a guest suggests it to the host over MQTT. + */ + fun addTrackToJam(track: MetadataTrack) { + if (jamRoomService.role.value == null) return + scope.launch { + try { + when (jamRoomService.role.value) { + JamRole.Host -> audioPlayerQueue.addToQueue( + QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantClientId) + ) + + JamRole.Guest -> jamRoomService.suggestTrack(track) + null -> return@launch + } + _events.emit(getString(Res.string.jam_queue_added_track)) + } catch (e: Exception) { + logger.e(e) { "Failed to add track to jam session" } + } + } + } + + /** + * Adds multiple tracks to the active jam queue (host applies locally, + * guest suggests to the host). + */ + fun addTracksToJam(tracks: List) { + if (tracks.isEmpty() || jamRoomService.role.value == null) return + scope.launch { + try { + when (jamRoomService.role.value) { + JamRole.Host -> audioPlayerQueue.addAllToQueue( + tracks.map { track -> + QueueEntry.StreamingTrack( + track = track, + url = "", + addedBy = jamRoomService.participantClientId) + } + ) + + JamRole.Guest -> jamRoomService.suggestPlaylist(tracks) + null -> return@launch + } + _events.emit(getPluralString(Res.plurals.jam_queue_added_tracks, tracks.size, tracks.size)) + } catch (e: Exception) { + logger.e(e) { "Failed to add tracks to jam session" } + } + } + } + + // ---------- Internals ---------- + + private fun request(request: PlaybackDestinationRequest) { + if (isRemoteConnected()) { + _pendingRequest.value = request + } else { + executeLocally(request) + } + } + + private fun executeLocally(request: PlaybackDestinationRequest) { + // Starting from a specific track (a tap in a track list) isn't tied to a collection button. + val busyKey = (request as? PlaybackDestinationRequest.Collection) + ?.takeIf { it.startTrack == null } + ?.let { CollectionPlaybackKey(it.type, it.id, it.action, it.shuffle) } + if (busyKey != null && busyKey in _busyCollections.value) return + busyKey?.let { key -> _busyCollections.update { it + key } } + scope.launch { + try { + executeLocallyNow(request) + } catch (e: Exception) { + logger.e(e) { "Failed to play ${request.title} locally" } + } finally { + busyKey?.let { key -> _busyCollections.update { it - key } } + } + } + } + + private suspend fun executeLocallyNow(request: PlaybackDestinationRequest) { + when (request) { + is PlaybackDestinationRequest.Collection -> { + val startTrack = request.startTrack + when (request.type) { + RemoteCollectionType.Playlist -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playPlaylist(request.id, request.shuffle) + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(request.id) + } + + RemoteCollectionType.Album -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playAlbum(request.id, request.shuffle) + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(request.id) + } + + RemoteCollectionType.ArtistTopTracks -> when (request.action) { + PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id) + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id) + } + + RemoteCollectionType.SavedTracks -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playSavedTracksFromTrack(startTrack) + } else { + collectionPlaybackHelper.playSavedTracks(request.shuffle) + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.addSavedTracksToQueue() + } + } + } + + is PlaybackDestinationRequest.Track -> { + val entry = QueueEntry.StreamingTrack(track = request.track, url = "") + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = listOf(entry), + autoPlay = true, + startPosition = 0, + collectionEntry = null) + } + + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addToQueue(entry) + } + + PlaybackDestinationAction.PlayNext -> { + val queue = audioPlayerQueue.getQueue() + queue.find { candidate -> + (candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true + }?.let { audioPlayerQueue.removeFromQueue(it) } + audioPlayerQueue.addAllAfterCurrent(listOf(entry)) + } + } + } + + is PlaybackDestinationRequest.Tracks -> { + val entries = request.tracks + .filter { track -> !isTrackBlacklisted(track) } + .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = null) + } + + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addAllToQueue(entries) + } + + PlaybackDestinationAction.PlayNext -> { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + } + } + } + + private fun executeOnRemote(request: PlaybackDestinationRequest) { + scope.launch { + try { + val command = when (request) { + is PlaybackDestinationRequest.Collection -> { + val source = when (request.type) { + RemoteCollectionType.Playlist -> "spotube://playlist/${request.id}" + RemoteCollectionType.Album -> "spotube://album/${request.id}" + RemoteCollectionType.ArtistTopTracks -> "spotube://artist/${request.id}" + RemoteCollectionType.SavedTracks -> "spotube://saved_tracks" + } + when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source, request.shuffle) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source) + } + } + + is PlaybackDestinationRequest.Track -> when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTrack(request.track) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTrackToQueue(request.track) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTrackNext(request.track) + } + + is PlaybackDestinationRequest.Tracks -> when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTracksNext(request.tracks) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTracksToQueue(request.tracks) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTracksNext(request.tracks) + } + } + remoteControlClient.sendCommand(command) + logger.i { "Sent remote ${request.action} for ${request.title}" } + } catch (e: Exception) { + logger.e(e) { "Failed to send remote playback command" } + } + } + } + + private suspend fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val artistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { + if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id + return title == other.title && + durationMs == other.durationMs && + album?.id == other.album?.id && + artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManager.kt new file mode 100644 index 00000000..6d58667d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManager.kt @@ -0,0 +1,265 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.scrobble + +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.plugin.ScrobblePluginSource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import kotlin.time.Clock + +/** + * Watches local playback and submits scrobbles through the currently selected scrobble plugin. + * + * Scrobbling follows the standard Last.fm rules, which plugins are expected to honour as well: + * - Tracks shorter than [MIN_TRACK_DURATION_MS] are never scrobbled. + * - The track must be listened to for at least half of its duration, or [MAX_SCROBBLE_THRESHOLD_MS], + * whichever comes first. + * + * Only time during which the player is actively [PlayerState.PLAYING] is counted, so pauses are + * ignored, and forward seeks don't credit unheard audio. The timestamp sent with the scrobble is + * the moment playback of the track started. Each play is scrobbled at most once; replaying or + * repeating the track starts a new scrobble session. + */ +class ScrobbleManager( + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val scrobblePluginSource: ScrobblePluginSource, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)) : KoinComponent { + + private val logger by injectLogger() + + private var activeTrackKey: String? = null + private var playbackStartedAtEpochSeconds: Long? = null + private var listenedMs: Long = 0L + private var lastPositionMs: Long? = null + private var scrobbled = false + + init { + scope.launch { + combine( + audioPlayerQueue.currentQueueEntryFlow, + audioPlayer.playerStateFlow, + audioPlayer.positionFlow, + audioPlayer.durationFlow) { entry, playerState, position, duration -> + PlaybackSnapshot( + entry = entry, + playerState = playerState, + positionMs = position.inWholeMilliseconds, + playerDurationMs = duration.inWholeMilliseconds) + }.collect(::onPlaybackSnapshot) + } + } + + private fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) { + val entry = snapshot.entry + if (entry == null) { + clearSession() + return + } + + val trackKey = entry.trackKey() + if (trackKey != activeTrackKey) { + beginSession(trackKey, snapshot.positionMs) + return + } + + val previousPositionMs = lastPositionMs + lastPositionMs = snapshot.positionMs + + if (snapshot.playerState != PlayerState.PLAYING) return + if (previousPositionMs == null) { + playbackStartedAtEpochSeconds = playbackStartedAtEpochSeconds ?: nowEpochSeconds() + return + } + + if (playbackStartedAtEpochSeconds == null) { + playbackStartedAtEpochSeconds = nowEpochSeconds() + } + + val deltaMs = snapshot.positionMs - previousPositionMs + if (deltaMs < 0) { + // A backward seek to the very beginning means the play started over + // (e.g. repeat one), so allow it to be scrobbled again. + if (snapshot.positionMs <= RESTART_POSITION_THRESHOLD_MS) { + restartSession(snapshot.positionMs) + } + return + } + + // A jump larger than a couple of position ticks is a forward seek or a + // buffering/app-suspension stall. Never credit unplayed audio for it. + if (deltaMs > MAX_COUNTED_POSITION_DELTA_MS) return + + listenedMs += deltaMs + if (scrobbled) return + + val durationMs = entry.durationMs().takeIf { it > 0 } ?: snapshot.playerDurationMs + val thresholdMs = scrobbleThresholdMs(durationMs) ?: return + if (listenedMs < thresholdMs) return + + if (scrobblePluginSource.selectedScrobblePlugin.value == null) { + // No scrobble plugin selected yet: keep the session hot so it can be + // submitted if the user selects one while the track is still playing. + return + } + + scrobbled = true + val track = entry.toScrobbleTrack( + timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(), + durationMs = durationMs) + scope.launch { submitScrobble(track) } + } + + private suspend fun submitScrobble(track: ScrobbleTrack) { + val plugin = scrobblePluginSource.selectedScrobblePlugin.value ?: return + try { + plugin.use { scrobbleAPI.scrobble(track) } + logger.d { "Scrobbled \"${track.trackName}\" by ${track.artistName} via ${plugin.pluginId}" } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e(e) { "Failed to scrobble \"${track.trackName}\" by ${track.artistName}" } + } + } + + private fun beginSession(trackKey: String, positionMs: Long) { + activeTrackKey = trackKey + restartSession(positionMs) + } + + private fun restartSession(positionMs: Long) { + listenedMs = 0L + playbackStartedAtEpochSeconds = null + lastPositionMs = positionMs + scrobbled = false + } + + private fun clearSession() { + activeTrackKey = null + listenedMs = 0L + playbackStartedAtEpochSeconds = null + lastPositionMs = null + scrobbled = false + } + + private fun nowEpochSeconds(): Long = Clock.System.now().epochSeconds + + private fun QueueEntry.trackKey(): String = when (this) { + is QueueEntry.StreamingTrack -> track.id + is QueueEntry.LocalTrack -> url + } + + private fun QueueEntry.durationMs(): Long = when (this) { + is QueueEntry.StreamingTrack -> track.durationMs + is QueueEntry.LocalTrack -> duration + } + + private fun QueueEntry.toScrobbleTrack(timestamp: Long, durationMs: Long): ScrobbleTrack { + return when (this) { + is QueueEntry.StreamingTrack -> ScrobbleTrack( + timestamp = timestamp, + trackId = track.id, + artistId = track.artists.firstOrNull()?.id.orEmpty(), + albumId = track.album?.id, + trackName = track.title, + artistName = track.artists.joinToString(", ") { it.name }, + albumName = track.album?.title, + streamingProvider = resolveStreamingProvider(track), + durationMs = durationMs) + + is QueueEntry.LocalTrack -> ScrobbleTrack( + timestamp = timestamp, + trackId = url, + artistId = "", + albumId = null, + trackName = name, + artistName = artists.joinToString(", "), + albumName = album, + streamingProvider = LOCAL_PROVIDER, + durationMs = durationMs) + } + } + + /** Maps a track's external URI to the streaming provider key scrobble plugins expect. */ + private fun resolveStreamingProvider(track: MetadataTrack): String { + val host = track.externalUri + ?.substringAfter("://", missingDelimiterValue = "") + ?.substringBefore('/') + ?.substringAfter('@') + ?.substringBefore(':') + ?.lowercase() + ?.removePrefix("www.") + .orEmpty() + + if (host.isBlank()) return UNKNOWN_PROVIDER + + return when { + "spotify" in host -> "spotify" + "apple" in host -> "apple_music" + "music.youtube" in host -> "youtube_music" + "youtube" in host || "youtu.be" in host -> "youtube" + "deezer" in host -> "deezer" + "tidal" in host -> "tidal" + "soundcloud" in host -> "soundcloud" + "amazon" in host -> "amazon_music" + "pandora" in host -> "pandora" + else -> host.substringBefore('.') + } + } + + private data class PlaybackSnapshot( + val entry: QueueEntry?, + val playerState: PlayerState, + val positionMs: Long, + val playerDurationMs: Long) + + companion object { + /** Tracks shorter than this are never scrobbled (Last.fm rule). */ + private const val MIN_TRACK_DURATION_MS = 30_000L + + /** Listening threshold is capped at four minutes (Last.fm rule). */ + private const val MAX_SCROBBLE_THRESHOLD_MS = 4 * 60 * 1000L + + /** Largest position delta credited as listening, covers position polling jitter. */ + private const val MAX_COUNTED_POSITION_DELTA_MS = 2_000L + + /** Positions at or below this are treated as the start of a fresh play. */ + private const val RESTART_POSITION_THRESHOLD_MS = 5_000L + + private const val UNKNOWN_PROVIDER = "unknown" + private const val LOCAL_PROVIDER = "local" + + private fun scrobbleThresholdMs(durationMs: Long): Long? { + if (durationMs < MIN_TRACK_DURATION_MS) return null + return minOf(durationMs / 2, MAX_SCROBBLE_THRESHOLD_MS) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/AlternativeTracksRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/AlternativeTracksRepository.kt new file mode 100644 index 00000000..9a4775a1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/AlternativeTracksRepository.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.server + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.plugin.AudioPluginSource +import org.koin.core.component.KoinComponent + +class AlternativeTracksRepository( + private val pluginManager: AudioPluginSource, + private val matchedTracksRepository: TrackSourceRepository, + private val streamingUrlRepository: StreamingUrlRepository, + private val audioPlayerQueue: AudioPlayerQueue, + private val cacheManager: CacheManager) : KoinComponent { + + private val logger by injectLogger() + + suspend fun resolveAlternatives(track: MetadataTrack): List { + val trackId = track.id + + val audioPlugin = pluginManager.selectedAudioPlugin.value + if (audioPlugin == null) { + logger.w { "No audio plugin selected while resolving alternatives for track $trackId" } + return emptyList() + } + + streamingUrlRepository.getCachedAlternatives(trackId, audioPlugin.pluginId)?.let { cached -> + logger.v { "Using cached alternatives for track $trackId" } + return cached + } + + val sources = runCatching { + audioPlugin.use { audioAPI.getStreamsByTrack(track) } + }.getOrElse { throwable -> + logger.w(throwable) { "Failed to fetch alternative sources for track $trackId" } + return emptyList() + } + + if (sources.isEmpty()) { + logger.d { "Plugin returned no alternative sources for track $trackId" } + return emptyList() + } + + streamingUrlRepository.cacheAlternatives(trackId, sources, audioPlugin.pluginId) + logger.d { "Resolved ${sources.size} alternative sources for track $trackId" } + return sources + } + + suspend fun getActiveSourceId(track: MetadataTrack): String? { + val audioPlugin = pluginManager.selectedAudioPlugin.value ?: return null + return matchedTracksRepository.getTrackSource(track, audioPlugin.pluginId)?.id + } + + suspend fun selectAlternative(track: MetadataTrack, source: AudioSource) { + val trackId = track.id + val audioPlugin = pluginManager.selectedAudioPlugin.value + if (audioPlugin == null) { + logger.w { "No audio plugin selected while selecting alternative for track $trackId" } + return + } + val basic = when (source) { + is AudioSource.Streamed -> source.toBasic() + is AudioSource.Basic -> source + } + + logger.i { "Selecting alternative source for track $trackId: ${basic.id} (${basic.title})" } + matchedTracksRepository.saveTrackSource(track, basic, audioPlugin.pluginId) + streamingUrlRepository.invalidateCachedStreamUrl(trackId) + streamingUrlRepository.invalidateCachedAlternatives(trackId) + cacheManager.invalidateCacheEntry(trackId) + audioPlayerQueue.reloadCurrent() + logger.d { "Alternative source selection complete for track $trackId" } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/CacheManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/CacheManager.kt new file mode 100644 index 00000000..28093b8a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/CacheManager.kt @@ -0,0 +1,274 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.server + +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.modules.settings.SettingsRepository +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HeadersBuilder +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.OutgoingContent +import io.ktor.server.application.ApplicationCall +import io.ktor.server.response.respond +import io.ktor.utils.io.writeFully +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath +import okio.SYSTEM +import okio.buffer +import okio.use + +@Serializable +data class CacheIndex( + val entries: List = emptyList() +) + +@Serializable +data class CacheEntry( + val trackId: String, + val filename: String, + val sizeBytes: Long, + val createdAtMs: Long, + val contentType: String = "application/octet-stream") + +class CacheManager( + private val paths: Paths, + private val settingsRepository: SettingsRepository) { + private val fileSystem: FileSystem = FileSystem.SYSTEM + private val cacheMutex: Mutex = Mutex() + private val json: Json = Json { ignoreUnknownKeys = true } + + companion object { + private val cacheIndexFileName = "cache_index.json".toPath() + } + + private fun resolveCacheFolder(): String? { + return settingsRepository.userSettings.value.cacheFolder + } + + private fun resolveSizeLimitMB(): Long { + return settingsRepository.userSettings.value.cacheSizeLimitMB + } + + fun resolveCacheDir(): Path { + val folder = resolveCacheFolder() + return if (!folder.isNullOrBlank()) { + folder.toPath() + } else { + paths.getMusicCacheDirPath().toPath() + } + } + + private fun getCacheIndexPath(): Path = resolveCacheDir() / cacheIndexFileName + + fun readCacheIndex(): CacheIndex { + val indexPath = getCacheIndexPath() + if (!fileSystem.exists(indexPath)) return CacheIndex() + return runCatching { + fileSystem.source(indexPath).buffer().use { source -> + json.decodeFromString(source.readUtf8()) + } + }.getOrDefault(CacheIndex()) + } + + fun writeCacheIndex(index: CacheIndex) { + val indexPath = getCacheIndexPath() + fileSystem.createDirectories(indexPath.parent ?: resolveCacheDir()) + fileSystem.sink(indexPath).buffer().use { sink -> + sink.writeUtf8(json.encodeToString(index)) + } + } + + fun findCachedEntry(trackId: String): Pair? { + val index = readCacheIndex() + val entry = index.entries.firstOrNull { it.trackId == trackId } ?: return null + val filePath = resolveCacheDir() / entry.filename.toPath() + return if (fileSystem.exists(filePath)) filePath to entry else null + } + + suspend fun invalidateCacheEntry(trackId: String) { + cacheMutex.withLock { + val index = readCacheIndex() + val entry = index.entries.firstOrNull { it.trackId == trackId } ?: return + val filePath = resolveCacheDir() / entry.filename.toPath() + if (fileSystem.exists(filePath)) { + fileSystem.delete(filePath) + } + writeCacheIndex(index.copy(entries = index.entries.filter { it.trackId != trackId })) + } + } + + fun resolveCacheFilename(track: MetadataTrack, contentType: String?): String { + val artists = track.artists.joinToString(", ") { it.name }.sanitizeFilenamePart() + val title = track.title.sanitizeFilenamePart() + val ext = contentTypeToExtension(contentType) + return "$artists - $title.$ext" + } + + suspend fun evictIfNeeded() { + val limitMB = resolveSizeLimitMB() + if (limitMB <= 0L) return + + cacheMutex.withLock { + val cacheDir = resolveCacheDir() + if (!fileSystem.exists(cacheDir)) return + + val index = readCacheIndex() + val sorted = index.entries.sortedBy { it.createdAtMs }.toMutableList() + + var totalSize = sorted.sumOf { it.sizeBytes } + val limitBytes = limitMB * 1024 * 1024 + + while (totalSize > limitBytes && sorted.isNotEmpty()) { + val entry = sorted.removeFirst() + val filePath = cacheDir / entry.filename.toPath() + if (fileSystem.exists(filePath)) { + fileSystem.delete(filePath) + } + totalSize -= entry.sizeBytes + } + + writeCacheIndex(index.copy(entries = sorted)) + } + } + + suspend fun commitCacheEntry( + trackId: String, + filename: String, + sizeBytes: Long, + contentType: String) { + val entry = CacheEntry( + trackId = trackId, + filename = filename, + sizeBytes = sizeBytes, + createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(), + contentType = contentType) + cacheMutex.withLock { + val index = readCacheIndex() + writeCacheIndex(CacheIndex( + entries = index.entries.filter { it.trackId != trackId } + entry + )) + } + } + + suspend fun serveCacheHead(call: ApplicationCall, entry: CacheEntry) { + call.respond(object : OutgoingContent.NoContent() { + override val status: HttpStatusCode = HttpStatusCode.OK + override val headers: Headers = HeadersBuilder().apply { + append(HttpHeaders.ContentType, entry.contentType) + append(HttpHeaders.ContentLength, entry.sizeBytes.toString()) + append(HttpHeaders.AcceptRanges, "bytes") + }.build() + override val contentLength: Long = entry.sizeBytes + override val contentType: ContentType = entry.toContentType() + }) + } + + suspend fun serveCacheGet(call: ApplicationCall, filePath: Path, entry: CacheEntry) { + val fileSize = entry.sizeBytes + val rangeHeader = call.request.headers[HttpHeaders.Range] + + if (rangeHeader != null) { + val range = parseRange(rangeHeader, fileSize) + if (range != null) { + val (start, end) = range + val length = end - start + 1 + val contentRange = "bytes $start-$end/$fileSize" + fileSystem.source(filePath).buffer().use { source -> + source.skip(start) + val bytes = source.readByteArray(length) + call.respond(object : OutgoingContent.WriteChannelContent() { + override val status: HttpStatusCode = HttpStatusCode.PartialContent + override val contentLength: Long = bytes.size.toLong() + override val contentType: ContentType = entry.toContentType() + override val headers: Headers = HeadersBuilder().apply { + append(HttpHeaders.ContentRange, contentRange) + append(HttpHeaders.AcceptRanges, "bytes") + }.build() + override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { + channel.writeFully(bytes) + } + }) + } + return + } + call.respond(object : OutgoingContent.NoContent() { + override val status: HttpStatusCode = HttpStatusCode.RequestedRangeNotSatisfiable + override val headers: Headers = HeadersBuilder().apply { + append(HttpHeaders.ContentRange, "bytes */$fileSize") + }.build() + }) + return + } + + fileSystem.source(filePath).buffer().use { source -> + val bytes = source.readByteArray() + call.respond(object : OutgoingContent.WriteChannelContent() { + override val status: HttpStatusCode = HttpStatusCode.OK + override val contentLength: Long = bytes.size.toLong() + override val contentType: ContentType = entry.toContentType() + override val headers: Headers = HeadersBuilder().apply { + append(HttpHeaders.AcceptRanges, "bytes") + }.build() + override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { + channel.writeFully(bytes) + } + }) + } + } +} + +internal fun parseRange(rangeHeader: String, fileSize: Long): Pair? { + val match = Regex("""bytes=(\d*)-(\d*)""").find(rangeHeader) ?: return null + val startStr = match.groupValues[1] + val endStr = match.groupValues[2] + if (startStr.isEmpty() && endStr.isEmpty()) return null + + val start = startStr.toLongOrNull() ?: 0L + val end = if (endStr.isNotEmpty()) endStr.toLongOrNull() ?: (fileSize - 1) else (fileSize - 1) + + if (start >= fileSize || start > end) return null + return start to end.coerceAtMost(fileSize - 1) +} + +internal fun CacheEntry.toContentType(): ContentType = + runCatching { ContentType.parse(contentType) }.getOrDefault(ContentType.Application.OctetStream) + +internal fun contentTypeToExtension(contentType: String?): String { + return when { + contentType == null -> "dat" + "webm" in contentType -> "webm" + "ogg" in contentType || "opus" in contentType -> "ogg" + "mp4" in contentType || "m4a" in contentType || "aac" in contentType -> "m4a" + "mpeg" in contentType || "mp3" in contentType -> "mp3" + "flac" in contentType -> "flac" + "wav" in contentType -> "wav" + else -> "dat" + } +} + +internal fun String.sanitizeFilenamePart(): String { + return this.replace(Regex("""[/\\:*?"<>|]"""), "_").take(200).trim() +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt new file mode 100644 index 00000000..14e4837c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt @@ -0,0 +1,229 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.server + +import org.spotube.spotube.core.network.HttpClientFactory +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.remote.RemoteControlHandler +import org.spotube.spotube.modules.settings.SettingsViewModel +import io.ktor.client.plugins.HttpTimeout +import io.ktor.http.HttpMethod +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.head +import io.ktor.server.routing.routing +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.webSocket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koin.core.component.KoinComponent + +class LocalServer( + settingsViewModel: SettingsViewModel, + private val streamingUrlRepository: StreamingUrlRepository, + private val audioPlayerQueue: AudioPlayerQueue, + private val cacheManager: CacheManager, + private val remoteControlHandler: RemoteControlHandler, + httpClientFactory: HttpClientFactory) : KoinComponent { + + val logger by injectLogger() + private val httpClient = httpClientFactory.create { + // A stalled upstream connection must not wedge an engine thread + // forever. Only the connect phase is bounded — the proxy streams long + // audio bodies, so request/socket timeouts would cut playback short. + install(HttpTimeout) { + connectTimeoutMillis = 10_000 + } + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val serverMutex = Mutex() + private var portWatcher: Job? = null + private val serverState = MutableStateFlow?>(null) + + val server: StateFlow?> = serverState.asStateFlow() + + private val activePort = MutableStateFlow(null) + val port = activePort.asStateFlow() + + private val activeHost = MutableStateFlow(null) + + val baseUrl = activePort.map { port -> + port?.let { "http://$HOST_LOCAL:$it" } + }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + + private val cachedCacheEnabled = MutableStateFlow(false) + + private val streamProxy by lazy { + StreamProxy( + httpClient = httpClient, + streamingUrlRepository = streamingUrlRepository, + cacheManager = cacheManager, + audioPlayerQueue = audioPlayerQueue, + isCachingEnabled = { cachedCacheEnabled.value }, + activePort = { activePort.value }, + scope = scope) + } + + companion object { + private const val HOST_LOCAL = "127.0.0.1" + private const val HOST_LAN = "0.0.0.0" + } + + init { + logger.d { "Starting playback proxy port watcher" } + portWatcher = scope.launch { + settingsViewModel.settingsState + .mapNotNull { it?.let { s -> s.playbackProxyServerPort to s.allowRemoteControl } } + .distinctUntilChanged() + .collectLatest { (port, allowRemoteControl) -> + logger.d { "Observed server config change: port=$port, allowRemoteControl=$allowRemoteControl" } + restartServer(port, allowRemoteControl) + } + } + scope.launch { + settingsViewModel.settingsState + .collect { settings -> + if (settings != null) { + cachedCacheEnabled.value = settings.enableMusicCaching + } + } + } + } + + @Suppress("unused") + suspend fun stop() { + logger.i { "Stopping playback proxy server and watcher" } + serverMutex.withLock { + stopServerLocked() + } + portWatcher?.cancel() + portWatcher = null + runCatching { httpClient.close() } + .onFailure { throwable -> + logger.w(throwable) { "Failed to close proxy HTTP client cleanly" } + } + scope.cancel() + logger.d { "Playback proxy server stopped" } + } + + private suspend fun restartServer(port: Int, allowRemoteControl: Boolean) { + val host = if (allowRemoteControl) HOST_LAN else HOST_LOCAL + serverMutex.withLock { + if (serverState.value != null && activePort.value == port && activeHost.value == host) { + logger.v { "Playback proxy server already running on $host:$port; skipping restart" } + return + } + + logger.d { "Restarting playback proxy server on $host:$port (remoteControl=$allowRemoteControl)" } + stopServerLocked() + + val bound = runCatching { + startServerWithPortFallback( + host = host, + preferredPort = port, + onBindFailure = { failedPort, cause -> + logger.w(cause) { "Playback proxy could not bind to $host:$failedPort" } + } + ) { configureRoutes() } + }.getOrElse { throwable -> + logger.e(throwable) { "Playback proxy failed to start on $host (requested port $port)" } + return + } + + if (bound.usedFallback) { + logger.w { "Port $port is unavailable; playback proxy is using $host:${bound.port} instead" } + } + serverState.value = bound.engine + activePort.value = bound.port + activeHost.value = host + logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$host:${bound.port}"}" } + } + } + + private fun stopServerLocked() { + serverState.value?.let { engine -> + logger.d { "Stopping playback proxy server on port ${activePort.value}" } + runCatching { engine.stop(gracePeriodMillis = 1_000, timeoutMillis = 3_000) } + .onSuccess { + logger.d { "Playback proxy server stopped cleanly" } + } + .onFailure { throwable -> + logger.w(throwable) { "Failed to stop playback proxy server cleanly" } + } + } + serverState.value = null + activePort.value = null + activeHost.value = null + } + + private fun Application.configureRoutes() { + install(WebSockets) { + pingPeriodMillis = 30_000L + timeoutMillis = 60_000L + maxFrameSize = 10L * 1024 * 1024 + masking = false + } + routing { + get("/health") { + call.respondText("ok") + } + + head("/stream/{trackId}") { + streamProxy.handleStreamRequest(call, HttpMethod.Head) + } + + get("/stream/{trackId}") { + streamProxy.handleStreamRequest(call, HttpMethod.Get) + } + + get("/manifest/{trackId}") { + streamProxy.handleManifestRequest(call) + } + + get("/segment/{trackId}") { + streamProxy.handleSegmentRequest(call) + } + + webSocket("/control") { + remoteControlHandler.handleConnection(this) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt new file mode 100644 index 00000000..a93bb012 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.server + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.db.Database +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * A matched [AudioSource.Basic] tagged with the id of the audio plugin that produced it. + * The [pluginId] tag lets [MatchedTracksRepository] detect and discard matches that were + * made by a plugin the user is no longer using, since a source id from one plugin (e.g. a + * YouTube video id) is meaningless - or worse, silently wrong - when handed to a different + * plugin's `getStreamsOfAudioSource`. + */ +@Serializable +data class PluginTaggedAudioSource( + val pluginId: String, + val source: AudioSource.Basic) + +/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */ +interface TrackSourceRepository { + suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? + suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) +} + +class MatchedTracksRepository(private val database: Database) : TrackSourceRepository { + /** + * Returns the previously matched source for [track], but only if it was produced by + * [pluginId]. A match saved by a different (or since-removed) plugin is treated as a + * cache miss rather than being handed to the wrong plugin. + */ + override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? { + return database.matchedTracksDataStore.data.map { prefs -> + val json = prefs[stringPreferencesKey(track.id)] ?: return@map null + val tagged = try { + Json.decodeFromString(json) + } catch (_: Exception) { + // Legacy entries stored before plugin-tagging was introduced; treat as + // unverifiable and force a fresh match instead of risking a wrong plugin. + return@map null + } + if (tagged.pluginId != pluginId) return@map null + tagged.source + }.first() + } + + override suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) { + database.matchedTracksDataStore.edit { prefs -> + prefs[stringPreferencesKey(track.id)] = + Json.encodeToString(PluginTaggedAudioSource(pluginId, source)) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt new file mode 100644 index 00000000..43d44309 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +package org.spotube.spotube.core.server + +import io.ktor.server.application.Application +import io.ktor.server.cio.CIO +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.connector +import io.ktor.server.engine.embeddedServer +import io.ktor.server.application.serverConfig +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.seconds + +internal class BoundServer(val engine: EmbeddedServer<*, *>, val port: Int, val usedFallback: Boolean) + +/** + * Starts the playback proxy on [preferredPort] and confirms the socket is actually bound. + * + * CIO's `start(wait = false)` returns before binding, so a port that is already taken fails + * later on a worker thread and the server looks started while nothing is listening. If the + * preferred port can't be bound, retry on an OS-assigned free port (port 0). + * + * The caller must read [BoundServer.port] instead of assuming [preferredPort]. + * + * `reuseAddress` lets a restarted app rebind its old port while connections from the previous + * run (e.g. the player's) are still in TIME_WAIT. + */ +internal suspend fun startServerWithPortFallback( + host: String, + preferredPort: Int, + onBindFailure: (port: Int, cause: Throwable) -> Unit = { _, _ -> }, + module: Application.() -> Unit +): BoundServer { + val candidates = listOf(preferredPort, 0).distinct() + var lastFailure: Throwable? = null + + // CIO reports a failed bind from its accept coroutine. Unhandled, that exception kills the + // process on Kotlin/Native (iOS) before the fallback below gets to run; the failure still + // reaches us through resolvedConnectors(), so it only needs to be contained here. + val swallowBindFailure = CoroutineExceptionHandler { _, _ -> } + + for (candidate in candidates) { + val engine = embeddedServer( + factory = CIO, + rootConfig = serverConfig { + parentCoroutineContext = swallowBindFailure + module { module() } + }, + configure = { + connector { + this.host = host + this.port = candidate + } + reuseAddress = true + }) + try { + engine.start(wait = false) + val boundPort = withTimeout(5.seconds) { engine.engine.resolvedConnectors().first().port } + return BoundServer(engine, boundPort, usedFallback = candidate != preferredPort) + } catch (e: Throwable) { + runCatching { engine.stop(gracePeriodMillis = 0, timeoutMillis = 500) } + // A failed bind surfaces as the engine job's CancellationException; only rethrow + // when it is the caller's own coroutine that was cancelled. + currentCoroutineContext().ensureActive() + onBindFailure(candidate, e) + lastFailure = e + } + } + throw lastFailure ?: IllegalStateException("No port candidates to bind") +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt new file mode 100644 index 00000000..900d830a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt @@ -0,0 +1,597 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.server + +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import io.ktor.client.HttpClient +import io.ktor.client.request.prepareRequest +import io.ktor.client.request.request +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsChannel +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HeadersBuilder +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.OutgoingContent +import io.ktor.http.decodeURLQueryComponent +import io.ktor.http.encodeURLParameter +import io.ktor.server.application.ApplicationCall +import io.ktor.server.response.respond +import io.ktor.util.cio.ChannelWriteException +import io.ktor.utils.io.ClosedWriteChannelException +import io.ktor.utils.io.copyTo +import io.ktor.utils.io.readAvailable +import io.ktor.utils.io.writeFully +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okio.FileSystem +import okio.IOException +import okio.Path.Companion.toPath +import okio.SYSTEM +import okio.buffer +import okio.use +import org.koin.core.component.KoinComponent +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource + +internal class StreamProxy( + private val httpClient: HttpClient, + private val streamingUrlRepository: StreamingUrlRepository, + private val cacheManager: CacheManager, + private val audioPlayerQueue: AudioPlayerQueue, + private val isCachingEnabled: () -> Boolean, + private val activePort: () -> Int?, + private val scope: CoroutineScope): KoinComponent { + val logger by injectLogger() + + companion object { + private const val HOST = "127.0.0.1" + } + + private val fileSystem = FileSystem.SYSTEM + private val streamingInProgress = mutableSetOf() + private val streamingMutex = Mutex() + + private fun getTrackMetadata(trackId: String): MetadataTrack? { + return audioPlayerQueue.queueFlow.value + .filterIsInstance() + .firstOrNull { it.track.id == trackId } + ?.track + } + + suspend fun handleStreamRequest(call: ApplicationCall, requestMethod: HttpMethod) { + val trackId = call.parameters["trackId"]?.trim().orEmpty() + if (trackId.isBlank()) { + logger.w { "Rejecting /stream request with missing track id" } + call.respond(HttpStatusCode.BadRequest, "Missing track id") + return + } + + logger.i { + "[Req] $requestMethod /stream/$trackId from=${call.request.local.remoteHost} " + + "version=${call.request.local.version} range=${call.request.headers[HttpHeaders.Range]} " + + "ua=${call.request.headers[HttpHeaders.UserAgent]} " + + "connection=${call.request.headers[HttpHeaders.Connection]} caching=${isCachingEnabled()}" + } + + if (isCachingEnabled()) { + val cachedEntry = cacheManager.findCachedEntry(trackId) + if (cachedEntry != null) { + val (filePath, entry) = cachedEntry + logger.d { "Serving track $trackId from cache" } + when (requestMethod) { + HttpMethod.Head -> cacheManager.serveCacheHead(call, entry) + HttpMethod.Get -> cacheManager.serveCacheGet(call, filePath, entry) + else -> call.respond(HttpStatusCode.MethodNotAllowed) + } + return + } + + val isFirstRequest = streamingMutex.withLock { + if (trackId in streamingInProgress) { + false + } else { + streamingInProgress.add(trackId) + true + } + } + + if (!isFirstRequest) { + logger.d { "Track $trackId is being cached, waiting for cache" } + var waited = 0 + while (waited < 30) { + delay(200.milliseconds) + waited++ + val entry = cacheManager.findCachedEntry(trackId) + if (entry != null) { + val (filePath, e) = entry + logger.d { "Track $trackId became available from cache" } + when (requestMethod) { + HttpMethod.Head -> cacheManager.serveCacheHead(call, e) + HttpMethod.Get -> cacheManager.serveCacheGet(call, filePath, e) + else -> call.respond(HttpStatusCode.MethodNotAllowed) + } + return + } + // The caching request ended without committing (player disconnected or the + // source was switched); nothing left to wait for. + if (streamingMutex.withLock { trackId !in streamingInProgress }) break + } + logger.d { "No cache for track $trackId after waiting, falling through to upstream" } + } + + try { + doStreamFromUpstream(call, trackId, requestMethod) + } finally { + if (isFirstRequest) { + streamingMutex.withLock { streamingInProgress.remove(trackId) } + } + } + return + } + + doStreamFromUpstream(call, trackId, requestMethod) + } + + private suspend fun doStreamFromUpstream(call: ApplicationCall, trackId: String, requestMethod: HttpMethod) { + val resolveStart = TimeSource.Monotonic.markNow() + val streamInfo = streamingUrlRepository.resolveStreamInfo(trackId) + logger.i { + "[Req] resolved stream for $trackId in ${resolveStart.elapsedNow()}: " + + "protocol=${streamInfo?.protocol} codec=${streamInfo?.codec} container=${streamInfo?.container} " + + "url=${streamInfo?.url?.take(120)}" + } + if (streamInfo == null) { + logger.w { "Unable to resolve stream for track $trackId" } + call.respond(HttpStatusCode.NotFound, "Unable to resolve stream for track $trackId") + return + } + + if (streamInfo.protocol != StreamProtocol.PROGRESSIVE) { + val port = activePort() ?: 8080 + val manifestUrl = "http://$HOST:$port/manifest/${trackId}" + logger.d { "Redirecting $trackId to manifest URL: $manifestUrl" } + call.response.headers.append(HttpHeaders.Location, manifestUrl) + call.respond(HttpStatusCode.Found) + return + } + + proxyStream(call, streamInfo.url, trackId, requestMethod) + } + + suspend fun handleManifestRequest(call: ApplicationCall) { + val trackId = call.parameters["trackId"]?.trim().orEmpty() + if (trackId.isBlank()) { + logger.w { "Rejecting /manifest request with missing track id" } + call.respond(HttpStatusCode.BadRequest, "Missing track id") + return + } + + val streamInfo = streamingUrlRepository.resolveStreamInfo(trackId) + if (streamInfo == null) { + logger.w { "Unable to resolve stream for track $trackId" } + call.respond(HttpStatusCode.NotFound, "Unable to resolve stream for track $trackId") + return + } + + if (streamInfo.protocol == StreamProtocol.PROGRESSIVE) { + logger.w { "Track $trackId is not a manifest stream" } + call.respond(HttpStatusCode.BadRequest, "Not a manifest stream") + return + } + + proxyManifest(call, streamInfo.url, trackId, streamInfo.protocol) + } + + suspend fun handleSegmentRequest(call: ApplicationCall) { + val trackId = call.parameters["trackId"]?.trim().orEmpty() + val segmentUrl = call.parameters["url"]?.let { decodeUrl(it) } + + if (trackId.isBlank() || segmentUrl.isNullOrBlank()) { + logger.w { "Rejecting /segment request with missing parameters" } + call.respond(HttpStatusCode.BadRequest, "Missing track id or segment url") + return + } + + proxySegment(call, segmentUrl, trackId) + } + + private suspend fun proxyManifest( + call: ApplicationCall, + manifestUrl: String, + trackId: String, + protocol: StreamProtocol + ) { + logger.d { "Proxying manifest for track $trackId ($protocol): $manifestUrl" } + + val response = runCatching { + httpClient.request(manifestUrl) { + method = HttpMethod.Get + call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) + call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) + } + }.getOrElse { throwable -> + logger.w(throwable) { "Failed to fetch manifest for track $trackId" } + call.respond(HttpStatusCode.BadGateway, "Failed to fetch manifest") + return + } + + if (response.status.value >= 400) { + logger.w { "Manifest request failed for track $trackId with status ${response.status.value}" } + call.respond(HttpStatusCode.BadGateway, "Failed to fetch manifest") + return + } + + val manifestText = response.bodyAsText() + val port = activePort() ?: 8080 + val serverUrl = "http://$HOST:$port" + val rewrittenManifest = when (protocol) { + StreamProtocol.HLS -> rewriteHlsManifest(manifestText, manifestUrl, serverUrl, trackId) + StreamProtocol.DASH -> rewriteDashManifest(manifestText, manifestUrl, serverUrl, trackId) + else -> manifestText + } + + val contentType = when (protocol) { + StreamProtocol.HLS -> ContentType("application", "vnd.apple.mpegurl") + StreamProtocol.DASH -> ContentType("application", "dash+xml") + else -> ContentType.Application.OctetStream + } + + call.respond(object : OutgoingContent.WriteChannelContent() { + override val status: HttpStatusCode = HttpStatusCode.OK + override val contentType: ContentType = contentType + override val contentLength: Long = rewrittenManifest.encodeToByteArray().size.toLong() + + override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { + channel.writeFully(rewrittenManifest.encodeToByteArray()) + } + }) + } + + private suspend fun proxySegment( + call: ApplicationCall, + segmentUrl: String, + trackId: String + ) { + logger.d { "Proxying segment for track $trackId" } + + runCatching { + httpClient.prepareRequest(segmentUrl) { + method = HttpMethod.Get + call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range) + call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) + call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) + }.execute { response -> respondWithSegment(call, response, trackId) } + }.onFailure { throwable -> + if (throwable is CancellationException) throw throwable + if (throwable.isClientDisconnect()) { + logger.d { "Player closed the connection while streaming segment of track $trackId" } + return + } + logger.w(throwable) { "Failed to fetch segment for track $trackId" } + runCatching { call.respond(HttpStatusCode.BadGateway, "Failed to fetch segment") } + } + } + + private suspend fun respondWithSegment(call: ApplicationCall, response: HttpResponse, trackId: String) { + + if (response.status.value >= 400) { + logger.w { "Segment request failed for track $trackId with status ${response.status.value}" } + call.respond(HttpStatusCode.BadGateway, "Failed to fetch segment") + return + } + + val downstreamHeaders = upstreamHeadersToForward(response) + val downstreamContentLength = downstreamHeaders[HttpHeaders.ContentLength]?.toLongOrNull() + + call.respond(object : OutgoingContent.WriteChannelContent() { + override val status: HttpStatusCode = response.status + override val headers: Headers = downstreamHeaders + override val contentLength: Long? = downstreamContentLength + override val contentType: ContentType? = + downstreamHeaders[HttpHeaders.ContentType]?.let(ContentType.Companion::parse) + + override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { + response.bodyAsChannel().copyTo(channel) + } + }) + } + + private suspend fun proxyStream( + call: ApplicationCall, + streamUrl: String, + trackId: String, + requestMethod: HttpMethod + ) { + logger.d { "Proxying stream for track $trackId using $requestMethod" } + + var currentUrl = streamUrl + var attemptedRefresh = false + val upstreamStart = TimeSource.Monotonic.markNow() + + // `request()` buffers the whole body before returning, which stalls the player until + // the full track is downloaded. `execute {}` hands over the response as soon as headers + // arrive so the body can be streamed. + while (true) { + var retryUrl: String? = null + val failure = runCatching { + httpClient.prepareRequest(currentUrl) { + method = requestMethod + call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range) + call.forwardRequestHeaderIfPresent(headers, HttpHeaders.IfRange) + call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) + call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) + }.execute { response -> + logger.i { + "[Req] upstream headers for $trackId after ${upstreamStart.elapsedNow()}: " + + "status=${response.status} version=${response.version} " + + "headers=${response.headers.entries().joinToString { "${it.key}=${it.value}" }}" + } + if (response.status.value < 400) { + respondWithUpstream(call, response, trackId, requestMethod) + return@execute + } + + logger.w { "Upstream stream request failed for track $trackId with status ${response.status.value}" } + streamingUrlRepository.invalidateCachedStreamUrl(trackId, currentUrl) + + if (!attemptedRefresh) { + val refreshedUrl = + streamingUrlRepository.resolveStreamInfo(trackId, forceRefresh = true)?.url + if (!refreshedUrl.isNullOrBlank() && refreshedUrl != currentUrl) { + retryUrl = refreshedUrl + return@execute + } + } + respondWithUpstream(call, response, trackId, requestMethod) + } + }.exceptionOrNull() + + if (failure != null) { + if (failure is CancellationException) throw failure + if (failure.isClientDisconnect()) { + logger.i(failure) { "[Req] player closed the connection while streaming track $trackId" } + return + } + logger.w(failure) { "Failed to fetch upstream stream for track $trackId" } + // Throws if the response already started streaming; nothing more to send then + runCatching { call.respond(HttpStatusCode.BadGateway, "Failed to fetch upstream stream") } + return + } + + val next = retryUrl ?: return + logger.d { "Retrying stream for track $trackId using refreshed URL" } + currentUrl = next + attemptedRefresh = true + } + } + + private suspend fun respondWithUpstream( + call: ApplicationCall, + upstream: HttpResponse, + trackId: String, + requestMethod: HttpMethod + ) { + if (upstream.status.value >= 400 && isCachingEnabled()) { + val cachedEntry = cacheManager.findCachedEntry(trackId) + if (cachedEntry != null) { + val (filePath, entry) = cachedEntry + logger.d { "Falling back to cache for track $trackId after upstream error" } + if (requestMethod == HttpMethod.Get) { + cacheManager.serveCacheGet(call, filePath, entry) + } else { + cacheManager.serveCacheHead(call, entry) + } + return + } + } + + val downstreamHeaders = upstreamHeadersToForward(upstream) + val downstreamContentLength = downstreamHeaders[HttpHeaders.ContentLength]?.toLongOrNull() + logger.v { + "Proxy response headers for $trackId status=${upstream.status.value} " + + "contentType=${downstreamHeaders[HttpHeaders.ContentType]} " + + "contentLength=${downstreamHeaders[HttpHeaders.ContentLength]} " + + "contentRange=${downstreamHeaders[HttpHeaders.ContentRange]} " + + "acceptRanges=${downstreamHeaders[HttpHeaders.AcceptRanges]}" + } + + if (requestMethod == HttpMethod.Head) { + call.respond(object : OutgoingContent.NoContent() { + override val status: HttpStatusCode = upstream.status + override val headers: Headers = downstreamHeaders + override val contentLength: Long? = downstreamContentLength + }) + return + } + + // Only a full 200 response can be cached; 206 partial responses must not be stored as the whole track + val doCache = requestMethod == HttpMethod.Get && isCachingEnabled() && + upstream.status == HttpStatusCode.OK + val cacheTrack = if (doCache) getTrackMetadata(trackId) else null + val cacheFilename = if (cacheTrack != null) { + cacheManager.resolveCacheFilename(cacheTrack, downstreamHeaders[HttpHeaders.ContentType]) + } else { + null + } + val trackIdForCache = trackId + val expectedLength = downstreamContentLength + val cacheContentType = downstreamHeaders[HttpHeaders.ContentType] ?: "application/octet-stream" + + call.respond(object : OutgoingContent.WriteChannelContent() { + override val status: HttpStatusCode = upstream.status + override val headers: Headers = downstreamHeaders + override val contentLength: Long? = downstreamContentLength + override val contentType: ContentType? = + downstreamHeaders[HttpHeaders.ContentType]?.let(ContentType.Companion::parse) + + override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { + val writeStart = TimeSource.Monotonic.markNow() + logger.i { "[Req] start writing body for $trackId status=${upstream.status} cache=${cacheFilename != null} expected=$expectedLength" } + try { + writeBody(channel) + logger.i { "[Req] finished writing body for $trackId in ${writeStart.elapsedNow()}" } + } catch (e: Throwable) { + logger.i(e) { "[Req] body write for $trackId aborted after ${writeStart.elapsedNow()}: ${e::class.simpleName}: ${e.message}" } + throw e + } + } + + private suspend fun writeBody(channel: io.ktor.utils.io.ByteWriteChannel) { + if (cacheFilename != null) { + val cacheDir = cacheManager.resolveCacheDir() + val cacheFile = cacheDir / cacheFilename.toPath() + fileSystem.createDirectories(cacheDir) + var completed = false + var totalBytes = 0L + try { + val source = upstream.bodyAsChannel() + val buffer = ByteArray(64 * 1024) + fileSystem.sink(cacheFile).buffer().use { sink -> + while (true) { + val read = source.readAvailable(buffer, 0, buffer.size) + if (read < 0) break + if (read == 0) continue + channel.writeFully(buffer, 0, read) + channel.flush() + sink.write(buffer, 0, read) + totalBytes += read + } + } + completed = expectedLength == null || expectedLength == totalBytes + if (completed) { + cacheManager.commitCacheEntry( + trackId = trackIdForCache, + filename = cacheFilename, + sizeBytes = totalBytes, + contentType = cacheContentType) + scope.launch { + cacheManager.evictIfNeeded() + } + } + } finally { + // Player disconnected or stream was incomplete: drop the partial file + if (!completed && fileSystem.exists(cacheFile)) { + fileSystem.delete(cacheFile) + } + } + } else { + upstream.bodyAsChannel().copyTo(channel) + } + } + }) + } + + private fun rewriteHlsManifest( + manifest: String, + baseUrl: String, + serverUrl: String, + trackId: String + ): String { + val manifestBase = baseUrl.substringBeforeLast('/') + val lines = manifest.lines() + val rewritten = lines.map { line -> + when { + line.isBlank() || line.startsWith("#") -> line + line.startsWith("http") -> { + "${serverUrl}/segment/${trackId}?url=${encodeUrl(line)}" + } + else -> { + val absoluteUrl = if (line.startsWith("/")) { + val urlBase = + baseUrl.substringBefore("://") + "://" + baseUrl.substringAfter("://") + .substringBefore('/') + "$urlBase$line" + } else { + "$manifestBase/$line" + } + "${serverUrl}/segment/${trackId}?url=${encodeUrl(absoluteUrl)}" + } + } + } + return rewritten.joinToString("\n") + } + + private fun rewriteDashManifest( + manifest: String, + baseUrl: String, + serverUrl: String, + trackId: String + ): String { + val manifestBase = baseUrl.substringBeforeLast('/') + var rewritten = manifest + + val urlRegex = Regex("""(BaseURL|SegmentURL|Location)>([^<]+)""") + rewritten = urlRegex.replace(rewritten) { match -> + val tag = match.groupValues[1] + val url = match.groupValues[2] + val absoluteUrl = if (url.startsWith("http")) url else "$manifestBase/$url" + "$tag>${serverUrl}/segment/${trackId}?url=${encodeUrl(absoluteUrl)}" + } + + val srcRegex = Regex("""src="([^"]+)"""") + rewritten = srcRegex.replace(rewritten) { match -> + val url = match.groupValues[1] + val absoluteUrl = if (url.startsWith("http")) url else "$manifestBase/$url" + """src="${serverUrl}/segment/${trackId}?url=${encodeUrl(absoluteUrl)}""" + } + + return rewritten + } + + /** + * True when the failure came from writing to the player's socket (seek, skip or stop closes + * it mid-stream), as opposed to a failure fetching from upstream. + */ + private fun Throwable.isClientDisconnect(): Boolean = + generateSequence(this) { it.cause }.take(10).any { + it is ChannelWriteException || it is ClosedWriteChannelException || + (it is IOException && it.message?.contains("Broken pipe", ignoreCase = true) == true) + } + + private fun encodeUrl(url: String): String = url.encodeURLParameter() + private fun decodeUrl(encoded: String): String = encoded.decodeURLQueryComponent() + + private fun ApplicationCall.forwardRequestHeaderIfPresent( + builder: HeadersBuilder, + headerName: String + ) { + request.headers.getAll(headerName)?.forEach { value -> + builder.append(headerName, value) + } + } + + private fun upstreamHeadersToForward(upstream: HttpResponse): Headers { + return HeadersBuilder().apply { + upstream.headers.forEach { name, values -> + values.forEach { value -> append(name, value) } + } + }.build() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt new file mode 100644 index 00000000..19692bf6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt @@ -0,0 +1,483 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.server + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.getPlatform +import org.spotube.spotube.modules.plugin.AudioPluginSource +import org.spotube.spotube.modules.settings.UserSettingsSource +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import org.koin.core.component.KoinComponent +import kotlin.math.abs +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds + +data class CachedStreamUrl( + val url: String, + val protocol: StreamProtocol, + val expiresAtMs: Long, + val container: String, + val codec: String, + val source: AudioSource.Streamed? = null, + val selectedStream: AudioStream? = null, + val preferredFormat: AudioFormat, + val preferredQuality: AudioQuality, + // The id of the audio plugin that produced this entry. Guards against a stale entry + // (e.g. a YouTube video URL) being served after the user switches to a different + // audio plugin, which would otherwise play back completely unrelated content. + val pluginId: String) + +private data class CachedAlternatives( + val pluginId: String, + val sources: List) + +/** + * Stream preferences for callers other than playback. The downloader passes the user's *download* + * format/quality and needs a file it can fetch with plain HTTP, so DASH/HLS manifests are excluded. + * Such requests bypass the playback stream-URL cache in both directions. + */ +data class StreamRequest( + val format: AudioFormat, + val quality: AudioQuality, + val progressiveOnly: Boolean) + +data class StreamInfo( + val url: String, + val protocol: StreamProtocol, + val codec: String, + val container: String) + +class StreamingUrlRepository( + private val pluginManager: AudioPluginSource, + private val audioPlayerQueue: AudioPlayerQueue, + private val matchedTracksRepository: TrackSourceRepository, + private val settingsRepository: UserSettingsSource, + private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent { + + private val logger by injectLogger() + private val streamUrlCacheMutex = Mutex() + private val streamUrlCache = mutableMapOf() + private val alternativesCacheMutex = Mutex() + private val alternativesCache = mutableMapOf() + + suspend fun resolveStreamInfo( + trackId: String, + forceRefresh: Boolean = false + ): StreamInfo? { + val queueEntry = audioPlayerQueue.queueFlow.value.firstOrNull { + it is QueueEntry.StreamingTrack && it.track.id == trackId + } as QueueEntry.StreamingTrack? + + if (queueEntry == null) { + logger.v { "Track $trackId is not present in current queue" } + return null + } + return resolveStreamInfo(queueEntry.track, forceRefresh) + } + + suspend fun resolveStreamInfo( + track: MetadataTrack, + forceRefresh: Boolean = false, + request: StreamRequest? = null + ): StreamInfo? { + val trackId = track.id + val settings = settingsRepository.userSettings.value + val preferredFormat = request?.format ?: settings.streamingMusicFormat + val preferredQuality = request?.quality ?: settings.streamingMusicQuality + // The URL cache holds what *playback* resolved; a download with other prefs must not + // read it, nor replace it with a stream playback didn't ask for. + val useUrlCache = request == null + + // A built-in audio plugin is always selected, so null means plugins are still loading. + // The player requests the restored queue's track right at startup; failing that request + // leaves the item permanently failed (AVPlayer), so wait for the plugin instead. + val audioPlugin = pluginManager.selectedAudioPlugin.value + ?: withTimeoutOrNull(AUDIO_PLUGIN_LOAD_TIMEOUT) { + logger.i { "[Resolve $trackId] waiting for the audio plugin to load" } + pluginManager.selectedAudioPlugin.filterNotNull().first() + } + if (audioPlugin == null) { + logger.w { "No audio plugin selected while resolving stream for track $trackId" } + return null + } + logger.i { + "[Resolve $trackId] '${track.title}' plugin=${audioPlugin.pluginId} forceRefresh=$forceRefresh " + + "prefs: format=${preferredFormat.codec}/${preferredFormat.container} quality=$preferredQuality " + + if (request == null) "(streaming settings)" else "(caller request, progressiveOnly=${request.progressiveOnly})" + } + + fun select(stream: AudioSource.Streamed, origin: String): AudioStream? { + val candidates = if (request?.progressiveOnly == true) { + stream.streams.filter { it.protocol == StreamProtocol.PROGRESSIVE }.also { kept -> + if (kept.size != stream.streams.size) logger.i { + "[Resolve $trackId] dropped ${stream.streams.size - kept.size} manifest (DASH/HLS) candidates; " + + "caller needs a directly downloadable file" + } + } + } else { + stream.streams + } + return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality) + .also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) } + } + + if (useUrlCache && !forceRefresh) { + getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached -> + logger.i { + "[Resolve $trackId] cache hit: codec=${cached.codec} container=${cached.container} " + + "protocol=${cached.protocol} ${urlSummary(cached.url)}" + } + return cached + } + } + + val source = matchedTracksRepository.getTrackSource(track, audioPlugin.pluginId) + + if (source != null) { + logger.d { "Attempting stream resolution for track $trackId using cached source" } + val stream = runCatching { + audioPlugin.use { + audioAPI.getStreamsOfAudioSource(source) + .firstOrNull() + } + }.getOrElse { throwable -> + logger.w(throwable) { "Failed to resolve audio stream for track $trackId using matched source" } + null + } + + if (stream != null) { + logger.d { "Resolved stream for track $trackId using cached source" } + val resolvedStream = select(stream, "matched source ${source.id}") + if (resolvedStream != null) { + val info = resolvedStream.toStreamInfo() + if (useUrlCache) { + cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) + } + return info + } + } + + logger.d { "Cached source did not provide a stream for track $trackId; falling back to track lookup" } + } + + logger.d { "Attempting stream resolution for track $trackId using track lookup" } + val sources = runCatching { + audioPlugin.use { + audioAPI.getStreamsByTrack(track) + } + }.getOrElse { throwable -> + logger.w(throwable) { "Failed to resolve audio stream for track $trackId" } + return null + } + + if (sources.isEmpty()) { + logger.w { "No stream candidates found for track $trackId" } + return null + } + + cacheAlternatives(trackId, sources, audioPlugin.pluginId) + + val stream = runCatching { + audioPlugin.use { + sources.firstNotNullOfOrNull { src -> + when (src) { + is AudioSource.Streamed -> { + matchedTracksRepository.saveTrackSource(track, src.toBasic(), audioPlugin.pluginId) + src + } + + is AudioSource.Basic -> { + matchedTracksRepository.saveTrackSource(track, src, audioPlugin.pluginId) + audioAPI.getStreamsOfAudioSource(src) + .firstOrNull() + } + } + } + } + }.getOrElse { throwable -> + logger.w(throwable) { "Failed to resolve audio stream for track $trackId" } + return null + } + + if (stream == null) { + logger.w { "No stream candidates found for track $trackId" } + return null + } + + logger.d { "Resolved stream for track $trackId via track lookup" } + val resolvedStream = select(stream, "track lookup -> ${stream.id}") + if (resolvedStream != null) { + val info = resolvedStream.toStreamInfo() + if (useUrlCache) { + cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) + } + return info + } + return null + } + + /** Logs every candidate with its ranking key so it's obvious *why* a stream won. */ + private fun logSelection( + trackId: String, + origin: String, + streams: List, + preferredFormat: AudioFormat, + preferredQuality: AudioQuality, + selected: AudioStream?) { + logger.i { + "[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " + + "want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " + + "(rank key = unplayable, formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " + + "lowest wins):\n" + + streams.joinToString("\n") { stream -> + val marker = if (stream === selected) " -> " else " " + "$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(currentPlatform)) 0 else 1}, " + + "${formatMismatchCount(stream, preferredFormat)}, " + + "${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " + + "${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " + + "${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}" + } + } + when { + selected == null -> logger.w { "[Resolve $trackId] no stream selected" } + !selected.isPlayableOn(currentPlatform) -> logger.w { + "[Resolve $trackId] NO PLAYABLE STREAM on $currentPlatform; falling back to " + + "${selected.describeForLog()}, which the player will likely reject" + } + formatMismatchCount(selected, preferredFormat) > 0 -> logger.w { + "[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " + + "got ${selected.codec}/${selected.container} (${selected.protocol})" + } + selected.qualityDistance(preferredQuality) != 0L -> logger.w { + "[Resolve $trackId] QUALITY FALLBACK: wanted $preferredQuality, got ${selected.describeForLog()}" + } + } + } + + private fun AudioStream.toStreamInfo() = StreamInfo( + url = normalizeManifestUrl(url, protocol), + protocol = protocol, + codec = codec, + container = container) + + private suspend fun getCachedStreamUrl( + trackId: String, + preferredFormat: AudioFormat, + preferredQuality: AudioQuality, + pluginId: String): StreamInfo? { + val now = Clock.System.now().toEpochMilliseconds() + return streamUrlCacheMutex.withLock { + val cached = streamUrlCache[trackId] ?: return@withLock null + if (cached.pluginId != pluginId) { + logger.v { "Cached stream URL for track $trackId belongs to a different plugin; ignoring" } + return@withLock null + } + if (cached.preferredFormat != preferredFormat || cached.preferredQuality != preferredQuality) { + return@withLock null + } + if (cached.expiresAtMs <= now) { + logger.v { "Cached stream URL expired for track $trackId" } + return@withLock null + } + StreamInfo(cached.url, cached.protocol, cached.codec, cached.container) + } + } + + private suspend fun cacheStreamInfo( + trackId: String, + info: StreamInfo, + preferredFormat: AudioFormat, + preferredQuality: AudioQuality, + source: AudioSource.Streamed, + selectedStream: AudioStream, + pluginId: String) { + val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs + streamUrlCacheMutex.withLock { + streamUrlCache[trackId] = CachedStreamUrl( + url = info.url, + protocol = info.protocol, + expiresAtMs = expiresAtMs, + container = info.container, + codec = info.codec, + source = source, + selectedStream = selectedStream, + preferredFormat = preferredFormat, + preferredQuality = preferredQuality, + pluginId = pluginId) + } + } + + suspend fun invalidateCachedStreamUrl(trackId: String, url: String? = null) { + streamUrlCacheMutex.withLock { + val cached = streamUrlCache[trackId] ?: return@withLock + if (url == null || cached.url == url) { + streamUrlCache.remove(trackId) + logger.v { "Invalidated cached stream URL for track $trackId" } + } + } + // Stream metadata is part of the same URL-cache entry and is removed with it. + } + + /** Returns cached alternatives only if they were produced by [pluginId]. */ + suspend fun getCachedAlternatives(trackId: String, pluginId: String): List? { + return alternativesCacheMutex.withLock { + val cached = alternativesCache[trackId] ?: return@withLock null + if (cached.pluginId != pluginId) { + logger.v { "Cached alternatives for track $trackId belong to a different plugin; ignoring" } + return@withLock null + } + cached.sources + } + } + + suspend fun cacheAlternatives(trackId: String, sources: List, pluginId: String) { + alternativesCacheMutex.withLock { + alternativesCache[trackId] = CachedAlternatives(pluginId, sources) + logger.v { "Cached ${sources.size} alternative sources for track $trackId" } + } + } + + /** Returns stream details from the existing playback URL cache; never fetches them. */ + suspend fun getCachedStreamUrlEntry(trackId: String): CachedStreamUrl? { + val now = Clock.System.now().toEpochMilliseconds() + return streamUrlCacheMutex.withLock { + val entry = streamUrlCache[trackId] ?: return@withLock null + if (entry.expiresAtMs <= now) { + logger.v { "Returning expired stream details for track $trackId" } + } + entry + } + } + + suspend fun invalidateCachedAlternatives(trackId: String) { + alternativesCacheMutex.withLock { + alternativesCache.remove(trackId) + } + } + +} + +/** + * Resolves a stream URL to an absolute URL. Relative manifest paths (DASH/HLS) are + * resolved against the YouTube origin; progressive URLs are returned unchanged. + */ +private val AUDIO_PLUGIN_LOAD_TIMEOUT = 30.seconds + +internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String { + if (url.startsWith("http")) return url + return when (protocol) { + StreamProtocol.DASH, StreamProtocol.HLS -> "https://www.youtube.com$url" + StreamProtocol.PROGRESSIVE -> url + } +} + +internal val currentPlatform: PlatformType by lazy { getPlatform().type } + +/** + * Selects the available stream closest to the user's preferred format and quality. + * Streams the [platform]'s player can decode always win over ones it can't, whatever the + * preferences say: e.g. opus/webm is unplayable on iOS. + */ +internal fun selectPreferredAudioStream( + streams: List, + preferredFormat: AudioFormat, + preferredQuality: AudioQuality, + platform: PlatformType = currentPlatform): AudioStream? = streams.minWithOrNull( + compareBy { stream -> + if (stream.isPlayableOn(platform)) 0 else 1 + }.thenBy { stream -> + formatMismatchCount(stream, preferredFormat) + }.thenBy { stream -> + if (stream.matchesQualityType(preferredQuality)) 0 else 1 + }.thenBy { stream -> + stream.qualityDistance(preferredQuality) + }.thenBy { stream -> + stream.channelDistance(preferredQuality) + } +) + +// AVPlayer has no WebM/Ogg/Matroska demuxer, so e.g. YouTube's opus/webm streams fail to load. +private val IOS_UNSUPPORTED_CONTAINERS = setOf("webm", "ogg", "oga", "mkv", "matroska") + +/** + * Whether [platform]'s player can play this stream. VLC (desktop) and ExoPlayer (Android) + * handle everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH. + */ +internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean = + isContainerPlayableOn(container, platform) && + !(platform == PlatformType.IOS && protocol == StreamProtocol.DASH) + +/** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */ +internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean = + platform != PlatformType.IOS || container.lowercase() !in IOS_UNSUPPORTED_CONTAINERS + +private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int = + (if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) + + (if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1) + +private fun AudioStream.matchesQualityType(preferredQuality: AudioQuality): Boolean = when (this) { + is AudioStream.Lossy -> preferredQuality is AudioQuality.Lossy + is AudioStream.Lossless -> preferredQuality is AudioQuality.Lossless +} + +private fun AudioStream.qualityDistance(preferredQuality: AudioQuality): Long = when { + this is AudioStream.Lossy && preferredQuality is AudioQuality.Lossy -> + abs(bitrate.toLong() - preferredQuality.bitrate.toLong()) + + this is AudioStream.Lossless && preferredQuality is AudioQuality.Lossless -> + abs(sampleRate.toLong() - preferredQuality.sampleRate.toLong()) + + else -> Long.MAX_VALUE +} + +private fun AudioStream.channelDistance(preferredQuality: AudioQuality): Int = when { + this is AudioStream.Lossless && preferredQuality is AudioQuality.Lossless -> + abs(channels - preferredQuality.channels) + + else -> 0 +} + +internal fun AudioStream.describeForLog(): String = when (this) { + is AudioStream.Lossy -> "lossy codec=$codec container=$container bitrate=$bitrate protocol=$protocol" + is AudioStream.Lossless -> + "lossless codec=$codec container=$container sampleRate=$sampleRate channels=$channels protocol=$protocol" +} + +/** + * Host plus the query params that identify a YouTube stream (itag, mime, clen = byte size, dur), + * instead of the full signed URL. + */ +internal fun urlSummary(url: String): String = runCatching { + val parsed = io.ktor.http.Url(url) + val keys = listOf("itag", "mime", "clen", "dur") + val params = keys.mapNotNull { key -> parsed.parameters[key]?.let { "$key=$it" } } + "url=${parsed.host}${parsed.encodedPath.take(40)}" + if (params.isEmpty()) "" else " ${params.joinToString(" ")}" +}.getOrDefault("url=") diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/share/ShareService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/share/ShareService.kt new file mode 100644 index 00000000..9ff7ccb9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/share/ShareService.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.share + +interface ShareService { + fun share(url: String, title: String) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt new file mode 100644 index 00000000..de319170 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.tools.user_agents + +import org.koin.core.component.inject +import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.network.HttpClientFactory +import co.touchlab.kermit.Logger +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.getPlatform +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.isSuccess +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import okio.FileSystem +import okio.Path.Companion.toPath +import okio.SYSTEM +import kotlin.time.Clock + +/** + * Provides browser User-Agent strings that match the platform the app is running on + * (Android UAs only on Android, iOS only on iOS, and desktop UAs only on the matching + * Windows / macOS / Linux host). + * + * The primary source is the `top-user-agents` list maintained by Microlink (the 100 most + * used UAs, kept current), cached on disk for [CACHE_TTL_MS]. The hardcoded [FALLBACKS] + * are used whenever nothing has been fetched yet or the fetch fails. + * + * [random] is intentionally synchronous and never blocks on the network: it serves + * whatever is cached (even stale) and refreshes in the background when needed. + */ +object UserAgents : KoinComponent { + private val httpClientFactory: HttpClientFactory by inject() + private const val SOURCE_URL = + "https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json" + private const val CACHE_FILE_NAME = "user_agents_cache.json" + private const val CACHE_TTL_MS = 24L * 60 * 60 * 1000 // 1 day + + private val logger = Logger.withTag("UserAgents") + private val json = Json { ignoreUnknownKeys = true } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val refreshMutex = Mutex() + + private val platformType: PlatformType = getPlatform().type + + @Serializable + private data class CacheEntry(val fetchedAtMs: Long, val agents: List) + private var remoteAgents: List = emptyList() + private var fetchedAtMs: Long = 0 + + // Loads the disk cache once and kicks off a background refresh if it is missing/stale. + private val bootstrap by lazy { + loadFromDisk() + if (isStale()) scope.launch { refresh() } + } + + fun random(): String { + bootstrap + return pool().random() + } + + /** + * Fetches the latest list from the network and caches it. No-op while the cache is + * still fresh unless [force] is set. Failures keep whatever data is already cached. + */ + suspend fun refresh(force: Boolean = false) = refreshMutex.withLock { + if (!force && !isStale()) return@withLock + try { + val agents = fetchRemote().filter(::isValidAgent) + if (agents.isEmpty()) return@withLock + remoteAgents = agents + fetchedAtMs = now() + saveToDisk(CacheEntry(fetchedAtMs, agents)) + } catch (e: Exception) { + logger.w(e) { "Failed to refresh user agents, using cached/fallback ones" } + } + } + + private fun pool(): List { + val matching = remoteAgents.filter { matchesPlatform(it, platformType) } + return matching.ifEmpty { FALLBACKS.getValue(platformType) } + } + + private fun isStale() = now() - fetchedAtMs >= CACHE_TTL_MS + + private fun now() = Clock.System.now().toEpochMilliseconds() + + private fun isValidAgent(ua: String) = ua.startsWith("Mozilla/") + + private suspend fun fetchRemote(): List { + val res = httpClientFactory.default.get(SOURCE_URL) + if (!res.status.isSuccess()) error("Unexpected status ${res.status}") + return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText()) + } + + private fun cachePath() = (Paths().getApplicationCacheDirPath() + "/" + CACHE_FILE_NAME).toPath() + + private fun loadFromDisk() { + try { + val path = cachePath() + val fs = FileSystem.SYSTEM + if (!fs.exists(path)) return + val entry = json.decodeFromString(fs.read(path) { readUtf8() }) + val agents = entry.agents.filter(::isValidAgent) + if (agents.isEmpty()) return + remoteAgents = agents + fetchedAtMs = entry.fetchedAtMs + } catch (e: Exception) { + logger.w(e) { "Failed to read user agents cache" } + } + } + + private fun saveToDisk(entry: CacheEntry) { + try { + val path = cachePath() + val tmp = "$path.tmp".toPath() + val fs = FileSystem.SYSTEM + path.parent?.let { fs.createDirectories(it) } + fs.write(tmp) { writeUtf8(json.encodeToString(CacheEntry.serializer(), entry)) } + fs.atomicMove(tmp, path) + } catch (e: Exception) { + logger.w(e) { "Failed to write user agents cache" } + } + } + + internal fun matchesPlatform(ua: String, type: PlatformType): Boolean { + val isIos = "iPhone" in ua || "iPad" in ua || "iPod" in ua + val isAndroid = "Android" in ua + return when (type) { + PlatformType.IOS -> isIos + PlatformType.Android -> isAndroid + PlatformType.Windows -> "Windows NT" in ua && !isAndroid + PlatformType.MacOS -> "Macintosh" in ua && !isIos + PlatformType.Linux -> ("X11" in ua || "Linux" in ua) && !isAndroid && "CrOS" !in ua + // Unknown host: any desktop UA is a safe choice. + PlatformType.Unknown -> !isIos && !isAndroid + } + } + + private val FALLBACKS: Map> = mapOf( + PlatformType.Windows to listOf( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Vivaldi/7.6.3797.63", + ), + PlatformType.MacOS to listOf( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Vivaldi/7.5.3735.47", + ), + PlatformType.Linux to listOf( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0", + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0", + ), + PlatformType.Android to listOf( + "Mozilla/5.0 (Linux; Android 15; ALI-NX1 Build/HONORALI-N21) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.92 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 14; SM-A528B Build/UP1A.231005.007) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.108 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36", + ), + PlatformType.IOS to listOf( + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/146.0.0.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1", + ), + PlatformType.Unknown to listOf( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt new file mode 100644 index 00000000..31a3fa05 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + + +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogFooter +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumSeparator +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.ui.unit.dp +import org.spotube.spotube.resources.iconsax.FluentDismiss +import org.spotube.spotube.resources.iconsax.Iconsax +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun AdaptiveDialogBottomSheet( + onDismiss: () -> Unit, + title: @Composable (() -> Unit)? = null, + // Plain-text title for the large-screen dialog's own titlebar; when set it replaces [title] + // there so the titlebar isn't left empty above a duplicated in-body title. + dialogTitle: String? = null, + modifier: Modifier = Modifier, + actions: @Composable (() -> Unit)? = null, + content: @Composable () -> Unit) { + val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium + + if (isLargeScreen) { + MillenniumDialog( + visible = true, + onDismissRequest = onDismiss, + modifier = modifier, + paneTitle = dialogTitle ?: "") { + MillenniumDialogHeader { + if (dialogTitle != null) { + MillenniumDialogTitle(text = dialogTitle, textStyle = AppTheme.typography.titleMedium) + } else { + MillenniumDialogTitle { title?.invoke() } + } + MillenniumDialogClose( + contentDescription = stringResource(Res.string.common_close_dialog)) + } + MillenniumDialogBody { content() } + actions?.let { MillenniumDialogFooter(content = { it() }) } + } + } else { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + LaunchedEffect(sheetState.currentDetent) { + if (sheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } + MillenniumModalBottomSheet(state = sheetState) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start) { + title?.let { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Box(Modifier.weight(1f)) { it() } + MillenniumIconButton( + onClick = onDismiss, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + Iconsax.FluentDismiss, + contentDescription = stringResource(Res.string.common_close_dialog)) + } + } + Spacer(Modifier.height(12.dp)) + } + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 560.dp) + .verticalScroll(rememberScrollState())) { + content() + } + actions?.let { + Spacer(Modifier.height(8.dp)) + MillenniumSeparator() + Spacer(Modifier.height(8.dp)) + it() + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt new file mode 100644 index 00000000..ed860c08 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt @@ -0,0 +1,370 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState +import compose.icons.FeatherIcons +import compose.icons.feathericons.Check +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxShare +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumDropdownMenuContent +import org.spotube.millennium.composables.MillenniumDropdownMenuEmpty +import org.spotube.millennium.composables.MillenniumDropdownMenuFilter +import org.spotube.millennium.composables.MillenniumDropdownMenuHeader +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import org.spotube.millennium.composables.MillenniumDropdownMenuItemIcon +import org.spotube.millennium.composables.MillenniumDropdownMenuSeparator +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumText +import org.spotube.millennium.composables.MillenniumTextField +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.accessibility_selected +import spotube.composeapp.generated.resources.common_no_results +import spotube.composeapp.generated.resources.search_filter_placeholder + +data class AdaptiveMenuItem( + val icon: ImageVector? = null, + val label: String, + val onClick: () -> Unit, + val enabled: Boolean = true, + val selected: Boolean = false, + val dividerBefore: Boolean = false, + val subtitle: String? = null, + val leadingContent: (@Composable () -> Unit)? = null) + +enum class HeaderDisplayMode { + Always, + OnlyInDropdown, + OnlyInBottomSheet, +} + +@Composable +fun AdaptiveDropdownBottomSheet( + items: List, + trigger: @Composable (onClick: () -> Unit) -> Unit, + modifier: Modifier = Modifier, + menuMinWidth: Dp = 200.dp, + header: @Composable (() -> Unit)? = null, + headerDisplayMode: HeaderDisplayMode = HeaderDisplayMode.Always, + filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { + var expanded by remember { mutableStateOf(false) } + val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium + + Box(modifier = modifier) { + if (isLargeScreen) { + DesktopDropdownMenu( + expanded = expanded, + onExpandedChange = { expanded = it }, + items = items, + menuMinWidth = menuMinWidth, + filter = filter, + trigger = { trigger { expanded = true } }) + } else { + trigger { expanded = true } + if (expanded) { + AdaptiveBottomSheetContent( + onDismiss = { expanded = false }, + items = items, + header = if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInBottomSheet)) { + header + } else { + null + }, + filter = filter) + } + } + } +} + +@Composable +private fun AdaptiveBottomSheetContent( + onDismiss: () -> Unit, + items: List, + header: @Composable (() -> Unit)?, + filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { + val hasSelection = items.any { it.selected } + val hasLeading = items.any { it.icon != null || it.leadingContent != null } + val queryState = rememberTextFieldState() + val query = queryState.text.toString() + val filteredItems = if (filter != null && query.isNotBlank()) { + items.filter { filter(it, query) } + } else { + items + } + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + LaunchedEffect(sheetState.currentDetent) { + if (sheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } + MillenniumModalBottomSheet( + state = sheetState, + contentPadding = PaddingValues(top = 10.dp, bottom = 8.dp), + contentSpacing = 4.dp) { + Column( + modifier = Modifier.fillMaxWidth()) { + header?.invoke() + + if (filter != null) { + Spacer(modifier = Modifier.height(8.dp)) + MillenniumTextField( + state = queryState, + label = "", + placeholder = stringResource(Res.string.search_filter_placeholder), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp)) + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp)) { + items( + count = filteredItems.size) { index -> + val item = filteredItems[index] + + if (item.dividerBefore) { + MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) + } + AdaptiveMenuTile( + item = item, + hasSelection = hasSelection, + hasLeading = hasLeading, + textStyle = AppTheme.typography.bodyLarge, + minimumHeight = 56.dp, + onClick = { + item.onClick() + onDismiss() + }) + } + } + } + } +} + +/** One menu row. The leading slot is always reserved when any sibling has an icon/leading content + * (or a selection), so labels and icons stay aligned in a column. */ +@Composable +private fun AdaptiveMenuTile( + item: AdaptiveMenuItem, + hasSelection: Boolean, + hasLeading: Boolean, + textStyle: androidx.compose.ui.text.TextStyle, + minimumHeight: Dp, + onClick: () -> Unit) { + if (item.dividerBefore) { + MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) + } + val contentColor = if (item.enabled) AppTheme.colors.onSurface + else AppTheme.colors.onSurface.copy(alpha = 0.38f) + MillenniumListTile( + title = { MillenniumText(item.label, style = textStyle, color = contentColor) }, + subtitle = item.subtitle?.let { subtitle -> + { + MillenniumText( + subtitle, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + }, + leading = when { + item.leadingContent != null -> item.leadingContent + item.icon != null -> { + { + MillenniumIcon( + imageVector = item.icon, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = contentColor) + } + } + item.selected -> { + { + MillenniumIcon( + imageVector = FeatherIcons.Check, + contentDescription = stringResource(Res.string.accessibility_selected), + modifier = Modifier.size(22.dp)) + } + } + hasSelection || hasLeading -> {{ Spacer(modifier = Modifier.size(22.dp)) }} + else -> null + }, + trailing = if (item.selected && (item.icon != null || item.leadingContent != null)) { + { + MillenniumIcon( + imageVector = FeatherIcons.Check, + contentDescription = stringResource(Res.string.accessibility_selected), + modifier = Modifier.size(22.dp)) + } + } else { + null + }, + selected = item.selected, + enabled = item.enabled, + onClick = onClick, + minimumHeight = minimumHeight) +} + +/** Desktop popup built from Millennium's dropdown bricks: native icons, descriptions, + * selection indicator, separators and an optional search header. */ +@Composable +private fun DesktopDropdownMenu( + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + items: List, + menuMinWidth: Dp, + filter: ((AdaptiveMenuItem, String) -> Boolean)?, + trigger: @Composable () -> Unit) { + val queryState = rememberTextFieldState() + LaunchedEffect(expanded) { if (!expanded) queryState.clearText() } + val query = queryState.text.toString() + val visibleItems = if (filter != null && query.isNotBlank()) { + items.filter { filter(it, query) } + } else { + items + } + val hasLeading = items.any { it.icon != null || it.leadingContent != null } + val iconSize = MillenniumTheme.dropdownMenuTheme.states.default.iconSize + + MillenniumDropdownMenu( + expanded = expanded, + onExpandedChange = onExpandedChange, + minimumWidth = menuMinWidth, + width = menuMinWidth, + anchor = trigger) { + if (filter != null) { + MillenniumDropdownMenuHeader(showDivider = false) { + MillenniumDropdownMenuFilter( + state = queryState, + placeholder = stringResource(Res.string.search_filter_placeholder)) + } + } + MillenniumDropdownMenuContent { + if (visibleItems.isEmpty()) { + MillenniumDropdownMenuEmpty(stringResource(Res.string.common_no_results)) + } + visibleItems.forEach { item -> + if (item.dividerBefore) MillenniumDropdownMenuSeparator() + MillenniumDropdownMenuItem( + onClick = item.onClick, + enabled = item.enabled, + selected = item.selected, + leading = item.leadingContent + ?: item.icon?.let { icon -> { MillenniumDropdownMenuItemIcon(icon) } } + ?: if (hasLeading) ({ Spacer(Modifier.size(iconSize)) }) else null, + description = item.subtitle?.let { subtitle -> { Text(subtitle) } }) { + Text(item.label) + } + } + } + } +} + +@Preview +@Composable +private fun AdaptiveDropdownBottomSheetPreview() { + Box(modifier = Modifier.background(AppTheme.colors.background)) { + AdaptiveDropdownBottomSheet( + items = listOf( + AdaptiveMenuItem( + icon = Iconsax.IconsaxHeart, + label = "Favorite", + onClick = {}), + AdaptiveMenuItem( + icon = Iconsax.IconsaxShare, + label = "Share", + onClick = {}), + AdaptiveMenuItem( + icon = Iconsax.IconsaxTrash, + label = "Delete", + onClick = {}, + enabled = false)), + trigger = { onClick -> + MillenniumIconButton( + onClick = onClick, + modifier = Modifier + .size(32.dp) + .background( + color = AppTheme.colors.primary.copy(alpha = 0.1f), + shape = CircleShape) + .clip(CircleShape), + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.Iconsax3DotsMore, + contentDescription = null, + tint = AppTheme.colors.primary) + } + }) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt new file mode 100644 index 00000000..b651edbf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.koin.compose.koinInject + +@Composable +fun AlbumCard( + album: MetadataAlbum, + modifier: Modifier = Modifier, + audioPlayerQueue: AudioPlayerQueue = koinInject(), + playbackHelper: CollectionPlaybackHelper = koinInject(), + navigationCommands: NavigationCommands = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), + listMode: Boolean = false +) { + val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() + + PlayableCard( + title = album.title, + subtitle = album.description + ?: "${album.albumType} • ${album.artists.joinToString { it.name }}", + imageURL = album.thumbnails.firstOrNull()?.url, + isPlaying = currentCollectionEntry?.id == album.id, + onClick = { + navigationCommands.navigateTo(Routes.Album(album.id)) + }, + onPlay = { + if (currentCollectionEntry?.id == album.id) return@PlayableCard + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Album, + album.id, + album.title + ) + }, + onAddToQueue = { + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.Album, + album.id, + album.title + ) + }, + modifier = if (listMode) modifier else modifier.width(160.dp), + sharedElementKey = "album_art_${album.id}", + listMode = listMode, + playLoading = CollectionPlaybackKey(RemoteCollectionType.Album, album.id, PlaybackDestinationAction.Play) in busyCollections, + addToQueueLoading = CollectionPlaybackKey(RemoteCollectionType.Album, album.id, PlaybackDestinationAction.AddToQueue) in busyCollections + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.kt new file mode 100644 index 00000000..74d7e83b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.foundation.layout.RowScope + +import androidx.compose.runtime.Composable +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.resources.iconsax.ArrowLeft3 +import org.spotube.spotube.resources.iconsax.Iconsax +import org.koin.compose.koinInject +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun ApplicationBackButton() { + val navigationCommands: NavigationCommands = koinInject() + + MillenniumIconButton( + onClick = { + navigationCommands.pop() + }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.ArrowLeft3, + contentDescription = stringResource(Res.string.common_back) + ) + } +} + +@Composable +expect fun ApplicationMainBar( + title: @Composable () -> Unit = {}, + subtitle: @Composable () -> Unit = {}, + actions: @Composable (RowScope.() -> Unit) = {}, + backButton: Boolean = true, + transparent: Boolean = false) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ArtistCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ArtistCard.kt new file mode 100644 index 00000000..c974f17c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ArtistCard.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.cards.AvatarCard +import org.koin.compose.koinInject +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun ArtistCard( + artist: MetadataArtist.Basic, + modifier: Modifier = Modifier, + isBlacklisted: Boolean = false, + listMode: Boolean = false +) { + val navigationCommands = koinInject() + + AvatarCard( + title = artist.name, + subtitle = stringResource(if (isBlacklisted) Res.string.media_blacklisted else Res.string.media_artist), + imageURL = artist.thumbnails.firstOrNull()?.url, + onClick = { + navigationCommands.navigateTo(Routes.Artist(artist.id)) + }, + modifier = modifier, + isBlacklisted = isBlacklisted, + listMode = listMode + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt new file mode 100644 index 00000000..84901d48 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt @@ -0,0 +1,346 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.height +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxEdit +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.resources.iconsax.IconsaxPauseCircle +import org.spotube.spotube.resources.iconsax.IconsaxPlayCircle2 +import org.spotube.spotube.resources.iconsax.IconsaxShuffle +import org.spotube.spotube.resources.iconsax.User +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun CollectionDetails( + modifier: Modifier = Modifier, + title: String, + description: String, + imageURL: String, + imageResource: DrawableResource? = null, + ownerName: String, + ownerImageURL: String?, + onOwnerClick: () -> Unit, + onPlay: () -> Unit, + onShufflePlay: () -> Unit, + onAddToQueue: () -> Unit, + isPlaying: Boolean = false, + /** Guest in a jam session: play/shuffle are replaced by Add to Jam. */ + isJamGuest: Boolean = false, + onAddToJam: (() -> Unit)? = null, + isFollowing: Boolean = false, + isFollowLoading: Boolean = false, + isPlayLoading: Boolean = false, + isShuffleLoading: Boolean = false, + isAddToQueueLoading: Boolean = false, + onFollowClick: () -> Unit = { }, + showFollowButton: Boolean = true, + onEdit: (() -> Unit)? = null, + sharedElementKey: String? = null) { + Box(modifier = modifier.fillMaxWidth()) { + val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium + + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + + val playPauseButton = @Composable { + if (isJamGuest) { + if (onAddToJam != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + MillenniumButton( + modifier = if (isCompact) Modifier.weight(1f) else Modifier, + onClick = onAddToJam!!) { + MillenniumIcon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = stringResource(Res.string.track_options_add_to_jam)) + TextWithShimmer(text = stringResource(Res.string.track_options_add_to_jam)) + } + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + val modifier = if (isCompact) { + Modifier.weight(1f) + } else { + Modifier + } + + MillenniumButton( + modifier = modifier, + onClick = onPlay, + loading = isPlayLoading) { + MillenniumIcon( + imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, + contentDescription = if (isPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description)) + TextWithShimmer(text = if (isPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description)) + } + MillenniumButton( + modifier = modifier, + onClick = onShufflePlay, + loading = isShuffleLoading, + style = MillenniumButtonStyle.Secondary + ) { + MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = stringResource(Res.string.collection_details_shuffle_play)) + TextWithShimmer(text = stringResource(Res.string.collection_details_shuffle)) + } + } + } + } + + val actions = @Composable { + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIconButton( + onClick = onAddToQueue, + loading = isAddToQueueLoading, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = stringResource(Res.string.player_add_to_queue_content_description) + ) + } + MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) + if (showFollowButton) { + if (isFollowing) { + MillenniumIconButton( + onClick = onFollowClick, + loading = isFollowLoading, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxHeart2, + contentDescription = stringResource(Res.string.collection_details_unfollow)) + } + } else { + MillenniumIconButton( + onClick = onFollowClick, + loading = isFollowLoading, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxHeart, + contentDescription = stringResource(Res.string.collection_details_follow), + tint = AppTheme.colors.onSurfaceVariant) + } + } + } + if (onEdit != null) { + MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) + MillenniumIconButton( + onClick = onEdit, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxEdit, + contentDescription = stringResource(Res.string.collection_details_edit_playlist)) + } + } + } + } + val ownerInfo = @Composable { + Row( + modifier = Modifier.clickable(onClick = onOwnerClick), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (ownerImageURL != null) { + AsyncImage( + model = ownerImageURL, + contentDescription = ownerName, + modifier = Modifier + .size(16.dp) + .clip(CircleShape) + .shimmerApply(), + contentScale = ContentScale.Crop) + } else { + MillenniumIcon( + imageVector = Iconsax.User, + contentDescription = ownerName, + modifier = Modifier + .size(16.dp) + .background( + AppTheme.colors.primaryContainer, + CircleShape + ) + .padding(6.dp), + tint = AppTheme.colors.onPrimaryContainer) + } + TextWithShimmer( + text = stringResource(Res.string.collection_details_by_owner, ownerName), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + } + } + + val artwork = @Composable { + Box( + modifier = Modifier + .size(if (isCompact) 100.dp else 200.dp) + .clip(RoundedCornerShape(12.dp)) + .sharedElementOrNone( + key = sharedElementKey, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope) + .shimmerApply(), + contentAlignment = Alignment.Center) { + if (imageURL.isNotBlank()) { + AsyncImage( + model = imageURL, + contentDescription = title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop) + } else if (imageResource != null) { + androidx.compose.foundation.Image( + painter = painterResource(imageResource), + contentDescription = title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop) + } else { + Text( + text = title.take(1).ifBlank { "?" }.uppercase(), + style = AppTheme.typography.displaySmall, + color = AppTheme.colors.onSurfaceVariant) + } + } + } + + val metadata = @Composable { + Column { + TextWithShimmer( + text = title, + style = if (isCompact) AppTheme.typography.bodyLarge else AppTheme.typography.headlineLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + + if (description.isNotBlank()) { + TextWithShimmer( + text = description, + style = if (isCompact) AppTheme.typography.labelSmall else AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + } + } + } + + MillenniumCard( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentPadding = PaddingValues(0.dp)) { + if (isCompact) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically) { + artwork() + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp)) { + metadata() + ownerInfo() + actions() + } + } + playPauseButton() + } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top) { + artwork() + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + metadata() + ownerInfo() + actions() + playPauseButton() + } + } + } + } + } +} + +@Composable +@androidx.compose.ui.tooling.preview.Preview +fun CollectionDetailsPreview() { + CollectionDetails( + title = "My Playlist", + description = "A collection of my favorite songs.", + imageURL = "https://example.com/playlist.jpg", + ownerName = "John Doe", + ownerImageURL = "https://example.com/john.jpg", + onOwnerClick = {}, + onPlay = {}, + onShufflePlay = {}, + onAddToQueue = {}) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt new file mode 100644 index 00000000..e29aa918 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt @@ -0,0 +1,272 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.composeunstyled.Text +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxPauseCircle +import org.spotube.spotube.resources.iconsax.IconsaxPlayCircle2 +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun CollectionView( + title: String, + description: String, + imageURL: String, + imageResource: DrawableResource? = null, + ownerName: String, + ownerImageURL: String?, + onOwnerClick: () -> Unit, + onPlay: () -> Unit, + onShufflePlay: () -> Unit, + onAddToQueue: () -> Unit, + isPlaying: Boolean = false, + isFollowing: Boolean = false, + isFollowLoading: Boolean = false, + isPlayLoading: Boolean = false, + isShuffleLoading: Boolean = false, + isAddToQueueLoading: Boolean = false, + onFollowClick: () -> Unit = {}, + showFollowButton: Boolean = true, + onEdit: (() -> Unit)? = null, + sharedElementKey: String? = null, + isLoading: Boolean = false, + error: String? = null, + onRetry: (() -> Unit)? = null, + tracks: List = emptyList(), + hasMore: Boolean = false, + isLoadingNextPage: Boolean = false, + currentTrackId: String? = null, + isCurrentTrackPlaying: Boolean = false, + onTrackClick: (MetadataTrack) -> Unit = {}, + onLoadNextPage: () -> Unit = {}, + onTrackOptionsAction: (MetadataTrack, TrackOptionsAction) -> Unit = { _, _ -> }, + onArtistClick: (MetadataArtist.Basic) -> Unit = {}, + onAlbumClick: (MetadataAlbum.Detailed) -> Unit = {}, + onArtistsOverflowClick: (MetadataTrack) -> Unit = {}, + onBulkDownload: (List) -> Unit = {}, + onBulkAddToQueue: (List) -> Unit = {}, + onBulkPlayNext: (List) -> Unit = {}, + onBulkAddToPlaylist: (List) -> Unit = {}, + onBulkAddToJam: (List) -> Unit = {}, + isInJam: Boolean = false, + isJamGuest: Boolean = false, + onAddToJam: (List) -> Unit = {}, + trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, + footerContent: (@Composable () -> Unit)? = null, + trailingContent: @Composable () -> Unit = {}) { + val listState = rememberLazyListState() + val isCollapsed by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || + listState.firstVisibleItemScrollOffset > 400 + } + } + + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + backButton = true, + title = if (isCollapsed) { + { + CollapsedCollectionTitle( + title = title, + imageURL = imageURL, + imageResource = imageResource) + } + } else { + {} + }, + actions = if (isCollapsed && !isJamGuest) { + { + MillenniumIconButton( + onClick = onPlay, + loading = isPlayLoading, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, + contentDescription = if (isPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description)) + } + } + } else { + {} + }) + + Box(modifier = Modifier.weight(1f).fillMaxSize()) { + if (error != null && tracks.isEmpty() && !isLoading) { + ErrorDisplay( + errorMessage = error, + onRetry = onRetry ?: {}, + modifier = Modifier.fillMaxSize()) + } else { + TrackList( + modifier = Modifier.fillMaxSize(), + state = listState, + headerContent = { + if (isLoading && tracks.isEmpty()) { + org.spotube.spotube.core.ui.misc.SkeletonTree(true) { + CollectionDetails( + title = title.ifBlank { stringResource(Res.string.common_loading) }, + description = description, + imageURL = imageURL, + imageResource = imageResource, + ownerName = ownerName.ifBlank { stringResource(Res.string.common_unknown) }, + ownerImageURL = ownerImageURL, + onOwnerClick = onOwnerClick, + onPlay = onPlay, + onShufflePlay = onShufflePlay, + onAddToQueue = onAddToQueue, + isPlaying = isPlaying, + isFollowing = isFollowing, + isFollowLoading = isFollowLoading, + isPlayLoading = isPlayLoading, + isShuffleLoading = isShuffleLoading, + isAddToQueueLoading = isAddToQueueLoading, + onFollowClick = onFollowClick, + showFollowButton = showFollowButton, + onEdit = onEdit, + sharedElementKey = sharedElementKey, + isJamGuest = isJamGuest, + onAddToJam = { onAddToJam(tracks) }) + } + } else { + CollectionDetails( + title = title, + description = description, + imageURL = imageURL, + imageResource = imageResource, + ownerName = ownerName, + ownerImageURL = ownerImageURL, + onOwnerClick = onOwnerClick, + onPlay = onPlay, + onShufflePlay = onShufflePlay, + onAddToQueue = onAddToQueue, + isPlaying = isPlaying, + isFollowing = isFollowing, + isFollowLoading = isFollowLoading, + isPlayLoading = isPlayLoading, + isShuffleLoading = isShuffleLoading, + isAddToQueueLoading = isAddToQueueLoading, + onFollowClick = onFollowClick, + showFollowButton = showFollowButton, + onEdit = onEdit, + sharedElementKey = sharedElementKey, + isJamGuest = isJamGuest, + onAddToJam = { onAddToJam(tracks) }) + } + }, + footerContent = footerContent, + tracks = tracks, + error = error, + hasMore = hasMore, + isLoading = isLoading, + isLoadingNextPage = isLoadingNextPage, + currentTrackId = currentTrackId, + isCurrentTrackPlaying = isCurrentTrackPlaying, + onTrackClick = onTrackClick, + onLoadNextPage = onLoadNextPage, + onTrackOptionsAction = onTrackOptionsAction, + onArtistClick = onArtistClick, + onAlbumClick = onAlbumClick, + onArtistsOverflowClick = onArtistsOverflowClick, + onBulkDownload = onBulkDownload, + onBulkAddToQueue = onBulkAddToQueue, + onBulkPlayNext = onBulkPlayNext, + onBulkAddToPlaylist = onBulkAddToPlaylist, + onBulkAddToJam = onBulkAddToJam, + isInJam = isInJam, + isJamGuest = isJamGuest, + trackOptionsState = trackOptionsState) + } + + trailingContent() + } + } +} + +@Composable +private fun CollapsedCollectionTitle( + title: String, + imageURL: String, + imageResource: DrawableResource? = null) { + Row( + verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(6.dp)) + .shimmerApply(), + contentAlignment = Alignment.Center) { + if (imageURL.isNotBlank()) { + AsyncImage( + model = imageURL, + contentDescription = title, + modifier = Modifier.size(36.dp), + contentScale = ContentScale.Crop) + } else if (imageResource != null) { + androidx.compose.foundation.Image( + painter = painterResource(imageResource), + contentDescription = title, + modifier = Modifier.size(36.dp), + contentScale = ContentScale.Crop) + } else { + Text( + text = title.take(1).ifBlank { "?" }.uppercase(), + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) + } + } + TextWithShimmer( + text = title, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 10.dp)) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt new file mode 100644 index 00000000..a5484b68 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt @@ -0,0 +1,266 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + + +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogFooter +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text + +import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.modules.plugin.MetadataAuthViewModel +import org.koin.compose.viewmodel.koinViewModel +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import compose.icons.FeatherIcons +import compose.icons.feathericons.AlertCircle +import compose.icons.feathericons.Copy +import compose.icons.feathericons.Maximize2 +import compose.icons.feathericons.RefreshCw +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun ErrorDisplay( + errorMessage: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, + authViewModel: MetadataAuthViewModel = koinViewModel()) { + val authState by authViewModel.uiState.collectAsStateWithLifecycle() + if (authState.loginRequired) { + LoginRequiredDisplay( + pluginName = authState.pluginName, + onLogin = authViewModel::login, + isLoggingIn = authState.isLoggingIn, + modifier = modifier) + return + } + var showFullLog by remember { mutableStateOf(false) } + val clipboardManager = LocalClipboardManager.current + + Column( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp)) { + MillenniumIcon( + imageVector = FeatherIcons.AlertCircle, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = AppTheme.colors.error.copy(alpha = 0.7f)) + + Text( + text = stringResource(Res.string.error_title), + style = AppTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = AppTheme.colors.onSurface, + textAlign = TextAlign.Center) + + Text( + text = stringResource(Res.string.error_description), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center, + lineHeight = AppTheme.typography.bodyMedium.lineHeight * 1.2) + + MillenniumCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(0.dp)) { + Column( + modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(Res.string.error_details), + style = AppTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurfaceVariant) + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp)) { + MillenniumIconButton( + onClick = { + clipboardManager.setText(AnnotatedString(errorMessage)) + }, + modifier = Modifier.size(32.dp), + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = FeatherIcons.Copy, + contentDescription = stringResource(Res.string.error_copy_to_clipboard), + modifier = Modifier.size(16.dp)) + } + + MillenniumIconButton( + onClick = { showFullLog = true }, + modifier = Modifier.size(32.dp), + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = FeatherIcons.Maximize2, + contentDescription = stringResource(Res.string.error_view_full_log), + modifier = Modifier.size(16.dp)) + } + } + } + + MillenniumCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(0.dp)) { + SelectionContainer { + Text( + text = errorMessage, + style = AppTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace), + color = AppTheme.colors.onSurfaceVariant, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(12.dp)) + } + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumButton( + onClick = onRetry, + modifier = Modifier.height(40.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( + imageVector = FeatherIcons.RefreshCw, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp).size(18.dp)) + Text(stringResource(Res.string.common_retry)) + } + } + } + } + + if (showFullLog) { + ErrorLogDialog( + errorMessage = errorMessage, + onDismiss = { showFullLog = false }) + } +} + +@Composable +private fun ErrorLogDialog( + errorMessage: String, + onDismiss: () -> Unit) { + val clipboardManager = LocalClipboardManager.current + + MillenniumDialog(visible = true, onDismissRequest = onDismiss) { + MillenniumDialogHeader { + MillenniumDialogTitle( + text = stringResource(Res.string.error_full_log_title), + textStyle = AppTheme.typography.titleMedium) + MillenniumIconButton( + onClick = { + clipboardManager.setText(AnnotatedString(errorMessage)) + }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = FeatherIcons.Copy, + contentDescription = stringResource(Res.string.error_copy_to_clipboard)) + } + MillenniumDialogClose() + } + MillenniumDialogBody(scrollable = false) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(400.dp) + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) { + SelectionContainer { + Text( + text = errorMessage, + style = AppTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace), + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(12.dp)) + } + } + } + MillenniumDialogFooter { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.common_close)) + } + } + } +} + +@Preview +@Composable +private fun ErrorLogDialogPreview() { + Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) { + ErrorLogDialog( + errorMessage = "This is an error message", + onDismiss = {}) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt new file mode 100644 index 00000000..6c768959 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt @@ -0,0 +1,409 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.blurEffect +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.ui.coverflow.Coverflow +import org.spotube.spotube.core.ui.coverflow.CoverflowParams +import org.spotube.spotube.core.ui.coverflow.rememberCoverflowState +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxPlayCircle +import org.spotube.spotube.resources.iconsax.Play +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun FeaturedCarousel( + items: List, + modifier: Modifier = Modifier) { + val filteredItems = remember(items) { + items.filter { it is MetadataBrowseItem.Album || it is MetadataBrowseItem.Playlist || it is MetadataBrowseItem.Track } + } + + if (filteredItems.isEmpty()) return + + FeaturedCarouselContent( + items = filteredItems) +} + +@Composable +private fun FeaturedCarouselContent( + items: List, + modifier: Modifier = Modifier) { + val centerIndex = items.size / 2 + var selectedIndex by remember { mutableIntStateOf(centerIndex) } + val state = rememberCoverflowState(centerIndex) { index -> selectedIndex = index } + val isSmallScreen = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium + + Box( + modifier = modifier + .fillMaxWidth() + .height( + if (isSmallScreen) 200.dp else 300.dp + ), + contentAlignment = Alignment.Center) { + Coverflow( + state = state, + params = CoverflowParams( + size = 1f, + offset = 0.38f, + angle = 12f, + shift = 0.255f, + zoom = 0.7864f, + mirror = false), + modifier = modifier) { + items( + items = items, + key = { index: Int -> items[index].hashCode() }) { item: MetadataBrowseItem -> + FeaturedCarouselCard( + item = item, + isCentered = selectedIndex == items.indexOf(item), + onScrollToCenter = { + state.scrollToItem(items.indexOf(item)) + }) + } + } + } +} + +@Composable +private fun FeaturedCarouselCard( + item: MetadataBrowseItem, + isCentered: Boolean, + modifier: Modifier = Modifier, + onScrollToCenter: () -> Unit = { }) { + val navigationCommands = koinInject() + val audioPlayerQueue = koinInject() + + val hazeState = rememberHazeState() + + val thumbnailUrl = when (item) { + is MetadataBrowseItem.Album -> item.data.thumbnails.firstOrNull()?.url + is MetadataBrowseItem.Playlist -> item.data.thumbnails.firstOrNull()?.url + is MetadataBrowseItem.Track -> (item.data.album?.thumbnails ?: item.data.thumbnails)?.firstOrNull()?.url + else -> null + } + + val title = when (item) { + is MetadataBrowseItem.Album -> item.data.title + is MetadataBrowseItem.Playlist -> item.data.title + is MetadataBrowseItem.Track -> item.data.title + else -> "" + } + + val subtitle = when (item) { + is MetadataBrowseItem.Album -> item.data.artists.joinToString { it.name } + is MetadataBrowseItem.Playlist -> item.data.owner?.displayName ?: item.data.owner?.username + ?: stringResource(Res.string.media_playlist) + + is MetadataBrowseItem.Track -> item.data.artists.joinToString { it.name } + else -> "" + } + + val dimAlpha by animateFloatAsState( + targetValue = if (isCentered) 0f else 0.5f, + label = "dim_alpha" + ) + + Box( + modifier = modifier + .clip(RoundedCornerShape(24.dp)) + .clickable() { + if (!isCentered) { + onScrollToCenter() + return@clickable + } + when (item) { + is MetadataBrowseItem.Album -> navigationCommands.navigateTo(Routes.Album(item.data.id)) + is MetadataBrowseItem.Playlist -> navigationCommands.navigateTo( + Routes.Playlist( + item.data.id + ) + ) + + is MetadataBrowseItem.Track -> {} + else -> {} + } + } + .aspectRatio(1f) + .hazeSource(hazeState)) { + AsyncImage( + model = thumbnailUrl, + contentDescription = title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop) + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = dimAlpha))) + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(140.dp) + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.9f))))) + } + } + + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.BottomCenter) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = subtitle, + style = AppTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis) + } + + val animatedAlpha by animateFloatAsState( + targetValue = if (isCentered) 1f else 0f, + animationSpec = tween(durationMillis = 300), + label = "ButtonFade" + ) + + FeaturedPlayButton( + item = item, + audioPlayerQueue = audioPlayerQueue, + navigationCommands = navigationCommands, + enabled = isCentered, + modifier = Modifier + .clip(CircleShape) + .graphicsLayer { + alpha = animatedAlpha + } + .hazeEffect(state = hazeState) { + blurEffect { + blurRadius = 20.dp + colorEffects = + listOf(HazeColorEffect.tint(Color.White.copy(alpha = 0.1f))) + } + } + ) + + } + } +} + +@Composable +private fun FeaturedPlayButton( + item: MetadataBrowseItem, + audioPlayerQueue: AudioPlayerQueue, + navigationCommands: NavigationCommands, + remotePlaybackController: RemotePlaybackController = koinInject(), + enabled: Boolean, + modifier: Modifier = Modifier) { + val scope = rememberCoroutineScope() + val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() + + when (item) { + is MetadataBrowseItem.Album -> { + val isPlaying = currentCollectionEntry is QueueCollectionEntry.Album && + (currentCollectionEntry as QueueCollectionEntry.Album).id == item.data.id + + Box( + modifier = modifier.size(44.dp), + contentAlignment = Alignment.BottomEnd + ) { + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, + onClick = { + if (isPlaying) { + navigationCommands.navigateTo(Routes.Album(item.data.id)) + } else { + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Album, + item.data.id, + item.data.title + ) + } + }, + enabled = enabled, + loading = CollectionPlaybackKey( + RemoteCollectionType.Album, item.data.id, PlaybackDestinationAction.Play + ) in busyCollections, + foreground = Color.White + ) { + MillenniumIcon( + imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = null, + tint = Color.White + ) + } + } + } + + is MetadataBrowseItem.Playlist -> { + val isPlaying = currentCollectionEntry is QueueCollectionEntry.Playlist && + (currentCollectionEntry as QueueCollectionEntry.Playlist).id == item.data.id + + Box( + modifier = modifier.size(44.dp), + contentAlignment = Alignment.BottomEnd + ) { + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, + onClick = { + if (isPlaying) { + navigationCommands.navigateTo(Routes.Playlist(item.data.id)) + } else { + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Playlist, + item.data.id, + item.data.title + ) + } + }, + enabled = enabled, + loading = CollectionPlaybackKey( + RemoteCollectionType.Playlist, item.data.id, PlaybackDestinationAction.Play + ) in busyCollections, + foreground = Color.White + ) { + MillenniumIcon( + imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = null, + tint = Color.White + ) + } + } + } + + is MetadataBrowseItem.Track -> { + val queueEntry = QueueEntry.StreamingTrack(track = item.data, url = "") + val currentTrack by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() + val isPlaying = + currentTrack is QueueEntry.StreamingTrack && (currentTrack as QueueEntry.StreamingTrack).track.id == + item.data.id + + Box( + modifier = modifier.size(44.dp), + contentAlignment = Alignment.BottomEnd + ) { + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, + onClick = { + scope.launch { + audioPlayerQueue.load( + entries = listOf(queueEntry), + autoPlay = true, + startPosition = 0, + collectionEntry = null) + } + }, + enabled = enabled + ) { + MillenniumIcon( + imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = null, + tint = Color.White + ) + } + } + } + + else -> {} + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt new file mode 100644 index 00000000..98439207 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import com.composeunstyled.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxPauseCircle +import org.spotube.spotube.resources.iconsax.Play +import org.jetbrains.compose.resources.painterResource +import org.koin.compose.koinInject +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID +import spotube.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource +import org.spotube.spotube.core.ui.component.cards.PlayableListTile +import org.spotube.spotube.core.ui.component.cards.MediaCardSurface +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.platform.LocalInputModeManager + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun LikedTracksCard( + modifier: Modifier = Modifier, + audioPlayerQueue: AudioPlayerQueue = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), + navigationCommands: NavigationCommands = koinInject(), + listMode: Boolean = false +) { + val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() + val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks + val title = stringResource(Res.string.media_liked_tracks) + val playLoading = CollectionPlaybackKey( + RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, PlaybackDestinationAction.Play + ) in busyCollections + val addToQueueLoading = CollectionPlaybackKey( + RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, PlaybackDestinationAction.AddToQueue + ) in busyCollections + val onPlay = { + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, title) + } + val onAddToQueue = { + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, title) + } + val subtitle = stringResource(Res.string.media_saved_songs) + if (listMode) { + PlayableListTile( + title = title, + subtitle = subtitle, + isPlaying = isPlaying, + onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }, + onPlay = onPlay, + onAddToQueue = onAddToQueue, + modifier = modifier, + sharedElementKey = "saved_tracks_art", + playLoading = playLoading, + addToQueueLoading = addToQueueLoading, + artwork = { + Image( + painterResource(Res.drawable.liked_tracks), + title, + Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + ) + return + } + val touchInput = LocalInputModeManager.current.inputMode == InputMode.Touch + + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + + MediaCardSurface( + modifier = modifier.width(160.dp), + onClick = { navigationCommands.navigateTo(Routes.SavedTracks) } + ) { isHovered -> + Column(modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.fillMaxWidth()) { + androidx.compose.foundation.Image( + painter = painterResource(Res.drawable.liked_tracks), + contentDescription = title, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(8.dp)) + .sharedElementOrNone( + key = "saved_tracks_art", + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope + ), + contentScale = ContentScale.Crop + ) + + Box( + modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), + contentAlignment = Alignment.BottomEnd + ) { + if (isHovered || touchInput || isPlaying || playLoading || addToQueueLoading) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(4.dp) + ) { + MillenniumIconButton( + onClick = onAddToQueue, + loading = addToQueueLoading, + modifier = Modifier.size(30.dp), + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = stringResource(Res.string.player_add_to_queue_content_description), + modifier = Modifier.size(16.dp) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + MillenniumIconButton( + onClick = onPlay, + loading = playLoading, + modifier = Modifier.size(30.dp), + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + imageVector = if (isPlaying) { + Iconsax.IconsaxPauseCircle + } else Iconsax.Play, + contentDescription = stringResource(Res.string.player_play_content_description), + modifier = Modifier.size(16.dp), + tint = LocalTextStyle.current.color + ) + } + } + } + } + } + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp) + ) { + Text( + text = title, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = subtitle, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt new file mode 100644 index 00000000..f65f5b9a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.Lock +import compose.icons.feathericons.LogIn +import org.spotube.spotube.core.ui.theming.AppTheme +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumIcon +import spotube.composeapp.generated.resources.* + +/** Friendly "sign in first" state for screens whose metadata plugin needs an account. */ +@Composable +fun LoginRequiredDisplay( + pluginName: String?, + onLogin: () -> Unit, + modifier: Modifier = Modifier, + isLoggingIn: Boolean = false) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp)) { + Box( + modifier = Modifier + .size(88.dp) + .clip(CircleShape) + .background(AppTheme.colors.primary.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center) { + MillenniumIcon( + imageVector = FeatherIcons.Lock, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = AppTheme.colors.primary) + } + + Text( + text = stringResource(Res.string.login_required_title), + style = AppTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = AppTheme.colors.onSurface, + textAlign = TextAlign.Center) + + Text( + text = if (pluginName.isNullOrBlank()) { + stringResource(Res.string.login_required_description) + } else { + stringResource(Res.string.login_required_description_plugin, pluginName) + }, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + + MillenniumButton( + onClick = onLogin, + loading = isLoggingIn, + modifier = Modifier.height(44.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( + imageVector = FeatherIcons.LogIn, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp).size(18.dp)) + Text(stringResource(Res.string.login_required_action)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt new file mode 100644 index 00000000..ecb12b79 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +@Composable +fun PlaylistCard( + playlist: MetadataPlaylist, + modifier: Modifier = Modifier, + audioPlayerQueue: AudioPlayerQueue = koinInject(), + playbackHelper: CollectionPlaybackHelper = koinInject(), + navigationCommands: NavigationCommands = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), + listMode: Boolean = false +) { + val scope = rememberCoroutineScope() + val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() + + PlayableCard( + title = playlist.title, + subtitle = playlist.description, + imageURL = playlist.thumbnails.firstOrNull()?.url, + isPlaying = currentCollectionEntry?.id == playlist.id, + onClick = { + navigationCommands.navigateTo(Routes.Playlist(playlist.id)) + }, + onPlay = { + if (audioPlayerQueue.isPlaylistPlaying(playlist.id)) return@PlayableCard + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Playlist, + playlist.id, + playlist.title + ) + }, + onAddToQueue = { + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.Playlist, + playlist.id, + playlist.title + ) + }, + modifier = modifier, + sharedElementKey = "playlist_art_${playlist.id}", + listMode = listMode, + playLoading = CollectionPlaybackKey(RemoteCollectionType.Playlist, playlist.id, PlaybackDestinationAction.Play) in busyCollections, + addToQueueLoading = CollectionPlaybackKey(RemoteCollectionType.Playlist, playlist.id, PlaybackDestinationAction.AddToQueue) in busyCollections + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/SharedTransitionLocals.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/SharedTransitionLocals.kt new file mode 100644 index 00000000..a37ef31b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/SharedTransitionLocals.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalSharedTransitionApi::class) +val LocalSharedTransitionScope = compositionLocalOf { null } + +@OptIn(ExperimentalSharedTransitionApi::class) +val LocalAnimatedVisibilityScope = compositionLocalOf { null } + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun Modifier.sharedElementOrNone( + key: String?, + sharedTransitionScope: SharedTransitionScope?, + animatedVisibilityScope: AnimatedVisibilityScope?): Modifier { + if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this + with(sharedTransitionScope) { + return this@sharedElementOrNone.sharedElement( + sharedContentState = rememberSharedContentState(key = key), + animatedVisibilityScope = animatedVisibilityScope) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackCard.kt new file mode 100644 index 00000000..d1862b48 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackCard.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import compose.icons.FeatherIcons +import compose.icons.feathericons.Play +import compose.icons.feathericons.PlusSquare +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +@Composable +fun TrackCard( + track: MetadataTrack, + modifier: Modifier = Modifier, + audioPlayerQueue: AudioPlayerQueue = koinInject()) { + val scope = rememberCoroutineScope() + val queueEntry = QueueEntry.StreamingTrack(track = track, url = "") + + PlayableCard( + title = track.title, + subtitle = track.artists.joinToString { it.name }, + imageURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url, + onPlay = { + scope.launch { + audioPlayerQueue.load( + entries = listOf(queueEntry), + autoPlay = true, + startPosition = 0, + collectionEntry = null) + } + }, + onAddToQueue = { + scope.launch { + audioPlayerQueue.addToQueue(queueEntry) + } + }, + modifier = modifier + ) +} + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackList.kt new file mode 100644 index 00000000..e6e69dfc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackList.kt @@ -0,0 +1,950 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + + + +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.ui.state.ToggleableState +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumCheckbox +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.composables.MillenniumTriStateCheckbox + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.crossfade +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.getPlatform +import org.spotube.spotube.isDesktop +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch +import org.spotube.spotube.resources.iconsax.IconsaxMusicPlaylist +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxSort +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +private val CompactTrackListBreakpoint = 600.dp +private val LargeTrackListBreakpoint = 840.dp +private const val ShimmerRowCount = 6 + +enum class TrackSortOption(val label: StringResource) { + None(Res.string.track_list_sort_original), + Title(Res.string.track_list_sort_title), + Artist(Res.string.track_list_sort_artist), + Album(Res.string.track_list_sort_album), + Duration(Res.string.track_list_sort_duration), +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TrackList( + tracks: List = emptyList(), + error: String? = null, + hasMore: Boolean = false, + isLoading: Boolean = false, + isLoadingNextPage: Boolean = false, + showEmptyMessage: Boolean = true, + headerContent: (@Composable () -> Unit)? = null, + footerContent: (@Composable () -> Unit)? = null, + onLoadNextPage: () -> Unit = {}, + onTrackClick: (MetadataTrack) -> Unit = {}, + onTrackOptionsAction: (MetadataTrack, TrackOptionsAction) -> Unit = { _, _ -> }, + onArtistClick: (MetadataArtist.Basic) -> Unit = {}, + onAlbumClick: (MetadataAlbum.Detailed) -> Unit = {}, + onArtistsOverflowClick: (MetadataTrack) -> Unit = {}, + onBulkDownload: (List) -> Unit = {}, + onBulkAddToQueue: (List) -> Unit = {}, + onBulkPlayNext: (List) -> Unit = {}, + onBulkAddToPlaylist: (List) -> Unit = {}, + onBulkAddToJam: (List) -> Unit = {}, + isInJam: Boolean = false, + isJamGuest: Boolean = false, + currentTrackId: String? = null, + isCurrentTrackPlaying: Boolean = false, + trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current), + simplified: Boolean = false, + scrollable: Boolean = true, + state: LazyListState? = null) { + val filterState = rememberTextFieldState() + val filterQuery = filterState.text.toString() + var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) } + var selectedTrackForOptions by remember { mutableStateOf(null) } + var isSelectionMode by rememberSaveable { mutableStateOf(false) } + var selectedTrackIds by rememberSaveable { mutableStateOf>(emptySet()) } + + val normalizedQuery = remember(filterQuery) { filterQuery.trim().lowercase() } + val visibleTracks = remember(tracks, normalizedQuery, sortBy) { + val filtered = if (normalizedQuery.isBlank()) { + tracks + } else { + tracks.filter { track -> + val searchableText = buildString { + append(track.title).append(' ') + append(track.album?.title.orEmpty()).append(' ') + append(track.artists.joinToString(" ") { it.name }) + }.lowercase() + searchableText.contains(normalizedQuery) + } + } + + when (sortBy) { + TrackSortOption.None -> filtered + TrackSortOption.Title -> filtered.sortedBy { it.title.lowercase() } + TrackSortOption.Artist -> filtered.sortedBy { + it.artists.firstOrNull()?.name?.lowercase().orEmpty() + } + + TrackSortOption.Album -> filtered.sortedBy { it.album?.title?.lowercase() } + TrackSortOption.Duration -> filtered.sortedBy { it.durationMs } + } + } + + val listState = state ?: rememberLazyListState() + val density = LocalDensity.current + val shouldLoadMore = remember(density) { + derivedStateOf { + val loadMoreThreshold = with(density) { 200.dp.toPx() } + val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == "track-card" } + // Trigger when the bottom of the track card is close to/inside the viewport, + // which means the user has scrolled through (or is about to scroll through) + // all loaded tracks. + cardItem != null && cardItem.offset + cardItem.size <= listState.layoutInfo.viewportEndOffset + loadMoreThreshold + } + } + + LaunchedEffect(shouldLoadMore.value) { + if (shouldLoadMore.value && hasMore && !isLoading && !isLoadingNextPage) { + onLoadNextPage() + } + } + + val widthBreakpoint = currentWindowWidthBreakpoint() + val isCompact = widthBreakpoint isBelow WindowBreakpoints.Medium + val isDesktop = remember { getPlatform().isDesktop() } + val showIndex = widthBreakpoint isAtLeast WindowBreakpoints.Expanded + val showAlbum = !isCompact + val useDropdownForOptions = isDesktop && !isCompact + + val trackCardContent: @Composable () -> Unit = { + MillenniumCard( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentPadding = PaddingValues(0.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 12.dp) + ) { + visibleTracks.forEachIndexed { displayedIndex, track -> + if (displayedIndex > 0) { + MillenniumSeparator() + } + TrackListRow( + index = displayedIndex + 1, + track = track, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions, + isCurrentTrack = track.id == currentTrackId, + isCurrentTrackPlaying = isCurrentTrackPlaying, + isSelectionMode = isSelectionMode, + isSelected = selectedTrackIds.contains(track.id), + onTrackClick = { + if (isSelectionMode) { + selectedTrackIds = + if (selectedTrackIds.contains(track.id)) { + selectedTrackIds - track.id + } else { + selectedTrackIds + track.id + } + } else if (isJamGuest) { + onTrackOptionsAction(track, TrackOptionsAction.AddToJam) + } else { + onTrackClick(track) + } + }, + onLongClick = { + if (!useDropdownForOptions && !isSelectionMode) { + isSelectionMode = true + selectedTrackIds = setOf(track.id) + } else if (!useDropdownForOptions) { + selectedTrackForOptions = track + } + }, + onSelectionToggle = { checked -> + isSelectionMode = true + selectedTrackIds = if (checked) { + selectedTrackIds + track.id + } else { + selectedTrackIds - track.id + } + }, + onTrackOptionsAction = { action -> + onTrackOptionsAction( + track, + action + ) + }, + trackOptionsState = trackOptionsState(track), + isInJam = isInJam, + isJamGuest = isJamGuest, + onShowOptionsClick = { selectedTrackForOptions = track }, + onArtistClick = onArtistClick, + onAlbumClick = onAlbumClick, + onArtistsOverflowClick = { onArtistsOverflowClick(track) }) + } + + if (isLoading && tracks.isEmpty()) { + repeat(ShimmerRowCount) { shimmerIndex -> + if (visibleTracks.isNotEmpty() || shimmerIndex > 0) { + MillenniumSeparator( + modifier = Modifier.padding(horizontal = 8.dp)) + } + ShimmerTrackListRow( + index = shimmerIndex + 1, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions) + } + } + + if (error != null) { + if (visibleTracks.isNotEmpty()) { + MillenniumSeparator( + modifier = Modifier.padding(horizontal = 8.dp)) + } + TrackListFeedbackRow( + message = error, + isError = true) + } + + if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) { + TrackListFeedbackRow(stringResource(Res.string.track_list_no_tracks)) + } + + if (isLoadingNextPage) { + if (visibleTracks.isNotEmpty()) { + MillenniumSeparator( + modifier = Modifier.padding(horizontal = 8.dp)) + } + ShimmerTrackListRow( + index = visibleTracks.size + 1, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions) + } + } + } + } + + val filterSortRow: @Composable () -> Unit = { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + .heightIn(max = 60.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End)) { + if (showIndex || isSelectionMode) { + val allSelected = + visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size + val anySelected = selectedTrackIds.isNotEmpty() + val headerState = when { + allSelected -> ToggleableState.On + anySelected -> ToggleableState.Indeterminate + else -> ToggleableState.Off + } + MillenniumTriStateCheckbox( + value = headerState, + onClick = { + if (allSelected) { + isSelectionMode = false + selectedTrackIds = emptySet() + } else { + isSelectionMode = true + selectedTrackIds = visibleTracks.map { it.id }.toSet() + } + }, + label = "") + } + + Box( + modifier = Modifier + .weight(1f) + ) { + MillenniumTextField( + state = filterState, + label = "", + placeholder = stringResource(Res.string.track_list_filter_placeholder), + modifier = Modifier + .widthIn(max = 400.dp) + .align(Alignment.CenterEnd)) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + contentAlignment = Alignment.Center) { + AdaptiveDropdownBottomSheet( + items = TrackSortOption.entries.map { option -> + AdaptiveMenuItem( + label = stringResource(option.label), + onClick = { sortBy = option }, + selected = sortBy == option) + }, + trigger = { onClick -> + MillenniumIconButton( + onClick = onClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxSort, + contentDescription = stringResource(Res.string.track_list_sort)) + } + }) + } + MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) + Box( + contentAlignment = Alignment.Center) { + val targetTracks = if (selectedTrackIds.isNotEmpty()) { + visibleTracks.filter { selectedTrackIds.contains(it.id) } + } else { + visibleTracks + } + val trackCount = targetTracks.size + val isAll = + selectedTrackIds.isEmpty() || trackCount == visibleTracks.size + AdaptiveDropdownBottomSheet( + items = buildList { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxDirectboxReceive, + label = if (isAll) stringResource(Res.string.track_list_download_all) else stringResource(Res.string.track_list_download_count, trackCount), + onClick = { onBulkDownload(targetTracks) })) + if (!isJamGuest) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = if (isAll) stringResource(Res.string.track_list_add_all_to_queue) else stringResource(Res.string.track_list_add_count_to_queue, trackCount), + onClick = { onBulkAddToQueue(targetTracks) })) + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = if (isAll) stringResource(Res.string.track_list_play_all_next) else stringResource(Res.string.track_list_play_count_next, trackCount), + onClick = { onBulkPlayNext(targetTracks) })) + } + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicPlaylist, + label = if (isAll) stringResource(Res.string.track_list_add_all_to_playlist) else stringResource(Res.string.track_list_add_count_to_playlist, trackCount), + onClick = { onBulkAddToPlaylist(targetTracks) })) + } + if (isInJam) { + listOf( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = if (isAll) stringResource(Res.string.track_list_add_all_to_jam) else stringResource(Res.string.track_list_add_count_to_jam, trackCount), + onClick = { onBulkAddToJam(targetTracks) })) + } else { + emptyList() + }, + trigger = { onClick -> + MillenniumIconButton( + onClick = onClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.Iconsax3DotsMore, + contentDescription = stringResource(Res.string.track_list_bulk_actions)) + } + }) + } + } + } + } + + if (scrollable) { + Box(modifier = modifier.fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier + .widthIn(max = 1280.dp) + .align(Alignment.TopCenter) + .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (headerContent != null) { + item { + headerContent() + } + } + if (!simplified) { + item { filterSortRow() } + } + item(key = "track-card") { trackCardContent() } + if (footerContent != null) { + item { + footerContent() + } + } + } + + if (!useDropdownForOptions) { + selectedTrackForOptions?.let { track -> + TrackOptionsBottomSheet( + track = track, + state = trackOptionsState(track), + onDismiss = { selectedTrackForOptions = null }, + onAction = { action -> + onTrackOptionsAction(track, action) + selectedTrackForOptions = null + }, + onAlbumClick = { track.album?.let { onAlbumClick(it) } }, + isInJam = isInJam, + isJamGuest = isJamGuest) + } + } + VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd)) + } + } else { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (headerContent != null) { + headerContent() + } + if (!simplified) { + filterSortRow() + } + trackCardContent() + if (footerContent != null) { + footerContent() + } + } + + if (!useDropdownForOptions) { + selectedTrackForOptions?.let { track -> + TrackOptionsBottomSheet( + track = track, + state = trackOptionsState(track), + onDismiss = { selectedTrackForOptions = null }, + onAction = { action -> + onTrackOptionsAction(track, action) + selectedTrackForOptions = null + }, + onAlbumClick = { track.album?.let { onAlbumClick(it) } }) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TrackListRow( + index: Int, + track: MetadataTrack, + showIndex: Boolean, + showAlbum: Boolean, + useDropdownForOptions: Boolean, + isCurrentTrack: Boolean, + isCurrentTrackPlaying: Boolean, + isSelectionMode: Boolean, + isSelected: Boolean, + onTrackClick: () -> Unit, + onLongClick: () -> Unit, + onSelectionToggle: (Boolean) -> Unit, + onTrackOptionsAction: (TrackOptionsAction) -> Unit, + trackOptionsState: TrackOptionsState, + isInJam: Boolean, + isJamGuest: Boolean, + onShowOptionsClick: () -> Unit, + onArtistClick: (MetadataArtist.Basic) -> Unit, + onAlbumClick: (MetadataAlbum.Detailed) -> Unit, + onArtistsOverflowClick: () -> Unit, + modifier: Modifier = Modifier) { + val artworkInteractionSource = remember { MutableInteractionSource() } + val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState() + val rowInteractionSource = remember { MutableInteractionSource() } + val isRowHovered by rowInteractionSource.collectIsHoveredAsState() + val rowBackgroundColor = when { + isSelected -> AppTheme.colors.secondary.copy(alpha = 0.12f) + isCurrentTrack && isCurrentTrackPlaying -> AppTheme.colors.primaryContainer + isCurrentTrack -> AppTheme.colors.surfaceVariant + trackOptionsState.isBlacklisted -> AppTheme.colors.error.copy(alpha = 0.1f) + else -> Color.Transparent + } + + MillenniumListTile( + modifier = modifier, + selected = isSelected || isCurrentTrack, + onClick = onTrackClick, + title = { + TextWithShimmer( + text = track.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + }, + subtitle = { + ArtistChips( + artists = track.artists, + onArtistClick = onArtistClick, + onArtistsOverflowClick = onArtistsOverflowClick) + }, + leading = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (isSelectionMode) { + MillenniumCheckbox( + checked = isSelected, + onCheckedChange = { onSelectionToggle(it) }, + label = "", + modifier = Modifier.shimmerApply()) + } else if (showIndex) { + Box( + modifier = Modifier.width(30.dp), + contentAlignment = Alignment.Center) { + AnimatedContent( + targetState = isRowHovered, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "index-checkbox") { hovered -> + if (hovered) { + MillenniumCheckbox( + checked = isSelected, + onCheckedChange = { onSelectionToggle(it) }, + label = "", + modifier = Modifier.shimmerApply()) + } else { + TextWithShimmer( + text = index.toString(), + style = AppTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = AppTheme.colors.onSurfaceVariant) + } + } + } + } + + Box( + modifier = Modifier + .size(44.dp) + .clip(AppTheme.shapes.small) + .hoverable(interactionSource = artworkInteractionSource), + contentAlignment = Alignment.Center) { + val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url + val platformContext = LocalPlatformContext.current + val imageRequest = remember(imageUrl) { + ImageRequest.Builder(platformContext) + .data(imageUrl) + .size(128) + .crossfade(false) + .build() + } + + AsyncImage( + model = imageRequest, + contentDescription = track.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().shimmerApply()) + + if (isArtworkHovered || isCurrentTrack) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center) { + MillenniumIcon( + imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description), + tint = Color.White, + modifier = Modifier.size(18.dp).shimmerApply()) + } + } + } + } + }, + trailing = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (showAlbum && track.album != null) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val isPressed by interactionSource.collectIsPressedAsState() + val colorScheme = AppTheme.colors + + TextWithShimmer( + text = track.album!!.title, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .widthIn(max = 220.dp) + .hoverable(interactionSource) + .clickable( + indication = null, + interactionSource = interactionSource) { onAlbumClick(track.album!!) } + .then( + if (isHovered || isPressed) { + Modifier.drawWithCache { + val underlineHeight = 1.dp.toPx() + onDrawWithContent { + drawContent() + drawLine( + color = colorScheme.primary, + start = Offset(0f, size.height - underlineHeight), + end = Offset(size.width, size.height - underlineHeight), + strokeWidth = underlineHeight) + } + } + } else { + Modifier + })) + } + + TextWithShimmer( + text = track.durationMs.toDurationString(), + style = AppTheme.typography.bodySmall, + textAlign = TextAlign.End, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.width(56.dp)) + + if (useDropdownForOptions) { + TrackOptions( + track = track, + state = trackOptionsState, + onAction = onTrackOptionsAction, + onAlbumClick = { track.album?.let { onAlbumClick(it) } }, + isInJam = isInJam, + isJamGuest = isJamGuest) + } else { + MillenniumIconButton( + onClick = onShowOptionsClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.Iconsax3DotsMore, + contentDescription = stringResource(Res.string.track_options_title)) + } + } + } + }) +} + +@Composable +private fun ArtistChips( + artists: List, + onArtistClick: (MetadataArtist.Basic) -> Unit, + onArtistsOverflowClick: () -> Unit) { + val visibleArtists = artists.take(2) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth()) { + visibleArtists.forEachIndexed { index, artist -> + Row( + verticalAlignment = Alignment.CenterVertically) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val isPressed by interactionSource.collectIsPressedAsState() + + TextWithShimmer( + text = artist.name, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null, + modifier = Modifier + .clickable( + indication = null, + interactionSource = interactionSource + ) { onArtistClick(artist) } + ) + if (index < visibleArtists.size - 1) + Text( + ",", + color = AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodySmall + ) + } + } + + val remaining = artists.size - visibleArtists.size + if (remaining > 0) { + TextWithShimmer( + text = stringResource(Res.string.track_list_more_artists, remaining), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.clickable(onClick = onArtistsOverflowClick)) + } + } +} + +@Composable +private fun ShimmerTrackListRow( + index: Int, + showIndex: Boolean, + showAlbum: Boolean, + useDropdownForOptions: Boolean) { + val dummyTrack = remember(index) { dummyTrackForShimmer(index) } + SkeletonTree(true) { + TrackListRow( + index = index, + track = dummyTrack, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions, + isCurrentTrack = false, + isCurrentTrackPlaying = false, + isSelectionMode = false, + isSelected = false, + onTrackClick = {}, + onLongClick = {}, + onSelectionToggle = {}, + onTrackOptionsAction = {}, + trackOptionsState = TrackOptionsState(), + isInJam = false, + isJamGuest = false, + onShowOptionsClick = {}, + onArtistClick = {}, + onAlbumClick = {}, + onArtistsOverflowClick = {}) + } +} + +private fun dummyTrackForShimmer(index: Int): MetadataTrack { + val artist = MetadataArtist.Basic( + id = "shimmer_artist_$index", + name = "Artist Name", + thumbnails = emptyList(), + externalUri = null) + return MetadataTrack( + id = "shimmer_track_$index", + title = "Track Title Here", + durationMs = 210_000, + trackNumber = index, + discNumber = 1, + artists = listOf( + artist, + artist.copy(id = "shimmer_artist_${index}_2", name = "Another Artist") + ), + album = MetadataAlbum.Detailed( + releaseDate = null, + genres = emptyList(), + trackCount = 10, + id = "shimmer_album_$index", + title = "Album Name", + description = null, + thumbnails = emptyList(), + albumType = MetadataAlbumType.Album, + artists = listOf(artist), + externalUri = null), + thumbnails = null, + explicit = false, + popularity = 0, + isrcCode = null, + externalUri = null) +} + +@Composable +private fun TrackListFeedbackRow( + message: String, + isError: Boolean = false) { + TextWithShimmer( + text = message, + color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp)) +} + +private fun Long.toDurationString(): String { + val totalSeconds = (this / 1000).coerceAtLeast(0) + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "$minutes:${seconds.toString().padStart(2, '0')}" +} + +fun previewTracks(): List { + val artists = listOf( + MetadataArtist.Basic( + id = "artist_1", + name = "Aria Vale", + thumbnails = emptyList(), + externalUri = null), + MetadataArtist.Basic( + id = "artist_2", + name = "Neon Harbor", + thumbnails = emptyList(), + externalUri = null), + MetadataArtist.Basic( + id = "artist_3", + name = "Echo Drift", + thumbnails = emptyList(), + externalUri = null)) + + val album = MetadataAlbum.Detailed( + releaseDate = null, + genres = emptyList(), + trackCount = 10, + id = "album_1", + title = "Night Drive Archives", + description = null, + thumbnails = listOf( + Thumbnail( + url = "https://picsum.photos/120", + width = 120, + height = 120) + ), + albumType = MetadataAlbumType.Album, + artists = artists.take(2), + externalUri = null) + + return listOf( + MetadataTrack( + id = "track_1", + title = "Signal Bloom", + durationMs = 198_000, + trackNumber = 1, + discNumber = 1, + artists = artists, + album = album, + thumbnails = null, + explicit = false, + popularity = 92, + isrcCode = null, + externalUri = null), + MetadataTrack( + id = "track_2", + title = "Static Horizon", + durationMs = 224_000, + trackNumber = 2, + discNumber = 1, + artists = artists.take(2), + album = album.copy(id = "album_2", title = "City Pulse"), + explicit = false, + popularity = 80, + isrcCode = null, + externalUri = null, + thumbnails = null), + MetadataTrack( + id = "track_3", + title = "Last Train Echo", + durationMs = 246_000, + trackNumber = 3, + discNumber = 1, + artists = artists.take(1), + album = album.copy(id = "album_3", title = "Afterlight"), + explicit = false, + popularity = 77, + isrcCode = null, + externalUri = null, + thumbnails = null)) +} + +@Composable +@Preview +private fun TrackListPreview() { + Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) { + TrackList( + tracks = previewTracks(), + hasMore = true, + isLoadingNextPage = true) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackOptions.kt new file mode 100644 index 00000000..1b7217c9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackOptions.kt @@ -0,0 +1,341 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.resources.iconsax.IconsaxMusicCircle +import org.spotube.spotube.resources.iconsax.IconsaxMusicPlaylist +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemove +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxShare +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +sealed interface TrackOptionsAction { + data object AddToJam : TrackOptionsAction + data object StartRadio : TrackOptionsAction + data object PlayNext : TrackOptionsAction + data object AddToQueue : TrackOptionsAction + data object RemoveFromQueue : TrackOptionsAction + data object ToggleFavorite : TrackOptionsAction + data object AddToPlaylist : TrackOptionsAction + data object Download : TrackOptionsAction + data object ToggleBlacklist : TrackOptionsAction + data object Share : TrackOptionsAction +} + +data class TrackOptionsState( + val isInQueue: Boolean = false, + val isCurrentlyPlaying: Boolean = false, + val isFavorite: Boolean = false, + val isBlacklisted: Boolean = false) + +data class TrackOptionsContext( + val currentTrackId: String? = null, + val queueTrackIds: Set = emptySet(), + val savedTrackIds: Set = emptySet(), + val blacklistedTrackIds: Set = emptySet(), + val blacklistedArtistIds: Set = emptySet(), + val forceFavorite: Boolean = false) { + fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState( + isInQueue = track.id in queueTrackIds, + isCurrentlyPlaying = track.id == currentTrackId, + isFavorite = forceFavorite || track.id in savedTrackIds, + isBlacklisted = track.id in blacklistedTrackIds || + track.artists.any { it.id in blacklistedArtistIds }) +} + +@Composable +fun TrackOptions( + track: MetadataTrack, + state: TrackOptionsState, + onAction: (TrackOptionsAction) -> Unit, + onAlbumClick: () -> Unit, + modifier: Modifier = Modifier, + isInJam: Boolean = false, + isJamGuest: Boolean = false) { + AdaptiveDropdownBottomSheet( + items = buildTrackMenuItems( + track = track, + state = state, + onAction = onAction, + onAlbumClick = onAlbumClick, + isInJam = isInJam, + isJamGuest = isJamGuest), + trigger = { onClick -> + MillenniumIconButton( + onClick = onClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.Iconsax3DotsMore, + contentDescription = stringResource(Res.string.track_options_title), + modifier = Modifier.shimmerApply() + ) + } + }, + headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, + header = { TrackOptionsSheetHeader(track = track) }, + modifier = modifier) +} + +@Composable +fun TrackOptionsBottomSheet( + track: MetadataTrack, + state: TrackOptionsState, + onDismiss: () -> Unit, + onAction: (TrackOptionsAction) -> Unit, + onAlbumClick: () -> Unit, + isInJam: Boolean = false, + isJamGuest: Boolean = false) { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + LaunchedEffect(sheetState.currentDetent) { + if (sheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } + MillenniumModalBottomSheet( + state = sheetState, + contentPadding = PaddingValues(top = 10.dp, bottom = 8.dp), + contentSpacing = 4.dp) { + Column(modifier = Modifier.fillMaxWidth()) { + TrackOptionsSheetHeader(track = track) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp)) { + buildTrackMenuItems( + track = track, + state = state, + onAction = { action -> + onAction(action) + onDismiss() + }, + onAlbumClick = { + onAlbumClick() + onDismiss() + }, + isInJam = isInJam, + isJamGuest = isJamGuest).forEach { item -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = item.enabled, + onClick = item.onClick) + .padding(horizontal = 12.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + item.icon?.let { icon -> + MillenniumIcon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = if (item.enabled) { + AppTheme.colors.onSurface + } else { + AppTheme.colors.onSurface.copy(alpha = 0.38f) + }) + } ?: Spacer(modifier = Modifier.size(22.dp)) + Text( + text = item.label, + style = AppTheme.typography.bodyLarge, + color = if (item.enabled) { + AppTheme.colors.onSurface + } else { + AppTheme.colors.onSurface.copy(alpha = 0.38f) + }) + } + } + } + } + } +} + +@Composable +fun TrackOptionsSheetHeader(track: MetadataTrack) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + AsyncImage( + model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(56.dp) + .clip(AppTheme.shapes.small)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = track.title, + style = AppTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis) + Text( + text = track.artists.joinToString(", ") { it.name }, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + track.album?.let { album -> + Text( + text = album.title, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +private fun buildTrackMenuItems( + track: MetadataTrack, + state: TrackOptionsState, + onAction: (TrackOptionsAction) -> Unit, + onAlbumClick: () -> Unit, + isInJam: Boolean = false, + isJamGuest: Boolean = false): List = buildList { + if (isInJam) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = stringResource(Res.string.track_options_add_to_jam), + onClick = { onAction(TrackOptionsAction.AddToJam) })) + } + + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicCircle, + label = stringResource(Res.string.track_options_start_radio), + onClick = { onAction(TrackOptionsAction.StartRadio) })) + + // A guest's queue is the shared jam queue — mutating it locally is not + // allowed, so queue actions are replaced by "Add to Jam". + if (!isJamGuest) { + if (!state.isInQueue && !state.isCurrentlyPlaying) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = stringResource(Res.string.track_options_play_next), + onClick = { onAction(TrackOptionsAction.PlayNext) })) + } else if (state.isInQueue && !state.isCurrentlyPlaying) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = stringResource(Res.string.track_options_move_to_next), + onClick = { onAction(TrackOptionsAction.PlayNext) })) + } + + if (!state.isInQueue) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = stringResource(Res.string.player_add_to_queue_content_description), + onClick = { onAction(TrackOptionsAction.AddToQueue) })) + } else { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicSquareRemove, + label = stringResource(Res.string.track_options_remove_from_queue), + onClick = { onAction(TrackOptionsAction.RemoveFromQueue) })) + } + } + + add( + AdaptiveMenuItem( + icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, + label = if (state.isFavorite) stringResource(Res.string.track_options_remove_from_favorites) else stringResource(Res.string.track_options_save_as_favorite), + onClick = { onAction(TrackOptionsAction.ToggleFavorite) })) + + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicPlaylist, + label = stringResource(Res.string.track_options_add_to_playlist), + onClick = { onAction(TrackOptionsAction.AddToPlaylist) })) + + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxDirectboxReceive, + label = stringResource(Res.string.track_options_download), + onClick = { onAction(TrackOptionsAction.Download) })) + + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicSquareRemoveFilled, + label = if (state.isBlacklisted) stringResource(Res.string.blacklist_remove) else stringResource(Res.string.blacklist_add), + onClick = { onAction(TrackOptionsAction.ToggleBlacklist) })) + + if (!track.externalUri.isNullOrBlank()) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxShare, + label = stringResource(Res.string.track_options_share), + onClick = { onAction(TrackOptionsAction.Share) })) + } + + if (track.album != null) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxCd, + label = stringResource(Res.string.player_go_to_album), + onClick = onAlbumClick)) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/UserCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/UserCard.kt new file mode 100644 index 00000000..5213cc0c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/UserCard.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.Alignment +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.spotube.core.ui.component.cards.AvatarCard +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun UserCard( + user: MetadataUser, + modifier: Modifier = Modifier) { + AvatarCard( + title = user.displayName ?: user.username, + subtitle = stringResource(Res.string.media_user), + imageURL = user.thumbnails.firstOrNull()?.url, + onClick = { + // TODO: Route to user details screen. + }, + modifier = modifier) +} + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.kt new file mode 100644 index 00000000..33b63559 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import org.spotube.spotube.getPlatform +import org.spotube.spotube.isDesktop + +@Composable +expect fun VerticalScrollbar( + listState: LazyListState, + modifier: Modifier = Modifier) + +fun Modifier.dragScrollable(rowState: LazyListState): Modifier = + if (getPlatform().isDesktop()) pointerInput(rowState) { + detectDragGestures { change, dragAmount -> + // Invert drag direction so content follows direct-manipulation behavior. + rowState.dispatchRawDelta(-dragAmount.x) + } + } else Modifier diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarCard.kt new file mode 100644 index 00000000..a9593908 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarCard.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component.cards + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.User + +@Composable +fun AvatarCard( + title: String, + subtitle: String? = null, + imageURL: String? = null, + onClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, + isBlacklisted: Boolean = false, + listMode: Boolean = false +) { + if (listMode) { + AvatarListTile(title, subtitle, imageURL, onClick, modifier, isBlacklisted) + return + } + + MediaCardSurface(modifier.width(160.dp), onClick, isBlacklisted = isBlacklisted) { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(CircleShape) + .padding(12.dp), + contentAlignment = Alignment.Center + ) { + MediaArtwork( + model = imageURL, + title = title, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(CircleShape) + .shimmerApply(), + shape = CircleShape, + placeholderIcon = Iconsax.User + ) + } + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp) + ) { + TextWithShimmer( + text = title, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + subtitle?.let { + Spacer(Modifier.height(4.dp)) + TextWithShimmer( + text = subtitle, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarListTile.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarListTile.kt new file mode 100644 index 00000000..fe656879 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarListTile.kt @@ -0,0 +1,31 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.core.ui.component.cards + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.User + +@Composable +fun AvatarListTile( + title: String, + subtitle: String? = null, + imageURL: String? = null, + onClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, + isBlacklisted: Boolean = false +) { + PlayableListTile( + title = title, + subtitle = subtitle, + imageURL = imageURL, + onClick = onClick, + modifier = modifier, + imageShape = CircleShape, + placeholderIcon = Iconsax.User, + isBlacklisted = isBlacklisted + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaCardSurface.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaCardSurface.kt new file mode 100644 index 00000000..e291363f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaCardSurface.kt @@ -0,0 +1,84 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.core.ui.component.cards + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumActionPointer +import org.spotube.millennium.theme.millenniumSurface + +/** Interaction state is presentation-only; all navigation/playback stays in event callbacks. */ +@Composable +internal fun MediaCardSurface( + modifier: Modifier, + onClick: (() -> Unit)?, + interactive: Boolean = onClick != null, + isBlacklisted: Boolean = false, + content: @Composable (highlighted: Boolean) -> Unit +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val pressed by interaction.collectIsPressedAsState() + val focused by interaction.collectIsFocusedAsState() + var groupFocused by remember { mutableStateOf(false) } + val colors = MillenniumTheme.colors + val highlight by animateFloatAsState( + if (hovered || focused || groupFocused || pressed) 1f else 0f, + tween(140) + ) + val scale by animateFloatAsState(if (pressed) 0.985f else 1f, tween(100)) + val shape = RoundedCornerShape(10.dp) + Box( + modifier.graphicsLayer { scaleX = scale; scaleY = scale } + .millenniumSurface( + background = Brush.verticalGradient( + listOf( + colors.raised.copy(alpha = highlight), + if (isBlacklisted) colors.danger.copy(alpha = 0.12f + highlight * 0.12f) + else colors.surface.copy(alpha = highlight) + ) + ), + shape = shape, + borderColor = (if (focused) colors.accent else colors.border).copy(alpha = highlight), + borderWidth = if (focused) 2.dp else 1.dp, + innerGlowColor = colors.highlight.copy(alpha = highlight * 0.4f), + shadowElevation = (highlight * 4f).dp + ) + .clip(shape) + .onFocusChanged { groupFocused = it.hasFocus } + .focusGroup() + .hoverable(interaction, enabled = interactive) + .then( + if (onClick != null) Modifier.millenniumActionPointer().clickable( + interactionSource = interaction, + indication = LocalIndication.current, + role = Role.Button, + onClick = onClick + ) else Modifier + ) + ) { content(hovered || focused || groupFocused) } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaEmptyState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaEmptyState.kt new file mode 100644 index 00000000..9af4fe34 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaEmptyState.kt @@ -0,0 +1,66 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.core.ui.component.cards + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush +import org.spotube.millennium.theme.millenniumSurface + +@Composable +internal fun MediaEmptyState( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, + description: String? = null, + action: (@Composable () -> Unit)? = null +) { + Box(modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Column( + Modifier.widthIn(max = 360.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + Modifier.size(64.dp).millenniumSurface( + background = millenniumBevelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(14.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ), + contentAlignment = Alignment.Center + ) { + MillenniumIcon( + icon, + null, + modifier = Modifier.size(30.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + } + Text(title, style = AppTheme.typography.titleMedium, textAlign = TextAlign.Center) + if (description != null) Text( + description, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center + ) + action?.invoke() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt new file mode 100644 index 00000000..8f9e81d8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt @@ -0,0 +1,224 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component.cards + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import com.composeunstyled.LocalTextStyle +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import org.spotube.spotube.core.ui.component.LocalAnimatedVisibilityScope +import org.spotube.spotube.core.ui.component.LocalSharedTransitionScope +import org.spotube.spotube.core.ui.component.sharedElementOrNone +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_add_to_queue_content_description +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.media_preview_title +import spotube.composeapp.generated.resources.media_preview_subtitle + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun PlayableCard( + title: String, + subtitle: String? = null, + imageURL: String? = null, + isPlaying: Boolean = false, + onClick: (() -> Unit)? = null, + onPlay: (() -> Unit)? = null, + onAddToQueue: (() -> Unit)? = null, + modifier: Modifier = Modifier, + sharedElementKey: String? = null, + listMode: Boolean = false, + playLoading: Boolean = false, + addToQueueLoading: Boolean = false +) { + if (listMode) { + PlayableListTile( + title, + subtitle, + imageURL, + isPlaying, + onClick, + onPlay, + onAddToQueue, + modifier, + sharedElementKey, + playLoading = playLoading, + addToQueueLoading = addToQueueLoading + ) + return + } + val touchInput = LocalInputModeManager.current.inputMode == InputMode.Touch + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + + MediaCardSurface( + modifier = modifier.width(160.dp), + onClick = onClick ?: onPlay, + interactive = onClick != null || onPlay != null || onAddToQueue != null + ) { highlighted -> + Column(modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.fillMaxWidth().shimmerApply()) { + MediaArtwork( + model = imageURL, + title = title, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(8.dp)) + .sharedElementOrNone( + key = sharedElementKey, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope + ), + shape = RoundedCornerShape(8.dp) + ) + + if (onPlay != null || onAddToQueue != null) + Box( + modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), + contentAlignment = Alignment.BottomEnd + ) { + this@Column.AnimatedVisibility( + visible = highlighted || touchInput || (isPlaying && onPlay != null) || + playLoading || addToQueueLoading, + enter = fadeIn(), + exit = fadeOut() + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(4.dp) + ) { + // While this collection is playing the play/pause toggle stays + // visible and the add-to-queue action is hidden entirely. + onAddToQueue?.takeIf { !isPlaying }?.let { + MillenniumIconButton( + onClick = onAddToQueue, + loading = addToQueueLoading, + modifier = Modifier.size(34.dp), + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = stringResource( + Res.string.player_add_to_queue_content_description + ), + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + onPlay?.let { + MillenniumIconButton( + onClick = onPlay, + loading = playLoading, + modifier = Modifier.size(34.dp), + style = MillenniumIconButtonStyle.Primary + + ) { + MillenniumIcon( + imageVector = if (isPlaying) { + Iconsax.IconsaxPause + } else Iconsax.IconsaxPlay, + contentDescription = stringResource( + if (isPlaying) { + Res.string.player_pause_content_description + } else { + Res.string.player_play_content_description + } + ), + tint = LocalTextStyle.current.color + ) + } + } + } + } + } + } + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp) + ) { + TextWithShimmer( + text = title, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + subtitle?.let { + TextWithShimmer( + text = subtitle, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + } +} + +@PreviewLightDark +@Composable +private fun PlayableCardPreview() { + Box { + PlayableCard( + title = stringResource(Res.string.media_preview_title), + subtitle = stringResource(Res.string.media_preview_subtitle), + imageURL = "https://placehold.co/600x400", + isPlaying = true, + onClick = {}, + onPlay = {}, + onAddToQueue = {} + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt new file mode 100644 index 00000000..abc69cda --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt @@ -0,0 +1,182 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.core.ui.component.cards + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.composeunstyled.LocalTextStyle +import coil3.compose.AsyncImage +import org.spotube.spotube.core.ui.component.LocalAnimatedVisibilityScope +import org.spotube.spotube.core.ui.component.LocalSharedTransitionScope +import org.spotube.spotube.core.ui.component.sharedElementOrNone +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun PlayableListTile( + title: String, + subtitle: String? = null, + imageURL: String? = null, + isPlaying: Boolean = false, + onClick: (() -> Unit)? = null, + onPlay: (() -> Unit)? = null, + onAddToQueue: (() -> Unit)? = null, + modifier: Modifier = Modifier, + sharedElementKey: String? = null, + playLoading: Boolean = false, + addToQueueLoading: Boolean = false, + imageModel: Any? = imageURL, + imageShape: Shape = RoundedCornerShape(6.dp), + placeholderIcon: ImageVector = Iconsax.IconsaxMusic, + artwork: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + isBlacklisted: Boolean = false, + compact: Boolean = false +) { + val colors = MillenniumTheme.colors + MillenniumListTile( + modifier = modifier, + onClick = onClick ?: onPlay, + selected = isPlaying, + background = if (isPlaying) null else millenniumPanelBrush(colors), + shape = RoundedCornerShape(8.dp), + borderColor = if (isBlacklisted) colors.danger.copy(alpha = 0.5f) else if (isPlaying) null else colors.border, + titleForeground = if (isBlacklisted) colors.danger else null, + borderWidth = 1.dp, + contentPadding = PaddingValues(if (compact) 8.dp else 12.dp), + minimumHeight = if (compact) 52.dp else 76.dp, + leading = { + MediaArtwork( + imageModel, title, + Modifier.size(if (compact) 36.dp else 52.dp).sharedElementOrNone( + sharedElementKey, + LocalSharedTransitionScope.current, + LocalAnimatedVisibilityScope.current + ), + imageShape, placeholderIcon, artwork + ) + }, + title = { + TextWithShimmer( + title, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + subtitle = subtitle?.let { text -> + { + TextWithShimmer( + text, + style = AppTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + }, + trailing = if (onPlay != null || onAddToQueue != null || trailingContent != null) { + { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + trailingContent?.invoke() + if (onAddToQueue != null && !isPlaying) { + MillenniumIconButton( + onClick = onAddToQueue, + loading = addToQueueLoading, + modifier = Modifier.size(if (compact) 32.dp else 40.dp), + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + Iconsax.IconsaxAddSquare, + stringResource(Res.string.player_add_to_queue_content_description) + ) + } + } + if (onPlay != null) { + MillenniumIconButton( + onClick = onPlay, + loading = playLoading, + modifier = Modifier.size(if (compact) 32.dp else 40.dp), + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + stringResource(if (isPlaying) Res.string.player_pause_content_description else Res.string.player_play_content_description), + tint = LocalTextStyle.current.color + ) + } + } + } + } + } else null + ) +} + +@Composable +internal fun MediaArtwork( + model: Any?, + title: String, + modifier: Modifier, + shape: Shape = RoundedCornerShape(6.dp), + placeholderIcon: ImageVector = Iconsax.IconsaxMusic, + artwork: (@Composable () -> Unit)? = null +) { + Box( + modifier.millenniumSurface( + background = millenniumBevelBrush(MillenniumTheme.colors), + shape = shape, + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ).clip(shape).shimmerApply(), + contentAlignment = Alignment.Center + ) { + if (artwork != null) artwork() + else if (model != null) AsyncImage( + model, + title, + Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + else MillenniumIcon( + placeholderIcon, + null, + modifier = Modifier.size(24.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Coverflow.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Coverflow.kt new file mode 100644 index 00000000..4ff18b1a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Coverflow.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.coverflow + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Coverflow( + modifier: Modifier = Modifier, + state: CoverflowState = rememberCoverflowState(), + params: CoverflowParams = CoverflowParams(), + content: CoverflowScope.() -> Unit) { + var size by remember { mutableStateOf(IntSize.Zero) } + var geometry by remember { mutableStateOf(null) } + + LaunchedEffect(geometry) { + if (state.initialScrollDone) return@LaunchedEffect + val g = geometry ?: return@LaunchedEffect + state.lazyListState.scrollToItem( + state.selectedIndex + 1, + -g.spacerWidth) + state.initialScrollDone = true + } + + Box(modifier = modifier.fillMaxSize()) { + LazyRow( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + size = coordinates.size + }, + verticalAlignment = Alignment.CenterVertically, + state = state.lazyListState, + flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState)) { + if (size != IntSize.Zero) { + val g = Geometry(params, size) + geometry = g + state.geometry = g + + val coverflowScope = CoverflowScopeImpl( + geometry = g, + lazyListScope = this, + coverflowState = state) + + item { + Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() })) + } + + coverflowScope.apply(content) + + item { + Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() })) + } + } + } + + Box( + modifier = Modifier + .fillMaxHeight() + .align(Alignment.CenterStart) + .clickable { + if (state.selectedIndex > 0) { + state.scrollToItem(state.selectedIndex - 1) + } + }) {} + Box( + modifier = Modifier + .fillMaxHeight() + .align(Alignment.CenterEnd) + .clickable { + state.scrollToItem(state.selectedIndex + 1) + }) {} + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowGeometry.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowGeometry.kt new file mode 100644 index 00000000..2b85d539 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowGeometry.kt @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.coverflow + +import androidx.compose.runtime.Stable +import androidx.compose.ui.unit.IntSize +import kotlin.math.abs +import kotlin.math.min + +@Stable +internal class Geometry( + internal val params: CoverflowParams = CoverflowParams(), + private val size: IntSize = IntSize.Zero) { + @Stable + private val shortEdge + get() = min(size.width, size.height) + + @Stable + private val containerCenter + get() = size.width / 2 + + @Stable + private val zoomDelta + get() = 1 - params.zoom + + @Stable + internal val coverSize + get() = shortEdge * params.size + + @Stable + internal val coverOffset + get() = (coverSize * params.offset).toInt() + + @Stable + internal val spacerWidth + get() = containerCenter - coverOffset / 2 + + @Stable + internal fun isSelected(horizontalPosition: Float): Boolean { + return distanceToCenter(horizontalPosition).toInt() == 0 + } + + @Stable + internal fun distanceToCenter(horizontalPosition: Float): Float { + return horizontalPosition + coverOffset * 0.5f - containerCenter + } + + @Stable + internal fun effectFactor(distanceToCenter: Float): Float { + val relative = distanceToCenter / coverSize + val absolute = abs(relative) + val start = 0f + val end = 0.5f + var factor = if (absolute <= start) { + 0f + } else if (absolute >= end) { + 1f + } else { + val intervalStep = absolute - start + val delta = end - start + intervalStep / delta + } + if (relative < 0) { + factor = -factor + } + return factor + } + + @Stable + internal fun rotation(distanceToCenter: Float): Float { + return -params.angle * effectFactor(distanceToCenter) + } + + @Stable + internal fun scale(distanceToCenter: Float): Float { + return 1 - zoomDelta * abs(effectFactor(distanceToCenter)) + } + + @Stable + internal fun translationX(distanceToCenter: Float): Float { + return coverOffset * params.shift * effectFactor(distanceToCenter) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowItem.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowItem.kt new file mode 100644 index 00000000..6b49fc94 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowItem.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.coverflow + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.zIndex +import kotlin.math.abs + +@Composable +internal fun CoverflowItem( + geometry: Geometry, + onClickHandler: () -> Unit = {}, + onSelectedHandler: (Boolean) -> Unit, + content: @Composable () -> Unit) { + var horizontalPosition by remember { mutableStateOf(null) } + val distanceToCenter = geometry.distanceToCenter(horizontalPosition ?: 0f) + + Box( + modifier = Modifier + .width(with(LocalDensity.current) { geometry.coverOffset.toDp() }) + .zIndex(1f - abs(distanceToCenter)) + .onGloballyPositioned { coordinates -> + horizontalPosition = coordinates.positionInParent().x + }, + contentAlignment = Alignment.Center) { + if (horizontalPosition != null) { + onSelectedHandler(geometry.isSelected(horizontalPosition ?: 0f)) + + Box( + modifier = Modifier + .requiredSize(with(LocalDensity.current) { geometry.coverSize.toDp() }) + .graphicsLayer( + rotationY = geometry.rotation(distanceToCenter), + translationX = geometry.translationX(distanceToCenter), + scaleY = geometry.scale(distanceToCenter), + scaleX = geometry.scale(distanceToCenter)), + propagateMinConstraints = true) { + val interactionSource = remember { MutableInteractionSource() } + if (geometry.params.mirror) { + Mirror( + modifier = Modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClickHandler), + coverSize = geometry.coverSize) { + content() + } + } else { + Box( + modifier = Modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClickHandler)) { + content() + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowParams.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowParams.kt new file mode 100644 index 00000000..5efeac08 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowParams.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.coverflow + +import androidx.compose.runtime.Immutable + +@Immutable +data class CoverflowParams( + val size: Float = 0.5f, + val offset: Float = 0.4f, + val angle: Float = 45f, + val shift: Float = 0.4f, + val zoom: Float = 0.8f, + val mirror: Boolean = false) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowScope.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowScope.kt new file mode 100644 index 00000000..6a21e771 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowScope.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.coverflow + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable + +interface CoverflowScope { + fun items( + items: List, + onSelectHandler: (item: T, index: Int) -> Unit = { _: T, _: Int -> }, + key: ((index: Int) -> Any)? = null, + contentType: (index: Int) -> Any? = { null }, + itemContent: @Composable ((item: T) -> Unit)) + + fun items( + count: Int, + onSelectHandler: (index: Int) -> Unit = {}, + key: ((index: Int) -> Any)? = null, + contentType: (index: Int) -> Any? = { null }, + itemContent: @Composable ((index: Int) -> Unit)) +} + +internal class CoverflowScopeImpl( + private val geometry: Geometry, + private val lazyListScope: LazyListScope, + private val coverflowState: CoverflowState) : CoverflowScope { + override fun items( + items: List, + onSelectHandler: (item: T, index: Int) -> Unit, + key: ((index: Int) -> Any)?, + contentType: (index: Int) -> Any?, + itemContent: @Composable (item: T) -> Unit) = items( + count = items.size, + onSelectHandler = { + onSelectHandler(items[it], it) + }, + key = key, + contentType = contentType) { + itemContent(items[it]) + } + + override fun items( + count: Int, + onSelectHandler: (index: Int) -> Unit, + key: ((index: Int) -> Any)?, + contentType: (index: Int) -> Any?, + itemContent: @Composable (index: Int) -> Unit) = lazyListScope.items( + count, + key, + contentType) { + CoverflowItem( + onClickHandler = { coverflowState.scrollToItem(it) }, + onSelectedHandler = { isSelected: Boolean -> + if (selectHandler(it, isSelected)) { + onSelectHandler(it) + } + }, + geometry = geometry) { + itemContent(it) + } + } + + private fun selectHandler( + index: Int, + isSelected: Boolean): Boolean { + if (!isSelected && index == coverflowState.selectedIndex) { + coverflowState.selectedIndex = -1 + } else if (isSelected && index != coverflowState.selectedIndex) { + coverflowState.selectedIndex = index + return true + } + return false + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowState.kt new file mode 100644 index 00000000..04acc272 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowState.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.coverflow + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun rememberCoverflowState( + initialIndex: Int = 0, + onSelectHandler: (Int) -> Unit = {}): CoverflowState { + val lazyListState: LazyListState = rememberLazyListState( + initialFirstVisibleItemIndex = initialIndex + 1) + val coroutineScope: CoroutineScope = rememberCoroutineScope() + return remember { + CoverflowState( + lazyListState, + coroutineScope, + onSelectHandler, + initialIndex) + } +} + +class CoverflowState internal constructor( + internal val lazyListState: LazyListState, + private val coroutineScope: CoroutineScope, + private val onSelectHandler: (Int) -> Unit = {}, + initialIndex: Int = 0) { + internal var geometry: Geometry? = null + internal var initialScrollDone: Boolean = false + + var selectedIndex: Int = initialIndex + internal set(it) { + field = it + onSelectHandler(it) + } + + fun scrollToItem(index: Int) { + val geometryCopy = geometry + if (geometryCopy != null) { + coroutineScope.launch { + lazyListState.animateScrollToItem( + index + 1, + -geometryCopy.spacerWidth) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Mirror.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Mirror.kt new file mode 100644 index 00000000..ca924c8e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Mirror.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.coverflow + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times + +@Composable +internal fun Mirror( + modifier: Modifier = Modifier, + coverSize: Float, + content: @Composable () -> Unit) { + val coverSizeDp = with(LocalDensity.current) { coverSize.toDp() } + + Column( + modifier = modifier + .padding(top = coverSizeDp) + .fillMaxWidth() + .requiredHeight(2 * coverSizeDp)) { + Box( + propagateMinConstraints = true, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .height(coverSizeDp)) { + content() + } + Box( + propagateMinConstraints = true, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .height(coverSizeDp) + .graphicsLayer( + compositingStrategy = CompositingStrategy.Offscreen, + rotationX = 180f) + .clip(HalfSizeShape) + .drawWithContent { + val colors = listOf( + Color.Transparent, + Color.Transparent, + Color.Black) + drawContent() + drawRect( + topLeft = Offset( + 0f, + this.size.height / 2), + size = Size( + this.size.width, + this.size.height / 2), + brush = Brush.verticalGradient(colors), + blendMode = BlendMode.DstIn) + } + .blur( + radiusX = 1.dp, + radiusY = 3.dp)) { + content() + } + } +} + +private object HalfSizeShape : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density): Outline = Outline.Rectangle( + Rect( + Offset( + 0f, + size.height / 2), + Size( + size.width, + size.height))) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/Shimmer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/Shimmer.kt new file mode 100644 index 00000000..6588d239 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/Shimmer.kt @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.misc + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.text.TextAutoSize +import com.composeunstyled.LocalTextStyle +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import com.valentinilk.shimmer.ShimmerBounds +import com.valentinilk.shimmer.rememberShimmer +import com.valentinilk.shimmer.shimmer +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush + +val LocalSkeletonLoading = compositionLocalOf { false } + +@Composable +fun SkeletonTree( + isLoading: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + // Intercept clicks and touches globally when loading + val interactionModifier = if (isLoading) { + modifier.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { it.consume() } + } + } + } + } else { + modifier + } + + CompositionLocalProvider(LocalSkeletonLoading provides isLoading) { + if (isLoading) { + // Valentinilk's synchronized hardware-accelerated shimmer loop + Box(modifier = interactionModifier.shimmer(rememberShimmer(shimmerBounds = ShimmerBounds.View))) { + content() + } + } else { + Box(modifier = interactionModifier) { + content() + } + } + } +} + +fun Modifier.shimmerApply( + color: Color? = null // defaults to Millennium's recessed skeleton fill +): Modifier = composed { + val isTreeLoading = LocalSkeletonLoading.current + val skeletonBrush = millenniumBevelBrush(MillenniumTheme.colors, sunken = true) + + when { + !isTreeLoading -> this + color != null -> this.background(color) + else -> this.background(skeletonBrush) + } +} + +@Composable +fun TextWithShimmer( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + autoSize: TextAutoSize? = null, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, + style: TextStyle = LocalTextStyle.current) { + // While a skeleton tree is loading, the placeholder glyphs must not be readable: + // only the shimmer bars (from `shimmerApply`) should show through. + val isLoadingSkeleton = LocalSkeletonLoading.current + Text( + text = text, + color = if (isLoadingSkeleton) Color.Transparent else color, + modifier = Modifier.shimmerApply().then(modifier), + autoSize = autoSize, + fontSize = fontSize, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign ?: TextAlign.Start, + lineHeight = lineHeight, + overflow = overflow, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout, + style = style + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt new file mode 100644 index 00000000..6720efb9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.theming + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.millennium.composables.MillenniumModalBottomSheetTheme +import org.spotube.millennium.composables.MillenniumTooltipHost +import org.spotube.millennium.composables.MillenniumTopAppBarTheme +import org.spotube.millennium.composables.MillenniumTopAppBarThemeProperties +import org.spotube.millennium.composables.defaultMillenniumModalBottomSheetTheme +import org.spotube.millennium.composables.defaultMillenniumTopAppBarTheme +import org.spotube.millennium.theme.MillenniumColors +import org.spotube.millennium.theme.MillenniumDarkThemeComponents +import org.spotube.millennium.theme.MillenniumLightThemeComponents +import org.spotube.millennium.theme.MillenniumPalette +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.MillenniumThemeConfig + +/** + * Semantic color roles used across the app, resolved from the active Millennium palette. + * + * This is not a second palette: every role is derived from [MillenniumColors], so the + * whole app follows the selected Millennium accent family and light/dark appearance. + */ +@Immutable +data class AppColors( + val primary: Color, + val onPrimary: Color, + val primaryContainer: Color, + val onPrimaryContainer: Color, + val secondary: Color, + val onSecondary: Color, + val secondaryContainer: Color, + val onSecondaryContainer: Color, + val tertiaryContainer: Color, + val onTertiaryContainer: Color, + val background: Color, + val onBackground: Color, + val surface: Color, + val onSurface: Color, + val surfaceVariant: Color, + val onSurfaceVariant: Color, + val surfaceContainer: Color, + val surfaceContainerHigh: Color, + val surfaceContainerHighest: Color, + val inverseSurface: Color, + val inverseOnSurface: Color, + val outline: Color, + val outlineVariant: Color, + val error: Color, + val onError: Color, + val errorContainer: Color, + val onErrorContainer: Color, + val scrim: Color +) + +/** Maps Millennium's semantic palette onto the app's role names. */ +fun appColorsFrom(m: MillenniumColors): AppColors = AppColors( + primary = m.accent, + onPrimary = m.onAccent, + primaryContainer = m.raised, + onPrimaryContainer = m.text, + secondary = m.accent, + onSecondary = m.onAccent, + secondaryContainer = m.raised, + onSecondaryContainer = m.text, + tertiaryContainer = m.raised, + onTertiaryContainer = m.text, + background = m.background, + onBackground = m.text, + surface = m.surface, + onSurface = m.text, + surfaceVariant = m.inset, + onSurfaceVariant = m.muted, + surfaceContainer = m.surface, + surfaceContainerHigh = m.raised, + surfaceContainerHighest = m.raised, + inverseSurface = m.text, + inverseOnSurface = m.background, + outline = m.border, + outlineVariant = m.border, + error = m.danger, + onError = m.background, + errorContainer = m.danger.copy(alpha = 0.16f), + onErrorContainer = m.danger, + scrim = m.scrim +) + +/** Named text styles used by the app, independent of Material typography. */ +@Immutable +data class AppTypography( + val displaySmall: TextStyle, + val headlineLarge: TextStyle, + val headlineMedium: TextStyle, + val headlineSmall: TextStyle, + val titleLarge: TextStyle, + val titleMedium: TextStyle, + val titleSmall: TextStyle, + val bodyLarge: TextStyle, + val bodyMedium: TextStyle, + val bodySmall: TextStyle, + val labelLarge: TextStyle, + val labelMedium: TextStyle, + val labelSmall: TextStyle +) + +private fun style( + size: Int, + lineHeight: Int, + weight: FontWeight = FontWeight.Normal +): TextStyle = TextStyle( + fontFamily = FontFamily.SansSerif, + fontSize = size.sp, + lineHeight = lineHeight.sp, + fontWeight = weight +) + +val AppDefaultTypography = AppTypography( + displaySmall = style(36, 44), + headlineLarge = style(32, 40), + headlineMedium = style(28, 36), + headlineSmall = style(24, 32), + titleLarge = style(22, 28, FontWeight.Medium), + titleMedium = style(16, 24, FontWeight.Medium), + titleSmall = style(14, 20, FontWeight.Medium), + bodyLarge = style(16, 24), + bodyMedium = style(14, 20), + bodySmall = style(12, 16), + labelLarge = style(14, 20, FontWeight.Medium), + labelMedium = style(12, 16, FontWeight.Medium), + labelSmall = style(11, 16, FontWeight.Medium) +) + +/** Corner shapes used by the app. */ +@Immutable +data class AppShapes( + val small: Shape, + val medium: Shape, + val large: Shape +) + +val AppDefaultShapes = AppShapes( + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp) +) + +val LocalAppColors = + staticCompositionLocalOf { appColorsFrom(org.spotube.millennium.theme.MillenniumLightColors) } +val LocalAppTypography = staticCompositionLocalOf { AppDefaultTypography } +val LocalAppShapes = staticCompositionLocalOf { AppDefaultShapes } + +/** Reads the active app theme tokens, all backed by Millennium. */ +object AppTheme { + val colors: AppColors @Composable get() = LocalAppColors.current + val typography: AppTypography @Composable get() = LocalAppTypography.current + val shapes: AppShapes @Composable get() = LocalAppShapes.current +} + +/** + * The app's single source of truth for colour. + * + * Installs [MillenniumTheme] with the user's selected [UserSettings.palette] and light/dark + * [UserSettings.theme], then derives the app's semantic [AppColors], [AppTypography] and + * [AppShapes] tokens from the active Millennium theme. + */ +@Composable +fun SpotubeTheme( + settings: UserSettings, + content: @Composable () -> Unit +) { + val isDarkTheme = when (settings.theme) { + Theme.LIGHT -> false + Theme.DARK -> true + Theme.SYSTEM -> isSystemInDarkTheme() + } + val palette: MillenniumPalette = settings.palette + + val darkTheme = MillenniumDarkThemeComponents + val lightTheme = MillenniumLightThemeComponents + + MillenniumTheme( + dark = isDarkTheme, + config = MillenniumThemeConfig( + palette = palette, + dark = darkTheme.copy( + topAppBarTheme = MillenniumTopAppBarTheme( + properties = darkTheme.topAppBarTheme.properties.copy( + separatorColor = Color.Transparent + ) + ), + modalBottomSheetTheme = compactModalBottomSheetTheme() + ), + light = lightTheme.copy( + topAppBarTheme = MillenniumTopAppBarTheme( + properties = lightTheme.topAppBarTheme.properties.copy( + separatorColor = Color.Transparent + ) + ), + modalBottomSheetTheme = compactModalBottomSheetTheme() + ) + ) + ) { + val colors = appColorsFrom(MillenniumTheme.colors) + MillenniumTooltipHost { + CompositionLocalProvider( + LocalAppColors provides colors, + LocalAppTypography provides AppDefaultTypography, + LocalAppShapes provides AppDefaultShapes, + content = content + ) + } + } +} + +/** Library default is 24dp padding + 16dp spacing, which is far too roomy on phones. */ +private fun compactModalBottomSheetTheme(): MillenniumModalBottomSheetTheme { + val padding = PaddingValues(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp) + fun org.spotube.millennium.composables.MillenniumModalBottomSheetThemeProperties.compact() = + copy(contentPadding = padding, contentSpacing = 8.dp) + val states = defaultMillenniumModalBottomSheetTheme().states + return MillenniumModalBottomSheetTheme( + states = org.spotube.millennium.theme.MillenniumControlStateProperty( + default = states.default.compact(), + hovered = states.hovered.compact(), + focused = states.focused.compact(), + pressed = states.pressed.compact(), + disabled = states.disabled.compact(), + selected = states.selected.compact(), + error = states.error.compact())) +} + +/** Whether the active Millennium appearance is light. */ +@Composable +fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebView.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebView.kt new file mode 100644 index 00000000..b89c9ec8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebView.kt @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.webview + +import dev.nucleusframework.webview.web.WebContent +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.cookie.CookieManager +import org.spotube.plugin_interfaces.host_apis.Cookie +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import org.koin.core.component.KoinComponent + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +class WebViewController(val navigationCommands: NavigationCommands): KoinComponent { + private val logger by injectLogger() + private var cookieManager: CookieManager? = null + private val urlFlow = MutableStateFlow("") + private val webViewCreated = MutableSharedFlow(replay = 1) + var currentPluginId: String? = null + private set + + suspend fun getCookies(url: String): List { + if (cookieManager == null) { + logger.w { "CookieManager is not initialized. Returning empty cookie list." } + return emptyList() + } + val cookies = cookieManager!!.getCookies(url) + val cookieList = mutableListOf() + cookies.forEach { + cookieList.add( + Cookie( + name = it.name, + value = it.value, + domain = it.domain ?: "", + path = it.path, + expiresAt = it.expiresDate, + secure = it.isSecure ?: false, + httpOnly = it.isHttpOnly ?: false + ) + ) + } + return cookieList + } + + private var content: String? = null + private var isHtmlContent: Boolean = false + fun getContent(): String? = content + fun getWebContent(additionalHttpHeaders: Map = emptyMap()): WebContent { + if (content == null) throw IllegalStateException("Content is null. This should not happen as WebView should only be opened when content is set.") + if (isHtmlContent) return WebContent.Data(data = content!!, mimeType = "text/html") + return WebContent.Url(url = content!!, additionalHttpHeaders = additionalHttpHeaders) + } + + var webViewNavigator: WebViewNavigator? = null + + fun emitUrlChange(url: String) { + urlFlow.value = url + } + + fun emitWebViewCreated() { + webViewCreated.tryEmit(Unit) + } + + fun setCookieManager(cookieManager: CookieManager) { + this.cookieManager = cookieManager + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun closeWebview() { + cookieManager = null + content = null + isHtmlContent = false + webViewNavigator = null + navigationCommands.pop(Routes.WebView) + urlFlow.value = "" + webViewCreated.resetReplayCache() + _postMessagesFlow.resetReplayCache() + } + + fun dispose() { + cookieManager = null + content = null + isHtmlContent = false + webViewNavigator = null + } + + fun navigateTo(url: String, pluginId: String) { + if (this.content != null) { + throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.") + } + this.currentPluginId = pluginId + this.content = url + this.isHtmlContent = false + navigationCommands.navigateTo(Routes.WebView) + } + + fun navigateToHTML(html: String, pluginId: String) { + if (this.content != null) { + throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.") + } + this.currentPluginId = pluginId + this.content = html + this.isHtmlContent = true + navigationCommands.navigateTo(Routes.WebView) + } + + suspend fun evaluateJavascript(jsCode: String): String? { + if (webViewNavigator == null) { + throw IllegalStateException("WebView is not initialized. Cannot evaluate JavaScript.") + } + val completer = CompletableDeferred() + try { + webViewNavigator?.evaluateJavaScript(jsCode) { result -> + completer.complete(result) + } + } catch (e: Exception) { + completer.completeExceptionally(e) + throw e + } + return completer.await() + } + + suspend fun clearData(pluginId: String? = null) { + cookieManager?.removeAllCookies() + val targetPluginId = pluginId ?: currentPluginId + platformClearWebviewData(targetPluginId) + cookieManager = null + content = null + isHtmlContent = false + webViewNavigator = null + currentPluginId = null + } + + val urlChangedFlow = urlFlow.asStateFlow() + val webviewCreatedFlow = webViewCreated.asSharedFlow() + private val _postMessagesFlow = MutableSharedFlow(replay = 1) + fun emitPostMessage(message: String) { + _postMessagesFlow.tryEmit(message) + } + + val postMessagesFlow = _postMessagesFlow.asSharedFlow() +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.kt new file mode 100644 index 00000000..5dab45ed --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.webview + +import dev.nucleusframework.webview.web.WebViewState + +expect fun platformWebviewConfig(webView: WebViewState, pluginId: String?) + +expect suspend fun platformClearWebviewData(pluginId: String?) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewScreen.kt new file mode 100644 index 00000000..c6984344 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewScreen.kt @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.webview + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.ui.draw.clip + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronLeft +import compose.icons.feathericons.ChevronRight +import compose.icons.feathericons.X +import org.spotube.spotube.core.tools.user_agents.UserAgents +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import dev.nucleusframework.webview.jsbridge.IJsMessageHandler +import dev.nucleusframework.webview.jsbridge.JsMessage +import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge +import dev.nucleusframework.webview.web.WebView +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewState +import dev.nucleusframework.webview.web.rememberWebViewNavigator +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +class PostMessageHandler( + private val onMessageReceived: (String) -> Unit = {} +) : IJsMessageHandler { + override fun methodName(): String { + return "sendMessage" + } + + override fun handle( + message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit + ) { + onMessageReceived(message.params) + callback(message.params) + } +} + +@Composable +fun PlatformWebViewScreen(webViewController: WebViewController) { + if (webViewController.getContent() == null) { + // This should never happen, but just in case + Text(stringResource(Res.string.webview_no_url)) + return + } + + val state = remember { + WebViewState( + webViewController.getWebContent( + additionalHttpHeaders = mapOf( + "User-Agent" to UserAgents.random() + ) + ) + ) + }.apply { + this.content = webViewController.getWebContent() + platformWebviewConfig(this, webViewController.currentPluginId) + } + + val navigator = rememberWebViewNavigator() + val webViewBridge = rememberWebViewJsBridge(navigator) + + val bridgeBootstrapScript = remember { + """ + (function() { + if (typeof window.sendMessage !== "function") { + window.sendMessage = function(message) { + if (typeof message !== "string") { + throw new TypeError("[window.sendMessage] Message must be a string"); + } + window.kmpJsBridge.callNative("sendMessage", message); + }; + } + + if (!window.bridgeReady) { + const event = new CustomEvent("onBridgeReady"); + window.dispatchEvent(event); + window.bridgeReady = true; + } + })(); + """.trimIndent() + } + + LaunchedEffect(state) { + snapshotFlow { state.lastLoadedUrl }.collect { url -> + if (url != null) { + webViewController.emitUrlChange(url) + navigator.evaluateJavaScript(bridgeBootstrapScript) + webViewController.emitWebViewCreated() + } + } + } + + LaunchedEffect(state.cookieManager, navigator) { + webViewController.setCookieManager(cookieManager = state.cookieManager) + webViewController.webViewNavigator = navigator + } + + LaunchedEffect(webViewBridge) { + webViewBridge.register(PostMessageHandler { message -> + webViewController.emitPostMessage(message) + }) + } + + DisposableEffect(Unit) { + onDispose { + webViewController.dispose() + } + } + + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { + Row( + modifier = Modifier.fillMaxWidth().height(56.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier.height(56.dp) + ) { + MillenniumIconButton( + onClick = { + navigator.navigateBack() + }, + enabled = navigator.canGoBack, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + FeatherIcons.ChevronLeft, + contentDescription = stringResource(Res.string.webview_go_back) + ) + } + MillenniumIconButton( + onClick = { + navigator.navigateForward() + }, + enabled = navigator.canGoForward, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + FeatherIcons.ChevronRight, + contentDescription = stringResource(Res.string.webview_go_forward) + ) + } + } + Box( + modifier = Modifier + .weight(1f) + .height(36.dp) + .padding(horizontal = 4.dp) + .clip(RoundedCornerShape(18.dp)) + .background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f)) + .border( + BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)), + RoundedCornerShape(18.dp)), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = state.lastLoadedUrl ?: "", + onValueChange = {}, // Read-only + readOnly = true, + singleLine = true, + textStyle = AppTheme.typography.bodyMedium.copy( + color = AppTheme.colors.onSurface, + textAlign = TextAlign.Start + ), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp) + .wrapContentHeight(Alignment.CenterVertically) + ) + } + } + } + ) + WebView( + state = state, + modifier = Modifier.weight(1f).fillMaxWidth(), + navigator = navigator, + webViewJsBridge = webViewBridge, + onCreated = { webView -> + navigator.evaluateJavaScript(bridgeBootstrapScript) + }) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/BuiltInPluginService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/BuiltInPluginService.kt new file mode 100644 index 00000000..b4a71234 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/BuiltInPluginService.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline + +import app.cash.zipline.ZiplineService +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.spotube.core.zipline.plugin_apis.common.RealCoreAPI +import org.spotube.spotube.core.zipline.plugin_apis.lrclib.RealLRCLibLyricsAPI +import org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt.RealNewPipeAudioAPI +import org.spotube.spotube.modules.plugin.LRCLIB_BUILT_IN_PLUGIN +import org.spotube.spotube.modules.plugin.NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN +import org.spotube.spotube.modules.plugin.PluginEntry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import kotlin.reflect.KClass + +class BuiltInPluginService( + private val pluginInfo: PluginEntry) : PluginService, KoinComponent { + + override val pluginId: String = pluginInfo.id + private val loggedInStateFlow = MutableStateFlow(false) + override val loggedInFlow = loggedInStateFlow.asStateFlow() + val servicesRegistry = mutableMapOf, ZiplineService>() + +// val webViewController: WebViewController by inject() +// val persistedStorage by lazy { RealPersistedStorageAPI(pluginInfo) } + + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private fun runLogInFlowObservers() = scope.launch { + val coreAPI = servicesRegistry[CoreAPI::class] as CoreAPI? + coreAPI?.loggedInFlow?.collect { isLoggedIn -> + loggedInStateFlow.value = isLoggedIn + } + } + + override suspend fun start() { + when (pluginInfo) { + NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> { + servicesRegistry[CoreAPI::class] = RealCoreAPI() + servicesRegistry[AudioAPI::class] = RealNewPipeAudioAPI() + } + + LRCLIB_BUILT_IN_PLUGIN -> { + servicesRegistry[CoreAPI::class] = RealCoreAPI() + servicesRegistry[LyricsAPI::class] = RealLRCLibLyricsAPI() + } + } + runLogInFlowObservers() + } + + override suspend fun stop() { + for (service in servicesRegistry.values) { + service.close() + } + servicesRegistry.clear() + } + + override suspend fun use(block: suspend PluginServiceScope.() -> T): T { + //Since built-in plugins don't require any special setup, we can just execute the block with an empty scope + return block(PluginServiceScope(servicesRegistry)) + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/FileSystemHTTPClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/FileSystemHTTPClient.kt new file mode 100644 index 00000000..a08da9a6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/FileSystemHTTPClient.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline + +import app.cash.zipline.loader.ZiplineHttpClient +import org.spotube.spotube.core.di.injectLogger +import io.ktor.http.URLBuilder +import io.ktor.http.decodeURLQueryComponent +import okio.ByteString +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath +import okio.SYSTEM +import org.koin.core.component.KoinComponent + +class FileSystemHTTPClient(private val baseDir: Path) : ZiplineHttpClient(), KoinComponent { + private val logger by injectLogger() + private val okio = FileSystem.SYSTEM + + override suspend fun download( + url: String, + requestHeaders: List> + ): ByteString { + try { + val parsedUrl = URLBuilder(url) + var fullPath = parsedUrl.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath() + + if (fullPath == null && parsedUrl.encodedPathSegments.isNotEmpty()) { + fullPath = baseDir / parsedUrl.encodedPathSegments.last() + logger.d { "Constructed full path from URL path: $fullPath" } + } + + if (fullPath == null) { + throw IllegalArgumentException("[FileSystemHTTPClient] Invalid URL: $url. Expected a 'path' query parameter or a simple path in the URL.") + } + + logger.d { "Reading: $fullPath" } + return okio.read(fullPath) { + val str = readByteString() + // Print the length of the content being read for debugging + logger.d { "Read ${str.size / 1024.0} KB from $fullPath" } + // Print the sha256 hash of the content for verification + val hash = str.sha256().hex() + logger.d { "SHA-256 hash of content: $hash" } + str + } + } catch (e: Exception) { + logger.e(e) { "Error reading file for URL: $url. Exception: ${e.message}" } + throw e + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/PluginService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/PluginService.kt new file mode 100644 index 00000000..023415fe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/PluginService.kt @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline + +import app.cash.zipline.ZiplineService +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import kotlinx.coroutines.flow.StateFlow +import kotlin.reflect.KClass + +class PluginServiceScope(private val registry: Map, ZiplineService>) { + val coreAPI: CoreAPI get() = getService() + val metadataUserAPI: MetadataUserAPI get() = getService() + val metadataTrackAPI: MetadataTrackAPI get() = getService() + val metadataAlbumAPI: MetadataAlbumAPI get() = getService() + val metadataArtistAPI: MetadataArtistAPI get() = getService() + val metadataPlaylistAPI: MetadataPlaylistAPI get() = getService() + val metadataBrowseAPI: MetadataBrowseAPI get() = getService() + val metadataSearchAPI: MetadataSearchAPI get() = getService() + val audioAPI: AudioAPI get() = getService() + val lyricsAPI: LyricsAPI get() = getService() + val scrobbleAPI: ScrobbleAPI get() = getService() + + private inline fun getService(): T { + return registry[T::class] as? T + ?: error("${T::class.simpleName} is not initialized or loaded.") + } +} + +interface PluginService { + val pluginId: String + val loggedInFlow: StateFlow + + suspend fun start() + suspend fun stop() + suspend fun use(block: suspend PluginServiceScope.() -> T): T +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt new file mode 100644 index 00000000..99496217 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline + +import kotlinx.coroutines.CoroutineDispatcher + +/** + * A coroutine dispatcher that can be closed/shut down. + */ +class ZiplineDispatcher( + val dispatcher: CoroutineDispatcher, + private val onClose: () -> Unit = {} +) { + fun close() { + onClose() + } +} + +/** + * Creates a single-threaded coroutine dispatcher suitable for running Zipline/QuickJS. + * + * QuickJS's compile() uses deep C-level recursion on the native thread stack. + * Zipline.create() sets maxStackSize to 6 MiB and expects the calling thread + * to have at least 8 MiB of stack. On JVM/Android, the default thread stack + * size is ~1 MiB, which causes native stack overflow when compiling large + * JS modules like kotlin-stdlib (~491 KB). + * + * iOS secondary threads get only 512 KiB, so every platform must create its own + * large-stack thread. This expect/actual ensures the backing thread has one. + */ +expect fun createZiplineDispatcher(): ZiplineDispatcher + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplinePluginService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplinePluginService.kt new file mode 100644 index 00000000..9b02c9e4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplinePluginService.kt @@ -0,0 +1,344 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline + +import app.cash.zipline.Zipline +import app.cash.zipline.ZiplineService +import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh +import app.cash.zipline.loader.LoadResult +import app.cash.zipline.loader.ManifestVerifier +import app.cash.zipline.loader.ZiplineLoader +import org.spotube.plugin_interfaces.core.Initializer +import org.spotube.plugin_interfaces.core.Initializer_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.CryptoAPI +import org.spotube.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.PersistedStorageAPI +import org.spotube.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.SystemInformationAPI +import org.spotube.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.WebViewAPI +import org.spotube.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.webview.WebViewController +import org.spotube.spotube.core.zipline.host_apis.RealCryptoAPI +import org.spotube.spotube.core.zipline.host_apis.RealHttpClientAPI +import org.spotube.spotube.core.zipline.host_apis.RealPersistedStorageAPI +import org.spotube.spotube.core.zipline.host_apis.RealSystemInformationAPI +import org.spotube.spotube.core.zipline.host_apis.RealWebViewAPI +import org.spotube.spotube.modules.plugin.PluginAbility +import org.spotube.spotube.modules.plugin.PluginCapability +import org.spotube.spotube.modules.plugin.PluginEntry +import io.ktor.http.URLBuilder +import io.ktor.http.decodeURLQueryComponent +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okio.Path.Companion.toPath +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.reflect.KClass + +/** + * Manages the lifecycle of a Zipline plugin. + * It runs everything on its own dispatcher (different thread) as per Zipline's requirements. + * Anything it provides, must be called within that dispatcher context. + * The [use] function must be used. + * + * The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well, + * so if they are calling something on [Dispatchers.Main], those calls should be wrapped in + * `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows. + * + * The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called. + */ +open class ZiplinePluginService( + val applicationName: String, + private val manifestUrl: String, + private val pluginInfo: PluginEntry) : PluginService, KoinComponent { + + override val pluginId: String = pluginInfo.id + + // QuickJS compile() uses deep C-level recursion on the native thread stack. + // Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules + // (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing. + // We use a custom EventListener to increase maxStackSize right after the Zipline + // instance is created, before any modules are loaded. + private val ziplineDispatcher = createZiplineDispatcher() + + private fun trace(event: String) { + logger.d { "[$applicationName] $event" } + } + + private val logger by injectLogger() + private val webViewController: WebViewController by inject() + + private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher) + private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable -> + logger.e(throwable) { "Zipline Engine Error" } + } + private val lifecycleMutex = Mutex() + private var ziplineLoader: ZiplineLoader + private var ziplineInstance: Zipline? = null + private val serviceRegistry = mutableMapOf, ZiplineService>() + + init { + val manifestPath = URLBuilder(manifestUrl) + val baseDir = + manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent + ?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.") + ziplineLoader = ZiplineLoader( + dispatcher = ziplineDispatcher.dispatcher, + manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS, + httpClient = FileSystemHTTPClient(baseDir) + ) + } + + private val realHttpClientAPI = RealHttpClientAPI() + private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id) + + private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo) + private val cryptoAPI = RealCryptoAPI(scope.coroutineContext) + private val systemInformationAPI = RealSystemInformationAPI() + + private val loggedInStateFlow = MutableStateFlow(false) + override val loggedInFlow: StateFlow = loggedInStateFlow.asStateFlow() + + private fun bindHostServices(zipline: Zipline) { + trace("initializer(): binding host APIs") + logger.d { "[$applicationName] Binding host APIs in initializer" } + try { + // Basic APIs + zipline.bind(CryptoAPI_SERVICE_NAME, cryptoAPI) + zipline.bind( + SystemInformationAPI_SERVICE_NAME, + systemInformationAPI + ) + + // Conditional APIs based on plugin capabilities + if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) { + zipline.bind( + HttpClientAPI_SERVICE_NAME, + realHttpClientAPI + ) + } + if (PluginCapability.WEBVIEW in pluginInfo.capabilities) { + zipline.bind(WebViewAPI_SERVICE_NAME, realWebViewAPI) + } + if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) { + zipline.bind( + PersistedStorageAPI_SERVICE_NAME, + persistedStorageAPI + ) + } + } catch (e: Exception) { + logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" } + throw e + } + } + + private fun consumePluginServices(result: LoadResult.Success) { + trace("start(): loadOnce success") + val apiMap = + buildMap, ZiplineService> { + put(CoreAPI::class, result.zipline.take(CoreAPI_SERVICE_NAME)) + + if (PluginAbility.METADATA in pluginInfo.abilities) { + put( + MetadataUserAPI::class, + result.zipline.take( + MetadataUserAPI_SERVICE_NAME + ) + ) + put( + MetadataTrackAPI::class, + result.zipline.take( + MetadataTrackAPI_SERVICE_NAME + ) + ) + put( + MetadataAlbumAPI::class, + result.zipline.take( + MetadataAlbumAPI_SERVICE_NAME + ) + ) + put( + MetadataArtistAPI::class, + result.zipline.take( + MetadataArtistAPI_SERVICE_NAME + ) + ) + put( + MetadataPlaylistAPI::class, + result.zipline.take( + MetadataPlaylistAPI_SERVICE_NAME + ) + ) + put( + MetadataBrowseAPI::class, + result.zipline.take( + MetadataBrowseAPI_SERVICE_NAME + ) + ) + put( + MetadataSearchAPI::class, + result.zipline.take( + MetadataSearchAPI_SERVICE_NAME + ) + ) + } + if (PluginAbility.AUDIO in pluginInfo.abilities) { + put( + AudioAPI::class, + result.zipline.take(AudioAPI_SERVICE_NAME) + ) + } + if (PluginAbility.LYRICS in pluginInfo.abilities) { + put( + LyricsAPI::class, + result.zipline.take(LyricsAPI_SERVICE_NAME) + ) + } + if (PluginAbility.SCROBBLE in pluginInfo.abilities) { + put( + ScrobbleAPI::class, + result.zipline.take(ScrobbleAPI_SERVICE_NAME) + ) + } + } + + serviceRegistry.putAll(apiMap) + trace("start(): API ready") + } + + private fun runLogInFlowObservers() = scope.launch { + val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI + coreAPI.loggedInFlow.collect { isLoggedIn -> + loggedInStateFlow.value = isLoggedIn + } + } + + override suspend fun start() { + lifecycleMutex.withLock { + trace("start(): entered") + if (serviceRegistry.isNotEmpty()) { + trace("start(): already started, skipping") + return + } + + logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" } + withContext(ziplineDispatcher.dispatcher) { + trace("start(): inside zipline dispatcher before loadOnce") + val result = ziplineLoader.loadOnce( + applicationName = applicationName, + manifestUrl = manifestUrl, + freshnessChecker = DefaultFreshnessCheckerNotFresh) + when (result) { + is LoadResult.Success -> { + logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" } + ziplineInstance = result.zipline + // Now we consume the initializer + val initializer = result.zipline.take(Initializer_SERVICE_NAME) + // Bind host services before initialization, so plugins can use them in their initializer + bindHostServices(result.zipline) + trace("start(): calling initializer.initialize()") + runCatching { initializer.initialize() } + .onSuccess { + consumePluginServices(result) + runLogInFlowObservers() + } + .onFailure { e -> + logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" } + throw e + } + } + + is LoadResult.Failure -> { + trace("start(): loadOnce failure: ${result.exception}") + logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" } + throw result.exception + } + } + } + + } + } + + override suspend fun stop() { + lifecycleMutex.withLock { + trace("stop(): entered") + withContext(ziplineDispatcher.dispatcher) { + ziplineInstance?.close() + ziplineInstance = null + for (service in serviceRegistry.values) { + try { + trace("stop(): closing service ${service::class.simpleName}") + service.close() + } catch (_: Exception) { + trace("stop(): error closing service ${service::class.simpleName}") + } + } + } + serviceRegistry.clear() + scope.cancel() + loggedInStateFlow.value = false + ziplineDispatcher.close() + trace("stop(): completed") + } + } + + override suspend fun use(block: suspend PluginServiceScope.() -> T): T { + return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) { + // Create the scope with the current registry + val scope = PluginServiceScope(serviceRegistry) + // Execute the block with 'scope' as 'this' + scope.block() + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealCryptoAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealCryptoAPI.kt new file mode 100644 index 00000000..bac3d40a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealCryptoAPI.kt @@ -0,0 +1,503 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline.host_apis + +import org.spotube.plugin_interfaces.host_apis.AESEncodingFormats +import org.spotube.plugin_interfaces.host_apis.AESKeySize +import org.spotube.plugin_interfaces.host_apis.AESKeySize.B128 +import org.spotube.plugin_interfaces.host_apis.AESKeySize.B192 +import org.spotube.plugin_interfaces.host_apis.AESKeySize.B256 +import org.spotube.plugin_interfaces.host_apis.CryptoAPI +import org.spotube.plugin_interfaces.host_apis.GenerateKeyPairAlgorithms +import org.spotube.plugin_interfaces.host_apis.ECCurves +import org.spotube.plugin_interfaces.host_apis.ECCurves.P256 +import org.spotube.plugin_interfaces.host_apis.ECCurves.P384 +import org.spotube.plugin_interfaces.host_apis.ECCurves.P521 +import org.spotube.plugin_interfaces.host_apis.ECCurves.brainpoolP256r1 +import org.spotube.plugin_interfaces.host_apis.ECCurves.brainpoolP384r1 +import org.spotube.plugin_interfaces.host_apis.ECCurves.brainpoolP512r1 +import org.spotube.plugin_interfaces.host_apis.ECCurves.secp256k1 +import org.spotube.plugin_interfaces.host_apis.ECDSASignatureFormats +import org.spotube.plugin_interfaces.host_apis.ECEncodingFormats +import org.spotube.plugin_interfaces.host_apis.EdDSACurves +import org.spotube.plugin_interfaces.host_apis.EdDSACurves.Ed25519 +import org.spotube.plugin_interfaces.host_apis.EdDSACurves.Ed448 +import org.spotube.plugin_interfaces.host_apis.EdDSAEncodingFormats +import org.spotube.plugin_interfaces.host_apis.MACKeyGeneratorAlgorithms +import org.spotube.plugin_interfaces.host_apis.HMACEncodingFormats +import org.spotube.plugin_interfaces.host_apis.HashAlgorithms +import org.spotube.plugin_interfaces.host_apis.LegacyCipherAlgorithms +import org.spotube.plugin_interfaces.host_apis.RSAEncodingFormats +import org.spotube.plugin_interfaces.host_apis.RSAEncodingFormats.JWK +import org.spotube.plugin_interfaces.host_apis.RSAEncodingFormats.PEM +import org.spotube.plugin_interfaces.host_apis.SignAlgorithms +import org.spotube.plugin_interfaces.host_apis.MACSignatureAlgorithms +import org.spotube.spotube.core.zipline.host_apis.additionals.DES +import dev.whyoleg.cryptography.BinarySize +import dev.whyoleg.cryptography.BinarySize.Companion.bits +import dev.whyoleg.cryptography.CryptographyAlgorithmId +import dev.whyoleg.cryptography.CryptographyProvider +import dev.whyoleg.cryptography.DelicateCryptographyApi +import dev.whyoleg.cryptography.algorithms.AES +import dev.whyoleg.cryptography.algorithms.Digest +import dev.whyoleg.cryptography.algorithms.EC +import dev.whyoleg.cryptography.algorithms.ECDSA +import dev.whyoleg.cryptography.algorithms.EdDSA +import dev.whyoleg.cryptography.algorithms.HMAC +import dev.whyoleg.cryptography.algorithms.MD5 +import dev.whyoleg.cryptography.algorithms.RIPEMD160 +import dev.whyoleg.cryptography.algorithms.RSA +import dev.whyoleg.cryptography.algorithms.SHA1 +import dev.whyoleg.cryptography.algorithms.SHA224 +import dev.whyoleg.cryptography.algorithms.SHA256 +import dev.whyoleg.cryptography.algorithms.SHA384 +import dev.whyoleg.cryptography.algorithms.SHA512 +import dev.whyoleg.cryptography.bigint.toBigInt +import dev.whyoleg.cryptography.random.CryptographyRandom +import kotlinx.coroutines.runBlocking +import kotlin.coroutines.CoroutineContext + +@OptIn(DelicateCryptographyApi::class) +fun HashAlgorithms.algorithm(): CryptographyAlgorithmId { + return when (this) { + HashAlgorithms.SHA224 -> SHA224 + HashAlgorithms.SHA256 -> SHA256 + HashAlgorithms.SHA384 -> SHA384 + HashAlgorithms.SHA512 -> SHA512 + HashAlgorithms.SHA1 -> SHA1 + HashAlgorithms.MD5 -> MD5 + HashAlgorithms.RIPEMD160 -> RIPEMD160 + } +} + +fun AESKeySize.size(): BinarySize { + return when (this) { + B128 -> AES.Key.Size.B128 + B192 -> AES.Key.Size.B192 + B256 -> AES.Key.Size.B256 + } +} + +fun ECCurves.curve(): EC.Curve { + return when (this) { + P256 -> EC.Curve.P256 + P384 -> EC.Curve.P384 + P521 -> EC.Curve.P521 + secp256k1 -> EC.Curve.secp256k1 + brainpoolP256r1 -> EC.Curve.brainpoolP256r1 + brainpoolP384r1 -> EC.Curve.brainpoolP384r1 + brainpoolP512r1 -> EC.Curve.brainpoolP512r1 + } +} + +fun EdDSACurves.curve(): EdDSA.Curve { + return when (this) { + Ed25519 -> EdDSA.Curve.Ed25519 + Ed448 -> EdDSA.Curve.Ed448 + } +} + +fun ECEncodingFormats.privateKeyFormat(): EC.PrivateKey.Format { + return when (this) { + ECEncodingFormats.DER -> EC.PrivateKey.Format.DER + ECEncodingFormats.RAW -> EC.PrivateKey.Format.RAW + ECEncodingFormats.PEM -> EC.PrivateKey.Format.PEM + ECEncodingFormats.JWK -> EC.PrivateKey.Format.JWK + } +} + +fun ECEncodingFormats.publicKeyFormat(): EC.PublicKey.Format { + return when (this) { + ECEncodingFormats.DER -> EC.PublicKey.Format.DER + ECEncodingFormats.RAW -> EC.PublicKey.Format.RAW + ECEncodingFormats.PEM -> EC.PublicKey.Format.PEM + ECEncodingFormats.JWK -> EC.PublicKey.Format.JWK + } +} + +fun RSAEncodingFormats.privateKeyFormat(): RSA.PrivateKey.Format { + return when (this) { + RSAEncodingFormats.DER -> RSA.PrivateKey.Format.DER + PEM -> RSA.PrivateKey.Format.PEM + JWK -> RSA.PrivateKey.Format.JWK + } +} + +fun RSAEncodingFormats.publicKeyFormat(): RSA.PublicKey.Format { + return when (this) { + RSAEncodingFormats.DER -> RSA.PublicKey.Format.DER + PEM -> RSA.PublicKey.Format.PEM + JWK -> RSA.PublicKey.Format.JWK + } +} + +fun EdDSAEncodingFormats.privateKeyFormat(): EdDSA.PrivateKey.Format { + return when (this) { + EdDSAEncodingFormats.DER -> EdDSA.PrivateKey.Format.DER + EdDSAEncodingFormats.PEM -> EdDSA.PrivateKey.Format.PEM + EdDSAEncodingFormats.JWK -> EdDSA.PrivateKey.Format.JWK + EdDSAEncodingFormats.RAW -> EdDSA.PrivateKey.Format.RAW + } +} + +fun EdDSAEncodingFormats.publicKeyFormat(): EdDSA.PublicKey.Format { + return when (this) { + EdDSAEncodingFormats.DER -> EdDSA.PublicKey.Format.DER + EdDSAEncodingFormats.PEM -> EdDSA.PublicKey.Format.PEM + EdDSAEncodingFormats.JWK -> EdDSA.PublicKey.Format.JWK + EdDSAEncodingFormats.RAW -> EdDSA.PublicKey.Format.RAW + } +} + +fun ECDSASignatureFormats.signatureFormat(): ECDSA.SignatureFormat { + return when (this) { + ECDSASignatureFormats.DER -> ECDSA.SignatureFormat.DER + ECDSASignatureFormats.RAW -> ECDSA.SignatureFormat.RAW + } +} + +fun HMACEncodingFormats.format(): HMAC.Key.Format { + return when (this) { + HMACEncodingFormats.RAW -> HMAC.Key.Format.RAW + HMACEncodingFormats.JWK -> HMAC.Key.Format.JWK + } +} + +fun AESEncodingFormats.format(): AES.Key.Format { + return when (this) { + AESEncodingFormats.RAW -> AES.Key.Format.RAW + AESEncodingFormats.JWK -> AES.Key.Format.JWK + } +} + +class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { + private val provider = CryptographyProvider.Default + + override suspend fun generateRandomBytes(size: Int): ByteArray { + return CryptographyRandom.nextBytes(size) + } + + override suspend fun hash(algorithm: HashAlgorithms, data: ByteArray): ByteArray { + val hasher = provider.get(algorithm.algorithm()).hasher() + return hasher.hash(data) + } + + override fun hashBlocking(algorithm: HashAlgorithms, data: ByteArray): ByteArray { + return runBlocking(scope) { + hash(algorithm, data) + } + } + + override suspend fun generateMACKey(algorithm: MACKeyGeneratorAlgorithms): ByteArray { + return when (algorithm) { + is MACKeyGeneratorAlgorithms.HMAC -> + provider.get(HMAC).keyGenerator(algorithm.hashAlgorithm.algorithm()).generateKey() + .encodeToByteArray(algorithm.format.format()) + + is MACKeyGeneratorAlgorithms.AES_CMAC -> provider.get(AES.CMAC) + .keyGenerator(algorithm.keySize.size()).generateKey() + .encodeToByteArray(algorithm.format.format()) + } + } + + override fun generateMACKeyBlocking(algorithm: MACKeyGeneratorAlgorithms): ByteArray { + return runBlocking(scope) { + generateMACKey(algorithm) + } + } + + override suspend fun signWithMACKey( + algorithm: MACSignatureAlgorithms, + key: ByteArray, + data: ByteArray + ): ByteArray { + return when (algorithm) { + is MACSignatureAlgorithms.HMAC -> + provider.get(HMAC).keyDecoder(algorithm.hashAlgorithm.algorithm()) + .decodeFromByteArray(algorithm.format.format(), key) + .signatureGenerator().generateSignature(data) + + is MACSignatureAlgorithms.AES_CMAC -> provider.get(AES.CMAC) + .keyDecoder() + .decodeFromByteArray(algorithm.format.format(), key) + .signatureGenerator().generateSignature(data) + } + } + + override fun signWithMACKeyBlocking( + algorithm: MACSignatureAlgorithms, + key: ByteArray, + data: ByteArray + ): ByteArray { + return runBlocking(scope) { + signWithMACKey(algorithm, key, data) + } + } + + override suspend fun verifyMACSignatureWithKey( + algorithm: MACSignatureAlgorithms, + key: ByteArray, + signature: ByteArray, + data: ByteArray + ): Boolean { + try { + when (algorithm) { + is MACSignatureAlgorithms.HMAC -> + provider.get(HMAC).keyDecoder(algorithm.hashAlgorithm.algorithm()) + .decodeFromByteArray( + algorithm.format.format(), + key + ) + .signatureVerifier().verifySignature( + data, + signature + ) + + is MACSignatureAlgorithms.AES_CMAC -> provider.get(AES.CMAC) + .keyDecoder() + .decodeFromByteArray(algorithm.format.format(), key) + .signatureVerifier().verifySignature( + data, + signature + ) + } + return true + } catch (_: Exception) { + return false + } + } + + override fun verifyMACSignatureWithKeyBlocking( + algorithm: MACSignatureAlgorithms, + key: ByteArray, + signature: ByteArray, + data: ByteArray + ): Boolean { + return runBlocking(scope) { + verifyMACSignatureWithKey(algorithm, key, signature, data) + } + } + + override suspend fun generateKeyPair(algorithm: GenerateKeyPairAlgorithms): Pair { + return when (algorithm) { + is GenerateKeyPairAlgorithms.RSA_PSS -> { + val keyPair = provider.get(RSA.PSS).keyPairGenerator( + algorithm.keySizeBits.bits, + algorithm.hashAlgorithm.algorithm(), + algorithm.publicExponent.toBigInt()).generateKey() + + Pair( + keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()), + keyPair.privateKey.encodeToByteArray(algorithm.format.privateKeyFormat()) + ) + } + + is GenerateKeyPairAlgorithms.ECDSA -> { + val keyPair = provider.get(ECDSA) + .keyPairGenerator(algorithm.curve.curve()) + .generateKey() + Pair( + keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()), + keyPair.privateKey.encodeToByteArray(algorithm.format.privateKeyFormat()) + ) + } + + is GenerateKeyPairAlgorithms.EdDSA -> { + val keyPair = provider.get(EdDSA) + .keyPairGenerator(algorithm.curve.curve()) + .generateKey() + Pair( + keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()), + keyPair.privateKey.encodeToByteArray(algorithm.format.privateKeyFormat()) + ) + } + + is GenerateKeyPairAlgorithms.RSA_PKCS1 -> { + val keyPair = provider.get(RSA.PKCS1) + .keyPairGenerator( + algorithm.keySizeBits.bits, + algorithm.hashAlgorithm.algorithm() + ) + .generateKey() + Pair( + keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()), + keyPair.privateKey.encodeToByteArray(algorithm.format.privateKeyFormat()) + ) + } + } + } + + override fun generateKeyPairBlocking(algorithm: GenerateKeyPairAlgorithms): Pair { + return runBlocking(scope) { + generateKeyPair(algorithm) + } + } + + override suspend fun signWithPrivateKey( + algorithm: SignAlgorithms, + data: ByteArray, + privateKey: ByteArray + ): ByteArray { + return when (algorithm) { + is SignAlgorithms.RSA_PSS -> + provider.get(RSA.PSS).privateKeyDecoder(algorithm.hashAlgorithm.algorithm()) + .decodeFromByteArray( + algorithm.format.privateKeyFormat(), + privateKey + ).signatureGenerator().generateSignature(data) + + is SignAlgorithms.ECDSA -> + provider.get(ECDSA).privateKeyDecoder( + algorithm.curve.curve()).decodeFromByteArray( + algorithm.format.privateKeyFormat(), + privateKey + ).signatureGenerator( + algorithm.hashAlgorithm.algorithm(), + algorithm.signatureFormat.signatureFormat() + ).generateSignature(data) + + is SignAlgorithms.EdDSA -> + provider.get(EdDSA).privateKeyDecoder( + algorithm.curve.curve()).decodeFromByteArray( + algorithm.format.privateKeyFormat(), + privateKey + ).signatureGenerator().generateSignature(data) + + is SignAlgorithms.RSA_PKCS1 -> + provider.get(RSA.PKCS1).privateKeyDecoder(algorithm.hashAlgorithm.algorithm()) + .decodeFromByteArray( + algorithm.format.privateKeyFormat(), + privateKey + ).signatureGenerator().generateSignature(data) + } + } + + override fun signWithPrivateKeyBlocking( + algorithm: SignAlgorithms, + data: ByteArray, + privateKey: ByteArray + ): ByteArray { + return runBlocking(scope) { + signWithPrivateKey(algorithm, data, privateKey) + } + } + + override suspend fun verifySignatureWithPublicKey( + algorithm: SignAlgorithms, + data: ByteArray, + signature: ByteArray, + publicKey: ByteArray + ): Boolean { + return try { + when (algorithm) { + is SignAlgorithms.RSA_PSS -> + provider.get(RSA.PSS).publicKeyDecoder(algorithm.hashAlgorithm.algorithm()) + .decodeFromByteArray( + algorithm.format.publicKeyFormat(), + publicKey + ).signatureVerifier().verifySignature( + data, + signature + ) + + is SignAlgorithms.ECDSA -> + provider.get(ECDSA).publicKeyDecoder( + algorithm.curve.curve()).decodeFromByteArray( + algorithm.format.publicKeyFormat(), + publicKey + ).signatureVerifier( + algorithm.hashAlgorithm.algorithm(), + algorithm.signatureFormat.signatureFormat() + ).verifySignature( + data, + signature + ) + + is SignAlgorithms.EdDSA -> + provider.get(EdDSA).publicKeyDecoder( + algorithm.curve.curve()).decodeFromByteArray( + algorithm.format.publicKeyFormat(), + publicKey + ).signatureVerifier().verifySignature( + data, + signature + ) + + is SignAlgorithms.RSA_PKCS1 -> + provider.get(RSA.PKCS1).publicKeyDecoder(algorithm.hashAlgorithm.algorithm()) + .decodeFromByteArray( + algorithm.format.publicKeyFormat(), + publicKey + ).signatureVerifier().verifySignature( + data, + signature + ) + } + true + } catch (_: Exception) { + false + } + } + + override fun verifySignatureWithPublicKeyBlocking( + algorithm: SignAlgorithms, + data: ByteArray, + signature: ByteArray, + publicKey: ByteArray + ): Boolean { + return runBlocking(scope) { + verifySignatureWithPublicKey(algorithm, data, signature, publicKey) + } + } + + override suspend fun encryptLegacy( + algorithm: LegacyCipherAlgorithms, + key: ByteArray, + data: ByteArray, + iv: ByteArray? + ): ByteArray { + return when (algorithm) { + is LegacyCipherAlgorithms.DES -> DES.encrypt( + data, + key, + mode = algorithm.mode, + padding = algorithm.padding + ) + +// else -> throw IllegalArgumentException("Unsupported legacy cipher algorithm: $algorithm") + } + } + + override suspend fun decryptLegacy( + algorithm: LegacyCipherAlgorithms, + key: ByteArray, + cipherText: ByteArray, + iv: ByteArray? + ): ByteArray { + return when (algorithm) { + is LegacyCipherAlgorithms.DES -> DES.decrypt( + cipherText, + key, + mode = algorithm.mode, + padding = algorithm.padding + ) +// else -> throw IllegalArgumentException("Unsupported legacy cipher algorithm: $algorithm") + } + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt new file mode 100644 index 00000000..553966b1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline.host_apis + +import org.koin.core.component.inject +import org.spotube.spotube.core.network.HttpClientFactory +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI +import org.spotube.plugin_interfaces.host_apis.HttpMethod +import org.spotube.plugin_interfaces.host_apis.HttpResponse +import org.spotube.spotube.core.di.injectLogger +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.takeFrom +import org.koin.core.component.KoinComponent + +class RealHttpClientAPI : HttpClientAPI, KoinComponent { + val logger by injectLogger() + private val httpClientFactory: HttpClientFactory by inject() + private val httpClient get() = httpClientFactory.default + + override suspend fun request( + method: HttpMethod, + url: String, + requestHeaders: Map?, + body: String? + ): HttpResponse { + logger.i { + buildString { + append("[${method.name.uppercase()}] $url\n") + append("Headers:\n") + requestHeaders?.forEach { (key, value) -> + append(" $key: $value\n") + } + body?.let { + append("Body: $it\n") + } + } + } + val res = httpClient.request { + this.method = when (method) { + HttpMethod.Get -> io.ktor.http.HttpMethod.Get + HttpMethod.Post -> io.ktor.http.HttpMethod.Post + HttpMethod.Put -> io.ktor.http.HttpMethod.Put + HttpMethod.Delete -> io.ktor.http.HttpMethod.Delete + HttpMethod.Patch -> io.ktor.http.HttpMethod.Patch + HttpMethod.Head -> io.ktor.http.HttpMethod.Head + HttpMethod.Options -> io.ktor.http.HttpMethod.Options + } + this.url { + takeFrom(url) + } + requestHeaders?.forEach { (key, value) -> + headers.append(key, value) + } + body?.let { setBody(it) } + } + + return HttpResponse( + statusCode = res.status.value, + headers = res.headers.entries().associate { it.key to it.value.joinToString(",") }, + body = res.bodyAsText() + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt new file mode 100644 index 00000000..72de1bc2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline.host_apis + +import androidx.datastore.preferences.core.stringPreferencesKey +import org.spotube.plugin_interfaces.host_apis.PersistedStorageAPI +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.modules.plugin.PluginEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class RealPersistedStorageAPI( + private val pluginInfo: PluginEntry +) : PersistedStorageAPI, KoinComponent { + val database: Database by inject() + + override suspend fun putString(key: String, value: String) { + return withContext(Dispatchers.IO) { + database.pluginsDataStore.updateData { prefs -> + prefs.toMutablePreferences().apply { + this[stringPreferencesKey("${pluginInfo.id}:$key")] = value + } + } + } + } + + override suspend fun getString(key: String): String? { + return withContext(Dispatchers.IO) { + val prefs = database.pluginsDataStore.data.first() + prefs[stringPreferencesKey("${pluginInfo.id}:$key")] + } + } + + override suspend fun remove(key: String) { + return withContext(Dispatchers.IO) { + database.pluginsDataStore.updateData { prefs -> + prefs.toMutablePreferences().apply { + remove(stringPreferencesKey("${pluginInfo.id}:$key")) + } + } + } + } + + override suspend fun getKeys(): List { + return withContext(Dispatchers.IO) { + val prefs = database.pluginsDataStore.data.first() + prefs.asMap().keys.mapNotNull { prefKey -> + val keyString = prefKey.name + if (keyString.startsWith("${pluginInfo.id}:")) { + keyString.removePrefix("${pluginInfo.id}:") + } else null + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt new file mode 100644 index 00000000..bd5c488f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline.host_apis + +import org.spotube.plugin_interfaces.host_apis.SystemInformationAPI +import org.spotube.spotube.getPlatform +import kotlinx.datetime.TimeZone + +class RealSystemInformationAPI: SystemInformationAPI { + override fun getTimeZone(): String { + val tz = TimeZone.currentSystemDefault() + return tz.id + } + + override fun getLocale(): String { + return "en-US" // TODO: Implement locale retrieval + } + + override fun getOperatingSystem(): String { + val platform = getPlatform() + return platform.name + } + + override fun getAppVersion(): String { + return "1.0.0" + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealWebViewAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealWebViewAPI.kt new file mode 100644 index 00000000..cbb4e1e3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealWebViewAPI.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline.host_apis + +import org.spotube.plugin_interfaces.host_apis.Cookie +import org.spotube.plugin_interfaces.host_apis.WebViewAPI +import org.spotube.spotube.core.webview.WebViewController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RealWebViewAPI( + private val scope: CoroutineScope, + private val webViewController: WebViewController, + private val pluginId: String) : WebViewAPI { + override fun navigateTo(url: String) { + scope.launch(Dispatchers.Main) { + webViewController.navigateTo(url, pluginId) + } + } + + override fun navigateToHTML(html: String) { + scope.launch { + webViewController.navigateToHTML(html, pluginId) + } + } + + override suspend fun getCookies(url: String): List { + return withContext(Dispatchers.Main) { + webViewController.getCookies(url) + } + } + + override suspend fun evaluateJavaScript(script: String): String? { + return withContext(Dispatchers.Main) { + webViewController.evaluateJavascript(script) + } + } + + override fun urlChangeFlow(): Flow { + return webViewController.urlChangedFlow + } + override fun webviewCreatedFlow(): Flow { + return webViewController.webviewCreatedFlow + } + override fun postMessagesFlow(): Flow { + return webViewController.postMessagesFlow + } + + override fun exitWebView() { + scope.launch(Dispatchers.Main) { + webViewController.closeWebview() + } + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DES.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DES.kt new file mode 100644 index 00000000..e64edb94 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DES.kt @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline.host_apis.additionals + +import org.spotube.plugin_interfaces.host_apis.PaddingTypes +import org.spotube.plugin_interfaces.host_apis.SymmetricModes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object DES { + + // --- 1. Tables & Matrices (Keep these identical to the previous implementation) --- + private val IP = intArrayOf(58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, 62, 54, 46, 38, 30, 22, 14, 6, 64, 56, 48, 40, 32, 24, 16, 8, 57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3, 61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7) + private val FP = intArrayOf(40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31, 38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29, 36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27, 34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25) + private val PC1 = intArrayOf(57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60, 52, 44, 36, 63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 28, 20, 12, 4) + private val PC2 = intArrayOf(14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10, 23, 19, 12, 4, 26, 8, 16, 7, 27, 20, 13, 2, 41, 52, 31, 37, 47, 55, 30, 40, 51, 45, 33, 48, 44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32) + private val SHIFTS = intArrayOf(1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1) + private val E = intArrayOf(32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13, 12, 13, 14, 15, 16, 17, 16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25, 24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1) + private val P = intArrayOf(16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10, 2, 8, 24, 14, 32, 27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25) + private val S_BOXES = arrayOf( + intArrayOf(14,4,13,1,2,15,11,8,3,10,6,12,5,9,0,7, 0,15,7,4,14,2,13,1,10,6,12,11,9,5,3,8, 4,1,14,8,13,6,2,11,15,12,9,7,3,10,5,0, 15,12,8,2,4,9,1,7,5,11,3,14,10,0,6,13), + intArrayOf(15,1,8,14,6,11,3,4,9,7,2,13,12,0,5,10, 3,13,4,7,15,2,8,14,12,0,1,10,6,9,11,5, 0,14,7,11,10,4,13,1,5,8,12,6,9,3,2,15, 13,8,10,1,3,15,4,2,11,6,7,12,0,5,14,9), + intArrayOf(10,0,9,14,6,3,15,5,1,13,12,7,11,4,2,8, 13,7,0,9,3,4,6,10,2,8,5,14,12,11,15,1, 13,6,4,9,8,15,3,0,11,1,2,12,5,10,14,7, 1,10,13,0,6,9,8,7,4,15,14,3,11,5,2,12), + intArrayOf(7,13,14,3,0,6,9,10,1,2,8,5,11,12,4,15, 13,8,11,5,6,15,0,3,4,7,2,12,1,10,14,9, 10,6,9,0,12,11,7,13,15,1,3,14,5,2,8,4, 3,15,0,6,10,1,13,8,9,4,5,11,12,7,2,14), + intArrayOf(2,12,4,1,7,10,11,6,8,5,3,15,13,0,14,9, 14,11,2,12,4,7,13,1,5,0,15,10,3,9,8,6, 4,2,1,11,10,13,7,8,15,9,12,5,6,3,0,14, 11,8,12,7,1,14,2,13,6,15,0,9,10,4,5,3), + intArrayOf(12,1,10,15,9,2,6,8,0,13,3,4,14,7,5,11, 10,15,4,2,7,12,9,5,6,1,13,14,0,11,3,8, 9,14,15,5,2,8,12,3,7,0,4,10,1,13,11,6, 4,3,2,12,9,5,15,10,11,14,1,7,6,0,8,13), + intArrayOf(4,11,2,14,15,0,8,13,3,12,9,7,5,10,6,1, 13,0,11,7,4,9,1,10,14,3,5,12,2,15,8,6, 1,4,11,13,12,3,7,14,10,15,6,8,0,5,9,2, 6,11,13,8,1,4,10,7,9,5,0,15,14,2,3,12), + intArrayOf(13,2,8,4,6,15,11,1,10,9,3,14,5,0,12,7, 1,15,13,8,10,3,7,4,12,5,6,11,0,14,9,2, 7,11,4,1,9,12,14,2,0,6,10,13,15,3,5,8, 2,1,14,7,4,10,8,13,15,12,9,0,3,5,6,11) + ) + + // --- 2. Bitwise Mechanics Engine --- + private fun permute(input: Long, table: IntArray, inputLen: Int): Long { + var output = 0L + for (i in table.indices) { + val bitPos = inputLen - table[i] + val bit = (input shr bitPos) and 1L + output = (output shl 1) or bit + } + return output + } + + private fun generateSubkeys(key64: Long): LongArray { + val subkeys = LongArray(16) + val permutedKey = permute(key64, PC1, 64) + var c = (permutedKey shr 28) and 0x0FFFFFFFUL.toLong() + var d = permutedKey and 0x0FFFFFFFUL.toLong() + + for (i in 0 until 16) { + val shift = SHIFTS[i] + c = ((c shl shift) or (c shr (28 - shift))) and 0x0FFFFFFFUL.toLong() + d = ((d shl shift) or (d shr (28 - shift))) and 0x0FFFFFFFUL.toLong() + val combined = (c shl 28) or d + subkeys[i] = permute(combined, PC2, 56) + } + return subkeys + } + + private fun feistel(right32: Long, subkey48: Long): Long { + val expanded = permute(right32, E, 32) + val xored = expanded xor subkey48 + var sBoxOutput = 0L + + for (i in 0 until 8) { + val chunk = (xored shr (42 - i * 6)) and 0x3F + val row = (((chunk shr 5) and 1) shl 1) or (chunk and 1) + val col = (chunk shr 1) and 0x0F + val sValue = S_BOXES[i][(row.toInt() shl 4) or col.toInt()] + sBoxOutput = (sBoxOutput shl 4) or sValue.toLong() + } + return permute(sBoxOutput, P, 32) + } + + private fun processBlock(block64: Long, subkeys: LongArray, encrypt: Boolean): Long { + val permutedBlock = permute(block64, IP, 64) + var left = (permutedBlock shr 32) and 0xFFFFFFFFUL.toLong() + var right = permutedBlock and 0xFFFFFFFFUL.toLong() + + for (i in 0 until 16) { + val roundKey = if (encrypt) subkeys[i] else subkeys[15 - i] + val nextLeft = right + val nextRight = left xor feistel(right, roundKey) + left = nextLeft + right = nextRight + } + + val preOutput = (right shl 32) or left + return permute(preOutput, FP, 64) + } + + private fun bytesToLong(bytes: ByteArray, offset: Int): Long { + var value = 0L + for (i in 0 until 8) { + value = (value shl 8) or (bytes[offset + i].toLong() and 0xFFL) + } + return value + } + + private fun longToBytes(value: Long, out: ByteArray, offset: Int) { + for (i in 7 downTo 0) { + out[offset + i] = (value shr (8 * (7 - i))).toByte() + } + } + + // --- 3. Public Configurable Interface APIs --- + + /** + * Configuration parameters: + * mode: Currently handles DESMode.ECB + * padding: Can be PaddingTypes.NONE or PaddingTypes.PKCS7 + */ + suspend fun encrypt( + data: ByteArray, + key: ByteArray, + mode: SymmetricModes = SymmetricModes.ECB, + padding: PaddingTypes = PaddingTypes.PKCS7 + ): ByteArray = withContext(Dispatchers.Default) { + require(key.size == 8) { "DES key must be exactly 8 bytes (64 bits)" } + require(mode == SymmetricModes.ECB) { "Only ECB mode is currently supported natively" } + + // 1. Process Padding Types Strategy + val workingBuffer = when (padding) { + PaddingTypes.NONE -> { + require(data.size % 8 == 0) { "Data size must be a multiple of 8 when using PaddingTypes.NONE" } + data + } + PaddingTypes.PKCS7 -> { + val paddingLen = 8 - (data.size % 8) + val padded = ByteArray(data.size + paddingLen) + data.copyInto(padded, destinationOffset = 0, startIndex = 0, endIndex = data.size) + for (i in data.size until padded.size) { + padded[i] = paddingLen.toByte() + } + padded + } + } + + val key64 = bytesToLong(key, 0) + val subkeys = generateSubkeys(key64) + val output = ByteArray(workingBuffer.size) + + // 2. Loop blocks independently (ECB Specification mechanics) + for (i in workingBuffer.indices step 8) { + val block = bytesToLong(workingBuffer, i) + val cipherBlock = processBlock(block, subkeys, encrypt = true) + longToBytes(cipherBlock, output, i) + } + output + } + + suspend fun decrypt( + cipherText: ByteArray, + key: ByteArray, + mode: SymmetricModes = SymmetricModes.ECB, + padding: PaddingTypes = PaddingTypes.PKCS7 + ): ByteArray = withContext(Dispatchers.Default) { + require(key.size == 8) { "DES key must be exactly 8 bytes" } + require(cipherText.size % 8 == 0) { "Cipher text block array size must be a multiple of 8" } + require(mode == SymmetricModes.ECB) { "Only ECB mode is supported" } + + val key64 = bytesToLong(key, 0) + val subkeys = generateSubkeys(key64) + val decryptedBuffer = ByteArray(cipherText.size) + + for (i in cipherText.indices step 8) { + val block = bytesToLong(cipherText, i) + val plainBlock = processBlock(block, subkeys, encrypt = false) + longToBytes(plainBlock, decryptedBuffer, i) + } + + // 3. Process Padding Removal Strategy + when (padding) { + PaddingTypes.NONE -> decryptedBuffer + PaddingTypes.PKCS7 -> { + val paddingLen = decryptedBuffer.last().toInt() and 0xFF + require(paddingLen in 1..8) { "Invalid PKCS7 padding formatting encountered" } + + // Ensure padding values are mathematically consistent + for (i in (decryptedBuffer.size - paddingLen) until decryptedBuffer.size) { + require(decryptedBuffer[i].toInt() == paddingLen) { "Corrupted PKCS7 padding byte structural layout" } + } + + val outputLen = decryptedBuffer.size - paddingLen + val output = ByteArray(outputLen) + decryptedBuffer.copyInto(output, destinationOffset = 0, startIndex = 0, endIndex = outputLen) + output + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt new file mode 100644 index 00000000..fccbd324 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline.plugin_apis.common + +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.core.PluginUpdateInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import net.swiftzer.semver.SemVer + +class RealCoreAPI : CoreAPI { + override suspend fun checkPluginUpdates(currentVersion: SemVer): PluginUpdateInfo? { + return null + } + + override fun supportMarkdownText(currentVersion: SemVer): String { + return "Keep supporting Spotube!" + } + + override val requiresAuthentication = false + + private val loggedInState = MutableStateFlow(false) + override val loggedInFlow = loggedInState.asStateFlow() + + override suspend fun login() { + // No-op + } + + override suspend fun logout() { + // No-op + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt new file mode 100644 index 00000000..59a931b8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline.plugin_apis.lrclib + +import org.koin.core.component.inject +import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.network.HttpJson +import org.spotube.spotube.core.network.HttpClientFactory +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsResponse +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import io.ktor.client.call.body +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.logging.SIMPLE +import io.ktor.client.request.get +import io.ktor.http.userAgent +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable + +@Serializable +data class LRCLibResponse( + val id: Long, + val trackName: String, + val artistName: String, + val albumName: String, + val plainLyrics: String?, + val syncedLyrics: String?) + +class RealLRCLibLyricsAPI : LyricsAPI, KoinComponent { + private val httpClientFactory: HttpClientFactory by inject() + private val httpClient by lazy { + httpClientFactory.create { + install(Logging) { + level = LogLevel.ALL + logger = Logger.SIMPLE + } + install(ContentNegotiation) { + json(HttpJson) + } + defaultRequest { + url("https://lrclib.net/api/get") + // TODO: Add the actual version of Spotube instead of hardcoding it + userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin") + } + } + } + + private suspend fun getLRCLibLyrics(track: MetadataTrack): LRCLibResponse? = + withContext(Dispatchers.IO) { + try { + httpClient.get { + url { + parameters.append("track_name", track.title) + track.artists.firstOrNull() + ?.let { parameters.append("artist_name", it.name) } + track.album?.let { parameters.append("album_name", it.title) } + if (track.durationMs > 0) parameters.append( + "duration", + (track.durationMs / 1000).toString() + ) // Convert ms to seconds + } + }.body() + } catch (e: Exception) { + null + } + } + + override suspend fun getLyrics(track: MetadataTrack): LyricsResponse? { + val response = getLRCLibLyrics(track) ?: return null + return LyricsResponse( + syncedLyrics = response.syncedLyrics?.lines()?.mapNotNull { line -> + val match = Regex("\\[(\\d{2}):(\\d{2}\\.\\d{2})]").find(line) ?: return@mapNotNull null + val minutes = match.groupValues[1].toLongOrNull() ?: return@mapNotNull null + val seconds = match.groupValues[2].toDoubleOrNull() ?: return@mapNotNull null + val timeMs = (minutes * 60 * 1000 + (seconds * 1000)).toLong() + val text = line.substring(match.range.last + 1).trim() + LyricsLine(time = timeMs, text = text) + }, + plainLyrics = response.plainLyrics) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt new file mode 100644 index 00000000..c9c95cca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class IFPISessionResponse( + val token: String, + val response: String +) + +/** + * { + * "searchFields": { + * "recordingArtistName": { + * "value": "Justin Bieber" + * }, + * "recordingTitle": { + * "value": "Peaches" + * }, + * "releaseName": { + * "value": "Justice" + * } + * }, + * "start": 0, + * "number": 10, + * "showReleases": false + * } + */ +@Serializable +data class IFPIRecordingRequestInput( + val searchFields: SearchFields, + val start: Int = 0, + val number: Int = 10, + val showReleases: Boolean = false +) { + @Serializable + data class SearchFields( + val recordingArtistName: FieldValue? = null, + val recordingTitle: FieldValue? = null, + val releaseName: FieldValue? = null + ) { + @Serializable + data class FieldValue(val value: String) + } +} + +/** + * { + * "numberOfRecordings": 1, + * "show_releases": false, + * "recordings": [ + * { + * "duration": "3:18", + * "recordingVersion": null, + * "isValidIsrc": "True", + * "recordingYear": "2021", + * "recordingArtistName": "Daniel Caesar ♦ Giveon ♦ Justin Bieber", + * "isrcFailureCode": null, + * "isExplicit": "False", + * "isrc": "USUM72102647", + * "recordingTitle": "Peaches", + * "id": "USUM72102647" + * } + * ] + * } + */ +@Serializable +data class IFPIRecordingResponse( + val numberOfRecordings: Int, + @SerialName("show_releases") + val showReleases: Boolean, + val recordings: List +) { + @Serializable + data class Recording( + val duration: String?, + val recordingVersion: String?, + val isValidIsrc: String?, + val recordingYear: String?, + val recordingArtistName: String?, + val isrcFailureCode: String?, + val isExplicit: String?, + val isrc: String, + val recordingTitle: String?, + val id: String + ) +} + +@Serializable +data class MusicGatewayResponse( + val result: Result +) { + @Serializable + data class Result( + val tracks: Tracks + ) { + @Serializable + data class Tracks( + val items: List + ) { + @Serializable + data class Item( + val album: Album, + val artists: List, + @SerialName("external_ids") + val externalIds: ExternalIds, + val id: String, + val name: String, + val popularity: Int) { + @Serializable + data class Album( + @SerialName("album_type") + val albumType: String, + val artists: List, + val id: String, + val name: String) + + @Serializable + data class Artist( + val id: String, + val name: String, + val type: String, + val uri: String + ) + + @Serializable + data class ExternalIds( + val isrc: String + ) + } + } + } +} + +@Serializable +data class SoundplateResponse( + val name: String, + val artist: String, + val album: String, + @SerialName("album_type") + val albumType: String, + @SerialName("artwork_url") + val artworkUrl: String, + val isrc: String, + val year: String, + @SerialName("spotify_url") + val spotifyUrl: String +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt new file mode 100644 index 00000000..52b2eaa7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt + +import org.koin.core.component.inject +import org.spotube.spotube.core.network.HttpClientFactory +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.tools.user_agents.UserAgents +import io.ktor.client.call.body +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import org.spotube.spotube.core.di.injectLogger +import org.koin.core.component.KoinComponent +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.http.headers +import io.ktor.serialization.kotlinx.json.json +import io.ktor.util.appendAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json + +class ISRCProviders : KoinComponent { + private val logger by injectLogger() + private val lenientJson = Json { isLenient = true; ignoreUnknownKeys = true } + + companion object { + val greedyGreenCorp = + listOf(-0x03, -0x01, 0x05, -0x0B, -0x03, 0x13) + .scan(0x64 + 0x0F) { acc, step -> acc + step } + .map { it.toChar() } + .joinToString("") + .lowercase() + } + + private val httpClientFactory: HttpClientFactory by inject() + private val client get() = httpClientFactory.json + + private fun MetadataTrack.describe() = + "id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " + + "durationMs=$durationMs isrc=$isrcCode externalUri=$externalUri" + + /** Logs the full raw body (so parse failures are diagnosable) then decodes it. */ + private suspend inline fun HttpResponse.decodeLogged(provider: String): T { + val text = bodyAsText() + logger.i { "[ISRC:$provider] status=${status.value} rawBody=$text" } + return lenientJson.decodeFromString(text) + } + + private var ifpiSessionToken: String? = null + private val ifpiDefaultHeaders = mapOf( + "Content-Type" to "application/json", + "Accept" to "application/json", + "Origin" to "https://isrcsearch.ifpi.org/", + "Referer" to "https://isrcsearch.ifpi.org/", + "User-Agent" to UserAgents.random() + ) + private val soundplateDefaultHeaders = mapOf( + "Content-Type" to "application/json", + "Accept" to "application/json", + "Origin" to "https://soundplate.com/", + "Referer" to "https://phpstack-822472-6184058.cloudwaysapps.com/?", + "sec-fetch-mode" to "cors", + "sec-fetch-site" to "same-origin", + "User-Agent" to UserAgents.random() + ) + + private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) { + if (ifpiSessionToken != null) return@withContext ifpiSessionToken + + logger.i { "[ISRC:ifpi] requesting session token" } + val res = client.get("https://isrc-api.soundexchange.com/api/ext/login") { + headers.appendAll(ifpiDefaultHeaders) + } + + if (res.status.value != 200) { + logger.w { "[ISRC:ifpi] session login failed status=${res.status.value} body=${res.bodyAsText()}" } + return@withContext null + } + + val body = res.decodeLogged("ifpi-login") + ifpiSessionToken = body.token + body.token + } + + suspend fun ifpi(track: MetadataTrack): String? = withContext(Dispatchers.IO) { + logger.i { "[ISRC:ifpi] input: ${track.describe()}" } + val sessonToken = ifpiSessionToken() ?: return@withContext null + val input = IFPIRecordingRequestInput( + searchFields = IFPIRecordingRequestInput.SearchFields( + recordingArtistName = track.artists.firstOrNull()?.name?.let { + IFPIRecordingRequestInput.SearchFields.FieldValue( + it + ) + }, + recordingTitle = track.title.let { + IFPIRecordingRequestInput.SearchFields.FieldValue( + it + ) + }, + releaseName = track.album?.title?.let { + IFPIRecordingRequestInput.SearchFields.FieldValue( + it + ) + } + ) + ) + + logger.i { "[ISRC:ifpi] request searchFields=${input.searchFields}" } + val res = client.post("https://isrc-api.soundexchange.com/api/ext/recordings") { + contentType(ContentType.Application.Json) + headers { + appendAll(ifpiDefaultHeaders) + append("Authorization", "Token $sessonToken") + } + setBody(input) + } + + if (res.status.value != 200) { + logger.w { "[ISRC:ifpi] search failed status=${res.status.value} body=${res.bodyAsText()}" } + return@withContext null + } + + val body = res.decodeLogged("ifpi") + if (body.recordings.isEmpty()) { + logger.i { "[ISRC:ifpi] no recordings returned" } + return@withContext null + } + body.recordings.forEachIndexed { i, r -> + logger.i { + "[ISRC:ifpi] candidate #$i isrc=${r.isrc} title='${r.recordingTitle}' " + + "artist='${r.recordingArtistName}' duration=${r.duration} year=${r.recordingYear} " + + "version=${r.recordingVersion} valid=${r.isValidIsrc}" + } + } + logger.i { "[ISRC:ifpi] picking FIRST candidate isrc=${body.recordings.first().isrc} (no artist/title validation)" } + body.recordings.first().isrc + } + + suspend fun musicGateway(track: MetadataTrack): String? = withContext(Dispatchers.IO) { + logger.i { "[ISRC:musicGateway] input: ${track.describe()}" } + val res = client.get("https://www.musicgateway.com/isrc-finder") { + parameter("search", "${track.title} ${track.artists.joinToString(", ") { it.name }}") + parameter("type", "name") + parameter("page", "1") + } + + if (res.status != HttpStatusCode.OK) { + logger.w { "[ISRC:musicGateway] failed status=${res.status.value} body=${res.bodyAsText()}" } + return@withContext null + } + + val body = res.decodeLogged("musicGateway") + val results = body.result.tracks.items + + if (results.isEmpty()) { + logger.i { "[ISRC:musicGateway] no results" } + return@withContext null + } + + var isrcCode: String? = null + + for ((_, artists, externalIds, id, name) in results) { + val isExactTitle = name.equals(track.title, ignoreCase = true) + val isArtistMatch = artists.any { + it.name.equals( + track.artists.firstOrNull()?.name ?: "", + ignoreCase = true + ) + } + val isSameId = id == track.id + logger.i { + "[ISRC:musicGateway] candidate id=$id name='$name' artists=${artists.map { it.name }} " + + "isrc=${externalIds.isrc} exactTitle=$isExactTitle artistMatch=$isArtistMatch sameId=$isSameId" + } + if (isSameId || (isExactTitle && isArtistMatch)) { + isrcCode = externalIds.isrc + break + } + } + + logger.i { "[ISRC:musicGateway] result isrc=$isrcCode" } + isrcCode + } + + suspend fun soundplate(track: MetadataTrack): String? = withContext(Dispatchers.IO) { + val isGreedyGreenCorp = track.externalUri?.contains(greedyGreenCorp) ?: false + val uri = "https://open.$greedyGreenCorp.com/track/${track.id}" + logger.i { "[ISRC:soundplate] input: ${track.describe()} useUri=$isGreedyGreenCorp" } + val res = + client.get("https://phpstack-822472-6184058.cloudwaysapps.com/api/$greedyGreenCorp.php") { + if (isGreedyGreenCorp) { + parameter("q", uri) + } else { + parameter("q", "${track.artists.firstOrNull()?.name ?: ""} - ${track.title}") + } + headers.appendAll(soundplateDefaultHeaders) + } + + if (res.status.value != 200) { + logger.w { "[ISRC:soundplate] failed status=${res.status.value} body=${res.bodyAsText()}" } + return@withContext null + } + + val body = res.decodeLogged("soundplate") + logger.i { + "[ISRC:soundplate] returned isrc=${body.isrc} name='${body.name}' artist='${body.artist}' " + + "album='${body.album}' year=${body.year} (input title='${track.title}' " + + "artist='${track.artists.firstOrNull()?.name}') — not validated against input" + } + body.isrc + } + + suspend fun auto(track: MetadataTrack): String? { + logger.i { "[ISRC:auto] start: ${track.describe()}" } + val providers = listOf String?>>( + "soundplate" to ::soundplate, + "ifpi" to ::ifpi, + "musicGateway" to ::musicGateway) + for ((name, provider) in providers) { + val result = runCatching { provider(track) } + result.exceptionOrNull()?.let { + if (it is kotlinx.coroutines.CancellationException) throw it + logger.w(it) { "[ISRC:auto] provider $name threw" } + } + val isrc = result.getOrNull() + logger.i { "[ISRC:auto] provider $name -> $isrc" } + if (isrc != null) { + logger.i { "[ISRC:auto] using isrc=$isrc from $name" } + return isrc + } + } + logger.w { "[ISRC:auto] no provider produced an ISRC" } + return null + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt new file mode 100644 index 00000000..46174a37 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt @@ -0,0 +1,265 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.newpipe.NewPipeService +import org.spotube.spotube.core.newpipe.SearchKind +import org.spotube.spotube.core.newpipe.VideoSearchResult +import org.koin.core.component.KoinComponent + +class RealNewPipeAudioAPI : AudioAPI, KoinComponent { + private val logger by injectLogger() + private val newPipeService = NewPipeService() + + companion object { + private val youtubeIDRegex = Regex("^[a-zA-Z0-9_-]{11}$") + private val isrcRegex = Regex("^[A-Z]{2}[A-Z0-9]{3}\\d{7}$") + private const val minConfidence = 0.3f + private const val confidentMatch = 0.6f + } + + private val isrcProvider = ISRCProviders() + + private fun normalize(value: String): String = + value.lowercase() + .replace(Regex("[^\\p{L}\\p{N}]+"), " ") + .trim() + + /** Drops "(feat. X)", "[Remastered]" style suffixes so the core title can be matched. */ + private fun coreTitle(title: String): String = + normalize(title.replace(Regex("[(\\[].*?[)\\]]"), " ")) + + private fun transformVideo(track: MetadataTrack, video: VideoSearchResult): AudioSource.Basic { + var confidence = 0f + + val videoTitle = normalize(video.title) + val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true) + val uploader = normalize(video.uploader.replace(Regex("(?i)\\s*-\\s*topic$"), "")) + val artistNames = track.artists.map { normalize(it.name) }.filter { it.isNotEmpty() } + val title = coreTitle(track.title) + + val videoTokens = videoTitle.split(' ').toSet() + val isTitleMatch = title.isNotEmpty() && + (title in videoTitle || title.split(' ').all { it in videoTokens }) + val isArtistInTitle = artistNames.any { it in videoTitle } + val isAlbumInTitle = track.album?.title?.let { normalize(it) } + ?.takeIf { it.isNotEmpty() }?.let { it in videoTitle } ?: false + val isUploaderMatch = uploader.isNotEmpty() && artistNames.any { it in uploader || uploader in it } + // Unknown duration (0) must neither reward nor punish + val durationDiff = if (video.durationMs > 0) kotlin.math.abs(video.durationMs - track.durationMs) else Long.MAX_VALUE + + if (isTitleMatch) confidence += 0.3f + if (isUploaderMatch) confidence += 0.3f + if (durationDiff <= 10_000) confidence += 0.2f + else if (durationDiff <= 30_000) confidence += 0.1f + if (isArtistInTitle) confidence += 0.1f + if (isAlbumInTitle) confidence += 0.1f + // Auto-generated "Artist - Topic" uploads are the studio audio, preferred over videos + if (isTopicChannel && isUploaderMatch) confidence += 0.1f + confidence = confidence.coerceAtMost(1f) + + logger.i { + "[Match] video id=${video.id} title='${video.title}' uploader='${video.uploader}' " + + "durationMs=${video.durationMs} url=${video.url}\n" + + " vs track title='${track.title}' artists=${track.artists.map { it.name }} " + + "album='${track.album?.title}' durationMs=${track.durationMs}\n" + + " normalized: videoTitle='$videoTitle' uploader='$uploader' coreTitle='$title' artists=$artistNames\n" + + " titleMatch=$isTitleMatch uploaderMatch=$isUploaderMatch topic=$isTopicChannel " + + "artistInTitle=$isArtistInTitle albumInTitle=$isAlbumInTitle durationDiffMs=$durationDiff " + + "=> confidence=$confidence" + } + + return AudioSource.Basic( + id = video.id, + title = video.title, + artist = video.uploader, + album = null, + thumbnails = listOf( + Thumbnail( + url = video.thumbnailUrl, + width = 300, + height = 300) + ), + externalUri = "https://www.youtube.com/watch?v=${video.id}", + confidence = confidence) + } + + override val supportedQualities: List = listOf( + AudioFormat( + codec = "opus", container = "webm", qualities = listOf( + AudioQuality.Lossy(bitrate = 44_000), + AudioQuality.Lossy(bitrate = 96_000), + AudioQuality.Lossy(bitrate = 128_000), + AudioQuality.Lossy(bitrate = 256_000)) + ), AudioFormat( + codec = "aac", container = "mp4", qualities = listOf( + AudioQuality.Lossy(bitrate = 44_000), + AudioQuality.Lossy(bitrate = 96_000), + AudioQuality.Lossy(bitrate = 128_000), + AudioQuality.Lossy(bitrate = 256_000)) + ) + ) + + override suspend fun getStreamsByTrack(track: MetadataTrack): List { + val isYouTubeUrl = + track.externalUri != null && (track.externalUri!!.contains("youtube.com") || track.externalUri!!.contains( + "youtu.be" + )) + val isYouTubeID = track.id.matches(youtubeIDRegex) + + logger.i { + "Searching streams for track(${track.id}): ${track.title} by ${ + track.artists.joinToString( + ", " + ) { it.name } + }" + } + if (isYouTubeID || isYouTubeUrl) { + logger.i { "Track has YouTube ID or URL, fetching video info directly" } + return newPipeService.getVideoInfo(track.id).let { videoInfo -> + listOf( + AudioSource.Streamed( + id = videoInfo.id, + title = videoInfo.title, + artist = videoInfo.uploader, + album = null, + thumbnails = listOf( + Thumbnail( + url = videoInfo.thumbnailUrl, + width = 300, + height = 300) + ), + externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}", + confidence = 1.0f, + streams = videoInfo.audioStreams) + ) + } + } + + logger.i { "No YouTube ID or URL found, performing search with track metadata" } + + logger.i { + "[Search] input track id=${track.id} title='${track.title}' artists=${track.artists.map { it.name }} " + + "album='${track.album?.title}' durationMs=${track.durationMs} trackIsrc=${track.isrcCode} " + + "externalUri=${track.externalUri}" + } + val rawIsrc = track.isrcCode ?: isrcProvider.auto(track) + val isrcCode = rawIsrc + ?.trim()?.uppercase()?.replace("-", "") + ?.takeIf { isrcRegex.matches(it) } + logger.i { + "[Search] isrc raw=$rawIsrc (from ${if (track.isrcCode != null) "track" else "providers"}) " + + "validated=$isrcCode" + } + + val artistText = track.artists.joinToString(" ") { it.name }.trim() + val firstArtist = track.artists.firstOrNull()?.name?.trim().orEmpty() + val plainTitle = track.title.replace(Regex("[(\\[].*?[)\\]]"), " ").replace(Regex("\\s+"), " ").trim() + + // Ordered from most to least trusted. The ISRC is only a hint: a text search for it on + // YouTube Music often returns unrelated songs, so every step is scored against the track + // and the next step runs until one result clears [confidentMatch]. + val plan = buildList { + if (isrcCode != null) add("isrc" to (isrcCode to SearchKind.Songs)) + add("songs: title+artist" to ("${track.title} $artistText".trim() to SearchKind.Songs)) + if (plainTitle.isNotEmpty() && firstArtist.isNotEmpty() && plainTitle != track.title) { + add("songs: plain title+first artist" to ("$plainTitle $firstArtist" to SearchKind.Songs)) + } + add("videos: title+artist" to ("${track.title} $artistText".trim() to SearchKind.Videos)) + add("songs: title only" to (plainTitle.ifEmpty { track.title } to SearchKind.Songs)) + } + + val sources = linkedMapOf() + val searched = mutableSetOf>() + // Results of the text searches in YouTube's relevance order, used if nothing scores well + val textSearchResults = linkedMapOf() + for ((stage, request) in plan) { + if (sources.values.any { it.confidence >= confidentMatch }) break + if (!searched.add(request)) continue + val (query, kind) = request + val raw = runCatching { newPipeService.searchVideos(query, kind) } + .onFailure { + if (it is kotlinx.coroutines.CancellationException) throw it + logger.w(it) { "[Search] stage '$stage' failed for query '$query' ($kind)" } + }.getOrDefault(emptyList()) + logger.i { "[Search] stage '$stage' query='$query' kind=$kind -> ${raw.size} results: $raw" } + val scored = raw.map { transformVideo(track, it) } + logger.i { + "[Search] stage '$stage' best confidence=${scored.maxOfOrNull { it.confidence }} " + + "(need >= $confidentMatch to stop)" + } + if (stage != "isrc") scored.forEach { if (it.id !in textSearchResults) textSearchResults[it.id] = it } + scored.forEach { s -> + val existing = sources[s.id] + if (existing == null || existing.confidence < s.confidence) sources[s.id] = s + } + } + + val ranked = sources.values.sortedByDescending { it.confidence } + val matched = ranked.filter { it.confidence >= minConfidence } + logger.i { "[Search] ${matched.size} of ${ranked.size} candidates have confidence >= $minConfidence" } + logger.i { + "[Search] final ranking:\n" + matched.take(5).mapIndexed { i, s -> + " #$i ${s.id} conf=${s.confidence} '${s.title}' by '${s.artist}'" + }.joinToString("\n") + } + + if (matched.isNotEmpty()) return matched.take(5) + + // Nothing scored well. Playing YouTube's best text-search guess beats failing playback, + // and ISRC results are skipped because an ISRC text search tends to return unrelated songs. + val fallback = textSearchResults.values.toList() + .sortedByDescending { it.confidence } // stable: ties keep YouTube's relevance order + .take(5) + .ifEmpty { ranked.take(5) } + logger.w { + "[Search] no confident match for '${track.title}'; falling back to best guesses: " + + fallback.joinToString { "${it.id} conf=${it.confidence} '${it.title}'" } + } + return fallback + } + + override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List { + return newPipeService.getVideoInfo(source.id).let { videoInfo -> + listOf( + AudioSource.Streamed( + id = videoInfo.id, + title = videoInfo.title, + artist = videoInfo.uploader, + album = null, + thumbnails = listOf( + Thumbnail( + url = videoInfo.thumbnailUrl, + width = 300, + height = 300) + ), + externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}", + confidence = source.confidence, + streams = videoInfo.audioStreams + ) + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumRepository.kt new file mode 100644 index 00000000..636c8a41 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumRepository.kt @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.album + +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.plugin.PluginManager +import io.github.reactivecircus.cache4k.Cache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +class AlbumRepository( + val pluginManager: PluginManager, + private val libraryRepository: LibraryRepository) { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + val plugin + get() = pluginManager.selectedMetadataPlugin.value + + private val albumInfoCache = Cache.Builder>().build() + private val albumTracksCache = Cache.Builder, PaginationResult>().build() + + init { + scope.launch { + pluginManager.selectedMetadataPlugin + .filterNotNull() + .flatMapLatest { it.loggedInFlow } + .distinctUntilChanged() + .collect { loggedIn -> + isLoggedIn = loggedIn + invalidateCaches() + } + } + } + + private var isLoggedIn: Boolean = false + + private val authPlugin get() = if (isLoggedIn) plugin else null + + fun invalidateCaches() { + albumInfoCache.invalidateAll() + albumTracksCache.invalidateAll() + } + + suspend fun getAlbumInfo(albumId: String) = plugin?.let { plugin -> + albumInfoCache.get(albumId) { + pluginManager.withScope { + plugin.use { + val album = metadataAlbumAPI.getAlbum(albumId) + val isSaved = libraryRepository.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false + album to isSaved + } + } + } + } + + suspend fun getAlbumTracks(albumId: String, paginationStrategy: PaginationStrategy? = null) = + authPlugin?.let { plugin -> + albumTracksCache.get(albumId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))) { + pluginManager.withScope { + plugin.use { + metadataAlbumAPI.getAlbumTracks( + id = albumId, + pagination = paginationStrategy + ) + } + } + } + } + + suspend fun toggleSavedAlbum(albumId: String, currentIsSaved: Boolean) = plugin?.let { plugin -> + if (currentIsSaved) { + libraryRepository.removeSavedAlbums(listOf(albumId)) + } else { + libraryRepository.saveAlbums(listOf(albumId)) + } + !currentIsSaved + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt new file mode 100644 index 00000000..bd134ba8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.album + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.map +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.CollectionView +import org.spotube.spotube.modules.library.playlist.AddToPlaylistPicker +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.album_loading_title +import spotube.composeapp.generated.resources.media_unknown_artist + +@Composable +fun AlbumScreen( + albumId: String, + viewModel: AlbumViewModel, + audioPlayerQueue: AudioPlayerQueue, + audioPlayer: AudioPlayerInterface, + navigationCommands: NavigationCommands +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) + val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() + val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle() + val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() + val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() + val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() + + val dataState = state as? AlbumScreenState.Data + val album = dataState?.album + val ownerName = + album?.artists?.joinToString { it.name }.orEmpty().ifBlank { stringResource(Res.string.media_unknown_artist) } + val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url + val firstArtistId = album?.artists?.firstOrNull()?.id + val artworkUrl = album?.thumbnails?.firstOrNull()?.url.orEmpty() + val isAlbumPlaying = currentCollectionEntry?.id == albumId && + playerState == PlayerState.PLAYING + + val errorMessage = (state as? AlbumScreenState.Error)?.message + val isLoading = state is AlbumScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) + + CollectionView( + isPlayLoading = playbackUiState.isPlayLoading, + isShuffleLoading = playbackUiState.isShuffleLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading, + title = album?.title ?: stringResource(Res.string.album_loading_title), + description = album?.description ?: "${album?.albumType?.name.orEmpty()} • ${album?.releaseDate.orEmpty()}", + imageURL = artworkUrl, + ownerName = ownerName, + ownerImageURL = ownerImageURL, + onOwnerClick = { + firstArtistId?.let { + navigationCommands.navigateTo(Routes.Artist(it)) + } + }, + onPlay = viewModel::playAlbum, + onShufflePlay = viewModel::shufflePlayAlbum, + onAddToQueue = viewModel::addAlbumToQueue, + isPlaying = isAlbumPlaying, + isFollowing = savedAlbumIds.contains(albumId), + isFollowLoading = dataState?.isSaving == true, + onFollowClick = viewModel::toggleSavedAlbum, + isLoading = isLoading, + error = errorMessage, + onRetry = { viewModel.refresh() }, + tracks = dataState?.tracks ?: emptyList(), + hasMore = dataState?.nextPagination != null, + isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore, + currentTrackId = trackOptionsContext.currentTrackId, + isCurrentTrackPlaying = playerState == PlayerState.PLAYING, + onTrackClick = viewModel::playAlbumFromTrack, + onLoadNextPage = viewModel::loadNextTracksPage, + onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) }, + onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) }, + onTrackOptionsAction = viewModel::handleTrackOptionsAction, + trackOptionsState = trackOptionsContext::stateFor, + onBulkDownload = viewModel::downloadTracks, + onBulkAddToQueue = viewModel::addTracksToQueue, + onBulkPlayNext = viewModel::playTracksNext, + onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, + onAddToJam = viewModel::addTracksToJam, + trailingContent = { + AddToPlaylistPicker( + visible = showAddToPlaylistPicker, + currentUserId = currentUserId, + onDismiss = viewModel::dismissAddToPlaylistPicker, + onPlaylistSelected = viewModel::addTracksToPlaylist) + }) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt new file mode 100644 index 00000000..eced34d5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt @@ -0,0 +1,398 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.album + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackUiState +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.ui.component.TrackOptionsAction +import org.spotube.spotube.core.ui.component.TrackOptionsContext +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.downloads.DownloadManager +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error + +sealed interface AlbumScreenState { + data object Loading : AlbumScreenState + + sealed interface Data : AlbumScreenState { + val album: MetadataAlbum.Detailed? + val isSaved: Boolean + val tracks: List + val nextPagination: PaginationStrategy? + val isSaving: Boolean + + data class Loaded( + override val album: MetadataAlbum.Detailed? = null, + override val isSaved: Boolean = false, + override val tracks: List = emptyList(), + override val nextPagination: PaginationStrategy? = null, + override val isSaving: Boolean = false) : Data { + fun toLoadingMore(): LoadingMore = LoadingMore( + album = album, + isSaved = isSaved, + tracks = tracks, + nextPagination = nextPagination, + isSaving = isSaving) + } + + data class LoadingMore( + override val album: MetadataAlbum.Detailed? = null, + override val isSaved: Boolean = false, + override val tracks: List = emptyList(), + override val nextPagination: PaginationStrategy? = null, + override val isSaving: Boolean = false) : Data + } + + data class Error(val message: String) : AlbumScreenState +} + +@OptIn(ExperimentalCoroutinesApi::class) +class AlbumViewModel( + private val albumId: String, + private val repository: AlbumRepository, + private val savedTracksRepository: SavedTracksRepository, + private val libraryRepository: LibraryRepository, + private val playbackHelper: CollectionPlaybackHelper, + private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository, + private val shareService: ShareService, + private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { + private val logger by injectLogger() + + private val _state = MutableStateFlow(AlbumScreenState.Loading) + val uiState: StateFlow = _state.asStateFlow() + + /** Loading flags for this screen's play / shuffle / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.Album, albumId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) + val savedAlbumIds + get() = libraryRepository.savedAlbumIdsFlow + + private val _currentUserId = MutableStateFlow(null) + val currentUserId: StateFlow = _currentUserId.asStateFlow() + + private val _blacklistedTrackIds = MutableStateFlow>(emptySet()) + val blacklistedTrackIds: StateFlow> = _blacklistedTrackIds.asStateFlow() + + private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) + val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() + + private val _tracksToAddToPlaylist = MutableStateFlow>(emptyList()) + private val _showAddToPlaylistPicker = MutableStateFlow(false) + val showAddToPlaylistPicker: StateFlow = _showAddToPlaylistPicker.asStateFlow() + + val trackOptionsContext: StateFlow = combine( + audioPlayerQueue.queueFlow, + audioPlayerQueue.currentQueueEntryFlow, + savedTracksRepository.savedTracksIdsFlow, + _blacklistedTrackIds, + _blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> + TrackOptionsContext( + currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, + queueTrackIds = queue.mapNotNull { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.id + }.toSet(), + savedTrackIds = savedIds, + blacklistedTrackIds = blacklistedTracks, + blacklistedArtistIds = blacklistedArtists) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext()) + + init { + viewModelScope.launch { + repository.pluginManager.selectedMetadataPlugin + .filterNotNull() + .flatMapLatest { it.loggedInFlow } + .distinctUntilChanged() + .collect { + loadInitialData() + loadCurrentUser() + } + } + blacklistRepository.blacklistedTracks + .onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() } + .launchIn(viewModelScope) + blacklistRepository.blacklistedArtists + .onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() } + .launchIn(viewModelScope) + } + + private suspend fun loadCurrentUser() { + runCatching { + _currentUserId.value = libraryRepository.currentUser()?.id + }.onFailure { e -> + logger.e(e) { "Failed to load current user" } + } + } + + private suspend fun loadInitialData() = runCatching { + _state.value = AlbumScreenState.Loading + val albumInfo = repository.getAlbumInfo(albumId) + val tracksResult = repository.getAlbumTracks(albumId) + tracksResult?.items?.let { savedTracksRepository.isSavedTracks(it.map { item -> item.id }) } + _state.value = AlbumScreenState.Data.Loaded( + album = albumInfo?.first, + isSaved = albumInfo?.second ?: false, + tracks = tracksResult?.items ?: emptyList(), + nextPagination = tracksResult?.nextPagination) + }.onFailure { e -> + logger.e(e) { "Failed to load album" } + _state.value = AlbumScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + + suspend fun loadMoreTracks() = runCatching { + val currentState = _state.value + if (currentState is AlbumScreenState.Data.Loaded && currentState.nextPagination != null) { + _state.value = currentState.toLoadingMore() + val result = repository.getAlbumTracks(albumId, currentState.nextPagination) + result?.items?.let { savedTracksRepository.isSavedTracks(it.map { item -> item.id }) } + _state.value = AlbumScreenState.Data.Loaded( + album = currentState.album, + isSaved = currentState.isSaved, + tracks = currentState.tracks + (result?.items ?: emptyList()), + nextPagination = result?.nextPagination, + isSaving = currentState.isSaving) + } + }.onFailure { e -> + logger.e(e) { "Failed to load more tracks" } + _state.value = AlbumScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + + fun loadNextTracksPage() { + viewModelScope.launch { + loadMoreTracks() + } + } + + fun toggleSavedAlbum() { + if ((_state.value as? AlbumScreenState.Data)?.isSaving == true) return + setSaving(true) + viewModelScope.launch { + runCatching { + val isLiked = + libraryRepository.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false + if (isLiked) { + libraryRepository.removeSavedAlbums(listOf(albumId)) + } else { + libraryRepository.saveAlbums(listOf(albumId)) + } + }.onFailure { e -> + logger.e(e) { "Failed to toggle saved album" } + } + setSaving(false) + } + } + + private fun setSaving(isSaving: Boolean) { + _state.update { state -> + when (state) { + is AlbumScreenState.Data.Loaded -> state.copy(isSaving = isSaving) + is AlbumScreenState.Data.LoadingMore -> state.copy(isSaving = isSaving) + else -> state + } + } + } + + fun playAlbum() { + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title) + } + + fun shufflePlayAlbum() { + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title, shuffle = true) + } + + fun addAlbumToQueue() { + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Album, albumId, title) + } + + fun playAlbumNext() { + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Album, albumId, title) + } + + fun playAlbumFromTrack(track: MetadataTrack) { + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.Album, + id = albumId, + title = title, + startTrack = track) + } + + fun refresh() { + viewModelScope.launch { + repository.invalidateCaches() + loadInitialData() + } + } + + fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { + viewModelScope.launch { + when (action) { + is TrackOptionsAction.StartRadio -> {} + is TrackOptionsAction.PlayNext -> { + val queue = audioPlayerQueue.getQueue() + val existing = queue.find { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } + } + + is TrackOptionsAction.AddToQueue -> { + remotePlaybackController.requestTrackAddToQueue(track) + } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) + } + + is TrackOptionsAction.RemoveFromQueue -> { + val queue = audioPlayerQueue.getQueue() + queue.find { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true + }?.let { audioPlayerQueue.removeFromQueue(it) } + } + + is TrackOptionsAction.ToggleFavorite -> { + val savedIds = savedTrackIds.value + if (savedIds.contains(track.id)) { + savedTracksRepository.removeSavedTracks(listOf(track.id)) + } else { + savedTracksRepository.saveTracks(listOf(track.id)) + } + } + + is TrackOptionsAction.Download -> downloadManager.enqueue(track) + is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) + is TrackOptionsAction.Share -> { + val uri = track.externalUri?.takeIf { it.isNotBlank() } + if (uri != null) { + shareService.share(uri, track.title) + } + } + is TrackOptionsAction.AddToPlaylist -> { + _tracksToAddToPlaylist.value = listOf(track) + _showAddToPlaylistPicker.value = true + } + } + } + } + + fun addTracksToPlaylist(playlistId: String) { + viewModelScope.launch { + runCatching { + libraryRepository.addTracksToPlaylist( + playlistId, + _tracksToAddToPlaylist.value.map { it.id }) + }.onFailure { e -> + logger.e(e) { "Failed to add tracks to playlist" } + }.onSuccess { + _showAddToPlaylistPicker.value = false + } + } + } + + fun showAddToPlaylistPicker(tracks: List) { + _tracksToAddToPlaylist.value = tracks + _showAddToPlaylistPicker.value = true + } + + fun dismissAddToPlaylistPicker() { + _showAddToPlaylistPicker.value = false + } + + fun downloadTracks(tracks: List) { + tracks.forEach { track -> downloadManager.enqueue(track) } + } + + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + + fun addTracksToQueue(tracks: List) { + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestTracksAddToQueue(tracks, title) + } + + fun playTracksNext(tracks: List) { + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestTracksPlayNext(tracks, title) + } + + fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = _blacklistedTrackIds.value + val artistIds = _blacklistedArtistIds.value + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + fun toggleTrackBlacklist(track: MetadataTrack) { + viewModelScope.launch { + blacklistRepository.toggleTrack(track) + } + } + + val savedTrackIds + get() = savedTracksRepository.savedTracksIdsFlow + + private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { + if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id + return title == other.title && + durationMs == other.durationMs && + album?.id == other.album?.id && + artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistRepository.kt new file mode 100644 index 00000000..b421fee0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistRepository.kt @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.artist + +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.plugin.PluginManager +import io.github.reactivecircus.cache4k.Cache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +class ArtistRepository( + private val pluginManager: PluginManager +) { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + val plugin + get() = pluginManager.selectedMetadataPlugin.value + + private val overviewCache = Cache.Builder().build() + private val albumsCache = + Cache.Builder, PaginationResult>().build() + private val relatedArtistsCache = + Cache.Builder, PaginationResult>().build() + private val featuredPlaylistsCache = + Cache.Builder, PaginationResult>().build() + private val topTracksCache = Cache.Builder>().build() + + init { + scope.launch { + pluginManager.selectedMetadataPlugin + .filterNotNull() + .flatMapLatest { it.loggedInFlow } + .distinctUntilChanged() + .collect { + invalidateCaches() + } + } + } + + fun invalidateCaches() { + overviewCache.invalidateAll() + albumsCache.invalidateAll() + relatedArtistsCache.invalidateAll() + featuredPlaylistsCache.invalidateAll() + topTracksCache.invalidateAll() + } + + suspend fun artistOverview(artistId: String) = plugin?.let { plugin -> + overviewCache.get(artistId) { + pluginManager.withScope { + plugin.use { + metadataArtistAPI.artistOverview(artistId) + } + } + } + } + + suspend fun topTracks(artistId: String) = plugin?.let { plugin -> + topTracksCache.get(artistId) { + pluginManager.withScope { + plugin.use { + metadataArtistAPI.getArtistTop10Tracks(artistId) + } + } + } + } + + suspend fun albums( + artistId: String, + paginationStrategy: PaginationStrategy? = null + ) = plugin?.let { plugin -> + albumsCache.get( + artistId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20)) + ) { + pluginManager.withScope { + plugin.use { + metadataArtistAPI.getArtistAlbums(artistId, paginationStrategy) + } + } + } + } + + suspend fun relatedArtists( + artistId: String, + paginationStrategy: PaginationStrategy? = null + ) = plugin?.let { plugin -> + relatedArtistsCache.get( + artistId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20)) + ) { + pluginManager.withScope { + plugin.use { + metadataArtistAPI.relatedArtists(artistId, paginationStrategy) + } + } + } + } + + suspend fun featuredPlaylists( + artistId: String, + paginationStrategy: PaginationStrategy? = null + ) = plugin?.let { plugin -> + featuredPlaylistsCache.get( + artistId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20)) + ) { + pluginManager.withScope { + plugin.use { + metadataArtistAPI.featuredPlaylists(artistId, paginationStrategy) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt new file mode 100644 index 00000000..68d5560b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt @@ -0,0 +1,752 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.artist + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumTooltip +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import com.composeunstyled.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.koin.compose.koinInject +import org.spotube.spotube.core.ui.component.AlbumCard +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.component.ArtistCard +import org.spotube.spotube.core.ui.component.PlaylistCard +import org.spotube.spotube.core.ui.component.TrackList +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.core.ui.component.dragScrollable +import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.modules.library.playlist.AddToPlaylistPicker +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxUserRemove +import org.spotube.spotube.resources.iconsax.User +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlin.math.roundToInt +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun ArtistScreen( + viewModel: ArtistViewModel, + audioPlayerQueue: AudioPlayerQueue, + audioPlayer: AudioPlayerInterface, + navigationCommands: NavigationCommands +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) + val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() + val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() + val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle() + val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle() + val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() + val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() + val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() + + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) + ) { + ApplicationMainBar() + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + when (val currentState = state) { + is ArtistScreenState.Loading -> { + ArtistLoadingContent() + } + + is ArtistScreenState.Error -> { + ArtistErrorContent( + message = currentState.message, + onRetry = { viewModel.refresh() } + ) + } + + is ArtistScreenState.Loaded -> { + val listState = rememberLazyListState() + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + item { + ArtistHeaderCard( + artist = currentState.artist, + isSaved = savedArtistIds.contains(currentState.artist.id), + isSaving = currentState.isSaving, + onFollowClick = viewModel::toggleSavedArtist, + isBlacklisted = currentState.artist.id in blacklistedArtistIds, + onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) }) + } + + item { + TopTracksHeader( + onPlay = viewModel::playTopTracks, + onAddToQueue = viewModel::addTopTracksToQueue, + isPlayLoading = playbackUiState.isPlayLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading + ) + } + + item { + TrackList( + tracks = currentState.topTracks, + error = null, + hasMore = false, + isLoading = false, + isLoadingNextPage = false, + currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, + isCurrentTrackPlaying = playerState == PlayerState.PLAYING, + onTrackClick = viewModel::playTopTracksFromTrack, + onTrackOptionsAction = viewModel::handleTrackOptionsAction, + trackOptionsState = trackOptionsContext::stateFor, + onArtistClick = { trackArtist -> + navigationCommands.navigateTo( + Routes.Artist( + trackArtist.id + ) + ) + }, + onAlbumClick = { album -> + navigationCommands.navigateTo( + Routes.Album( + album.id + ) + ) + }, + onLoadNextPage = {}, + simplified = true, + scrollable = false, + onBulkDownload = viewModel::downloadTracks, + onBulkAddToQueue = viewModel::addTracksToQueue, + onBulkPlayNext = viewModel::playTracksNext, + onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, + isJamGuest = isJamGuest + ) + } + + if (currentState.albums.isNotEmpty() || currentState.albumsNextPagination != null) { + item { + AlbumsSection( + albums = currentState.albums, + onAlbumClick = { album -> + navigationCommands.navigateTo( + Routes.Album( + album.id + ) + ) + }, + onLoadMore = { viewModel.loadMoreAlbums() }) + } + } + + if (currentState.relatedArtists.isNotEmpty() || currentState.relatedArtistsNextPagination != null) { + item { + RelatedArtistsSection( + artists = currentState.relatedArtists, + onArtistClick = { artist -> + navigationCommands.navigateTo( + Routes.Artist( + artist.id + ) + ) + }, + onLoadMore = { viewModel.loadMoreRelatedArtists() }) + } + } + + if (currentState.featuredPlaylists.isNotEmpty() || currentState.featuredPlaylistsNextPagination != null) { + item { + FeaturedPlaylistsSection( + playlists = currentState.featuredPlaylists, + onPlaylistClick = { playlist -> + navigationCommands.navigateTo( + Routes.Playlist( + playlist.id + ) + ) + }, + onLoadMore = { viewModel.loadMoreFeaturedPlaylists() }) + } + } + } + } + } + } + + AddToPlaylistPicker( + visible = showAddToPlaylistPicker, + currentUserId = currentUserId, + onDismiss = viewModel::dismissAddToPlaylistPicker, + onPlaylistSelected = viewModel::addTracksToPlaylist + ) + } +} + +@Composable +private fun ArtistLoadingContent() { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + SkeletonTree(true) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Box( + modifier = Modifier + .size(200.dp) + .clip(CircleShape) + .background(AppTheme.colors.surfaceContainerHighest) + ) + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + items(4) { + PlayableCard( + title = "Item Title", + subtitle = "Subtitle", + imageURL = "https://placehold.co/600x400" + ) + } + } + } + } + } + } +} + +@Composable +private fun ArtistErrorContent( + message: String, + onRetry: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = message, + color = AppTheme.colors.error, + style = AppTheme.typography.bodyLarge + ) + MillenniumButton(onClick = onRetry) { + Text(stringResource(Res.string.common_retry)) + } + } + } +} + +@Composable +private fun ArtistHeaderCard( + artist: MetadataArtist.Detailed, + isSaved: Boolean, + isSaving: Boolean, + onFollowClick: () -> Unit, + isBlacklisted: Boolean, + onBlacklistClick: () -> Unit +) { + Box(modifier = Modifier.fillMaxWidth()) { + val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium + + MillenniumCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + if (isCompact) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + ArtistAvatar( + artist = artist, + size = 180.dp + ) + + ArtistMeta( + artist = artist, + isCompact = true + ) + + ArtistHeaderActions( + isSaved = isSaved, + isSaving = isSaving, + onFollowClick = onFollowClick, + isBlacklisted = isBlacklisted, + onBlacklistClick = onBlacklistClick + ) + } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalAlignment = Alignment.Top + ) { + ArtistAvatar( + artist = artist, + size = 220.dp + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + ArtistMeta( + artist = artist, + isCompact = false + ) + + ArtistHeaderActions( + isSaved = isSaved, + isSaving = isSaving, + onFollowClick = onFollowClick, + isBlacklisted = isBlacklisted, + onBlacklistClick = onBlacklistClick + ) + } + } + } + } + } +} + +@Composable +private fun ArtistAvatar( + artist: MetadataArtist.Detailed, + size: androidx.compose.ui.unit.Dp +) { + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(AppTheme.colors.surfaceContainerHighest), + contentAlignment = Alignment.Center + ) { + val imageUrl = artist.thumbnails.firstOrNull()?.url.orEmpty() + if (imageUrl.isNotBlank()) { + AsyncImage( + model = imageUrl, + contentDescription = artist.name, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + MillenniumIcon( + imageVector = Iconsax.User, + contentDescription = artist.name, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(size * 0.4f) + ) + } + } +} + +@Composable +private fun ArtistMeta( + artist: MetadataArtist.Detailed, + isCompact: Boolean +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = artist.name, + style = if (isCompact) AppTheme.typography.headlineSmall else AppTheme.typography.headlineLarge, + fontWeight = FontWeight.SemiBold, + textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = buildString { + append(formatFollowers(artist.followersCount)) + if (artist.genres.isNotEmpty()) { + append(" • ") + append(artist.genres.joinToString(", ")) + } + }, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = if (isCompact) TextAlign.Center else TextAlign.Start + ) + + artist.biography?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + maxLines = if (isCompact) 4 else 6, + overflow = TextOverflow.Ellipsis, + textAlign = if (isCompact) TextAlign.Center else TextAlign.Start + ) + } + } +} + +@Composable +private fun ArtistHeaderActions( + isSaved: Boolean, + isSaving: Boolean, + onFollowClick: () -> Unit, + isBlacklisted: Boolean, + onBlacklistClick: () -> Unit +) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (isSaved) { + MillenniumButton( + onClick = onFollowClick, + loading = isSaving, + style = MillenniumButtonStyle.Secondary + ) { + Text( + stringResource(Res.string.artist_following), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } else { + MillenniumButton(onClick = onFollowClick, loading = isSaving) { + Text(stringResource(Res.string.collection_details_follow), modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) + } + } + + MillenniumTooltip( + text = if (isBlacklisted) stringResource(Res.string.blacklist_remove) else stringResource(Res.string.blacklist_add) + ) { + if (isBlacklisted) { + MillenniumIconButton( + onClick = onBlacklistClick, + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = stringResource(Res.string.blacklist_remove), + tint = AppTheme.colors.error + ) + } + } else { + MillenniumIconButton( + onClick = onBlacklistClick, + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = stringResource(Res.string.blacklist_add) + ) + } + } + } + } +} + +@Composable +private fun TopTracksHeader( + onPlay: () -> Unit, + onAddToQueue: () -> Unit, + isPlayLoading: Boolean = false, + isAddToQueueLoading: Boolean = false +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(Res.string.artist_top_tracks), + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumIconButton( + onClick = onPlay, + loading = isPlayLoading, + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxPlay, + contentDescription = stringResource(Res.string.artist_play_top_tracks), + tint = LocalTextStyle.current.color + ) + } + MillenniumIconButton( + onClick = onAddToQueue, + loading = isAddToQueueLoading, + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = stringResource(Res.string.artist_add_top_tracks_to_queue) + ) + } + } + } +} + +@Composable +private fun AlbumsSection( + albums: List, + onAlbumClick: (MetadataAlbum.Detailed) -> Unit, + onLoadMore: () -> Unit +) { + val rowState = rememberLazyListState() + + LaunchedEffect(rowState) { + snapshotFlow { rowState.layoutInfo } + .map { layoutInfo -> + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index + val totalItems = layoutInfo.totalItemsCount + Pair(lastVisibleIndex, totalItems) + } + .distinctUntilChanged() + .collect { (lastVisibleIndex, totalItems) -> + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) { + onLoadMore() + } + } + } + + SectionHeader(title = stringResource(Res.string.library_tab_albums)) + Spacer(modifier = Modifier.size(8.dp)) + LazyRow( + state = rowState, + modifier = Modifier.dragScrollable(rowState), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + items(albums, key = { it.id }) { album -> + AlbumCard(album = album) + } + } +} + +@Composable +private fun RelatedArtistsSection( + artists: List, + onArtistClick: (MetadataArtist.Basic) -> Unit, + onLoadMore: () -> Unit +) { + val rowState = rememberLazyListState() + + LaunchedEffect(rowState) { + snapshotFlow { rowState.layoutInfo } + .map { layoutInfo -> + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index + val totalItems = layoutInfo.totalItemsCount + Pair(lastVisibleIndex, totalItems) + } + .distinctUntilChanged() + .collect { (lastVisibleIndex, totalItems) -> + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) { + onLoadMore() + } + } + } + + SectionHeader(title = stringResource(Res.string.artist_related_artists)) + Spacer(modifier = Modifier.size(8.dp)) + LazyRow( + state = rowState, + modifier = Modifier.dragScrollable(rowState), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + items(artists, key = { it.id }) { artist -> + ArtistCard(artist = artist) + } + } +} + +@Composable +private fun FeaturedPlaylistsSection( + playlists: List, + onPlaylistClick: (MetadataPlaylist) -> Unit, + onLoadMore: () -> Unit +) { + val rowState = rememberLazyListState() + + LaunchedEffect(rowState) { + snapshotFlow { rowState.layoutInfo } + .map { layoutInfo -> + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index + val totalItems = layoutInfo.totalItemsCount + Pair(lastVisibleIndex, totalItems) + } + .distinctUntilChanged() + .collect { (lastVisibleIndex, totalItems) -> + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) { + onLoadMore() + } + } + } + + SectionHeader(title = stringResource(Res.string.artist_featured_playlists)) + Spacer(modifier = Modifier.size(8.dp)) + LazyRow( + state = rowState, + modifier = Modifier.dragScrollable(rowState), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + items(playlists, key = { it.id }) { playlist -> + PlaylistCard(playlist = playlist) + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 16.dp) + ) +} + +@Composable +private fun formatFollowers(count: Int?): String { + if (count == null) return stringResource(Res.string.artist_followers_unavailable) + return when { + count >= 1_000_000_000 -> stringResource(Res.string.artist_followers_billions, formatAbbreviatedCount(count, 1_000_000_000)) + count >= 1_000_000 -> stringResource(Res.string.artist_followers_millions, formatAbbreviatedCount(count, 1_000_000)) + count >= 1_000 -> stringResource(Res.string.artist_followers_thousands, formatAbbreviatedCount(count, 1_000)) + else -> pluralStringResource(Res.plurals.artist_followers_count, count, count) + } +} + +private fun formatAbbreviatedCount(count: Int, divisor: Int): String { + val scaled = count / divisor.toDouble() + val rounded = (scaled * 10).roundToInt() / 10.0 + return if (rounded % 1.0 == 0.0) { + rounded.toInt().toString() + } else { + rounded.toString() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt new file mode 100644 index 00000000..81764bab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt @@ -0,0 +1,443 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.artist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.remote.CollectionPlaybackUiState +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.ui.component.TrackOptionsAction +import org.spotube.spotube.core.ui.component.TrackOptionsContext +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.downloads.DownloadManager +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.* + +sealed interface ArtistScreenState { + data object Loading : ArtistScreenState + + data class Loaded( + val artist: MetadataArtist.Detailed, + val isArtistSaved: Boolean, + val topTracks: List, + val albums: List, + val albumsNextPagination: PaginationStrategy?, + val relatedArtists: List, + val relatedArtistsNextPagination: PaginationStrategy?, + val featuredPlaylists: List, + val featuredPlaylistsNextPagination: PaginationStrategy?, + val isSaving: Boolean = false) : ArtistScreenState + + data class Error(val message: String) : ArtistScreenState +} + +@OptIn(ExperimentalStdlibApi::class) +class ArtistViewModel( + private val artistId: String, + private val repository: ArtistRepository, + private val libraryRepository: LibraryRepository, + private val savedTracksRepository: SavedTracksRepository, + private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository, + private val shareService: ShareService, + private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { + private val logger by injectLogger() + + private val _state = MutableStateFlow(ArtistScreenState.Loading) + val state: StateFlow = _state.asStateFlow() + + /** Loading flags for the top-tracks play / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.ArtistTopTracks, artistId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) + + val savedArtistIds + get() = libraryRepository.savedArtistIdsFlow + + val savedTrackIds + get() = savedTracksRepository.savedTracksIdsFlow + + private val _currentUserId = MutableStateFlow(null) + val currentUserId: StateFlow = _currentUserId.asStateFlow() + + private val _blacklistedTrackIds = MutableStateFlow>(emptySet()) + val blacklistedTrackIds: StateFlow> = _blacklistedTrackIds.asStateFlow() + + private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) + val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() + + private val _tracksToAddToPlaylist = MutableStateFlow>(emptyList()) + private val _showAddToPlaylistPicker = MutableStateFlow(false) + val showAddToPlaylistPicker: StateFlow = _showAddToPlaylistPicker.asStateFlow() + + val trackOptionsContext: StateFlow = combine( + audioPlayerQueue.queueFlow, + audioPlayerQueue.currentQueueEntryFlow, + savedTracksRepository.savedTracksIdsFlow, + _blacklistedTrackIds, + _blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> + TrackOptionsContext( + currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, + queueTrackIds = queue.mapNotNull { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.id + }.toSet(), + savedTrackIds = savedIds, + blacklistedTrackIds = blacklistedTracks, + blacklistedArtistIds = blacklistedArtists) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext()) + + init { + viewModelScope.launch { + libraryRepository.savedArtistIdsFlow.collect { + val currentState = _state.value + if (currentState is ArtistScreenState.Loaded) { + _state.value = currentState.copy(isArtistSaved = it.contains(artistId)) + } + } + } + blacklistRepository.blacklistedTracks + .onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() } + .launchIn(viewModelScope) + blacklistRepository.blacklistedArtists + .onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() } + .launchIn(viewModelScope) + loadCurrentUser() + loadOverview() + } + + private fun loadCurrentUser() { + viewModelScope.launch { + runCatching { + _currentUserId.value = libraryRepository.currentUser()?.id + }.onFailure { e -> + logger.e(e) { "Failed to load current user" } + } + } + } + + fun refresh() { + repository.invalidateCaches() + loadOverview() + } + + fun toggleSavedArtist() { + if ((_state.value as? ArtistScreenState.Loaded)?.isSaving == true) return + setSaving(true) + viewModelScope.launch { + runCatching { + val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false + if (isLiked) { + libraryRepository.removeSavedArtists(listOf(artistId)) + } else { + libraryRepository.saveArtists(listOf(artistId)) + } + }.onFailure { e -> + logger.e(e) { "Failed to toggle saved artist" } + } + setSaving(false) + } + } + + private fun setSaving(isSaving: Boolean) { + _state.update { state -> + if (state is ArtistScreenState.Loaded) state.copy(isSaving = isSaving) else state + } + } + + fun isArtistBlacklisted(artistId: String): Boolean { + return _blacklistedArtistIds.value.contains(artistId) + } + + fun toggleArtistBlacklist(artist: MetadataArtist) { + viewModelScope.launch { + blacklistRepository.toggleArtist(artist) + } + } + + fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = _blacklistedTrackIds.value + val artistIds = _blacklistedArtistIds.value + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + fun toggleTrackBlacklist(track: MetadataTrack) { + viewModelScope.launch { + blacklistRepository.toggleTrack(track) + } + } + + fun loadMoreAlbums() { + val currentState = _state.value + if (currentState !is ArtistScreenState.Loaded) return + val pagination = currentState.albumsNextPagination ?: return + + viewModelScope.launch { + runCatching { + repository.albums(artistId, pagination) + }.onSuccess { result -> + if (result != null) { + _state.value = currentState.copy( + albums = currentState.albums + result.items, + albumsNextPagination = result.nextPagination) + } + }.onFailure { e -> + logger.e(e) { "Failed to load more albums" } + } + } + } + + fun loadMoreRelatedArtists() { + val currentState = _state.value + if (currentState !is ArtistScreenState.Loaded) return + val pagination = currentState.relatedArtistsNextPagination ?: return + + viewModelScope.launch { + runCatching { + repository.relatedArtists(artistId, pagination) + }.onSuccess { result -> + if (result != null) { + _state.value = currentState.copy( + relatedArtists = currentState.relatedArtists + result.items, + relatedArtistsNextPagination = result.nextPagination) + } + }.onFailure { e -> + logger.e(e) { "Failed to load more related artists" } + } + } + } + + fun loadMoreFeaturedPlaylists() { + val currentState = _state.value + if (currentState !is ArtistScreenState.Loaded) return + val pagination = currentState.featuredPlaylistsNextPagination ?: return + + viewModelScope.launch { + runCatching { + repository.featuredPlaylists(artistId, pagination) + }.onSuccess { result -> + if (result != null) { + _state.value = currentState.copy( + featuredPlaylists = currentState.featuredPlaylists + result.items, + featuredPlaylistsNextPagination = result.nextPagination) + } + }.onFailure { e -> + logger.e(e) { "Failed to load more featured playlists" } + } + } + } + + fun addTopTracksToQueue() { + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.ArtistTopTracks, + artistId, + artistName) + } + + fun playTopTracks() { + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.ArtistTopTracks, + artistId, + artistName) + } + + fun playTopTracksFromTrack(track: MetadataTrack) { + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.ArtistTopTracks, + id = artistId, + title = artistName, + startTrack = track) + } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + + fun addTracksToQueue(tracks: List) { + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestTracksAddToQueue(tracks, artistName) + } + + fun playTracksNext(tracks: List) { + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestTracksPlayNext(tracks, artistName) + } + + fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { + viewModelScope.launch { + when (action) { + is TrackOptionsAction.StartRadio -> {} + is TrackOptionsAction.PlayNext -> { + val queue = audioPlayerQueue.getQueue() + val existing = queue.find { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } + } + is TrackOptionsAction.AddToQueue -> { + remotePlaybackController.requestTrackAddToQueue(track) + } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) + } + is TrackOptionsAction.RemoveFromQueue -> { + val queue = audioPlayerQueue.getQueue() + queue.find { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true + }?.let { audioPlayerQueue.removeFromQueue(it) } + } + is TrackOptionsAction.ToggleFavorite -> { + val savedIds = savedTrackIds.value + if (savedIds.contains(track.id)) { + savedTracksRepository.removeSavedTracks(listOf(track.id)) + } else { + savedTracksRepository.saveTracks(listOf(track.id)) + } + } + is TrackOptionsAction.Download -> downloadManager.enqueue(track) + is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) + is TrackOptionsAction.Share -> { + val uri = track.externalUri?.takeIf { it.isNotBlank() } + if (uri != null) { + shareService.share(uri, track.title) + } + } + is TrackOptionsAction.AddToPlaylist -> { + _tracksToAddToPlaylist.value = listOf(track) + _showAddToPlaylistPicker.value = true + } + } + } + } + + fun addTracksToPlaylist(playlistId: String) { + viewModelScope.launch { + runCatching { + libraryRepository.addTracksToPlaylist( + playlistId, + _tracksToAddToPlaylist.value.map { it.id }) + }.onFailure { e -> + logger.e(e) { "Failed to add tracks to playlist" } + }.onSuccess { + _showAddToPlaylistPicker.value = false + } + } + } + + fun showAddToPlaylistPicker(tracks: List) { + _tracksToAddToPlaylist.value = tracks + _showAddToPlaylistPicker.value = true + } + + fun dismissAddToPlaylistPicker() { + _showAddToPlaylistPicker.value = false + } + + fun downloadTracks(tracks: List) { + tracks.forEach { track -> downloadManager.enqueue(track) } + } + + private fun loadOverview() { + _state.value = ArtistScreenState.Loading + viewModelScope.launch { + runCatching { + repository.artistOverview(artistId) + }.onSuccess { overview -> + if (overview != null) { + savedTracksRepository.isSavedTracks(overview.top10Tracks.map { it.id }) + _state.value = ArtistScreenState.Loaded( + artist = overview.artist, + isArtistSaved = libraryRepository.savedArtistIdsFlow.value.contains(artistId), + topTracks = overview.top10Tracks, + albums = overview.albums.items, + albumsNextPagination = overview.albums.nextPagination, + relatedArtists = overview.relatedArtists.items, + relatedArtistsNextPagination = overview.relatedArtists.nextPagination, + featuredPlaylists = overview.featuredPlaylists.items, + featuredPlaylistsNextPagination = overview.featuredPlaylists.nextPagination) + } else { + _state.value = ArtistScreenState.Error(getString(Res.string.artist_error_load_failed)) + } + }.onFailure { e -> + logger.e(e) { "Failed to load artist overview" } + _state.value = ArtistScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + } + } + + private suspend fun resolveTopTrackEntries(): List { + val currentState = _state.value + if (currentState !is ArtistScreenState.Loaded) return emptyList() + + val blacklistedTracks = blacklistRepository.getTracksSnapshot() + val blacklistedArtists = blacklistRepository.getArtistsSnapshot() + val blacklistedTrackIds = blacklistedTracks.map { it.id }.toSet() + val blacklistedArtistIds = blacklistedArtists.map { it.id }.toSet() + + return currentState.topTracks + .filter { track -> + track.id !in blacklistedTrackIds && + track.artists.none { it.id in blacklistedArtistIds } + } + .map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + } + + private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { + if (id.isNotBlank() && other.id.isNotBlank()) { + return id == other.id + } + return title == other.title && + durationMs == other.durationMs && + album?.id == other.album?.id && + artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistRepository.kt new file mode 100644 index 00000000..394d264b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistRepository.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2026 Kingmor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.blacklist + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.db.Database +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString + +open class BlacklistRepository( + private val database: Database) { + companion object { + private val BLACKLISTED_TRACKS_KEY = stringPreferencesKey("blacklisted_tracks") + private val BLACKLISTED_ARTISTS_KEY = stringPreferencesKey("blacklisted_artists") + } + + private val json = Json { ignoreUnknownKeys = true } + + open val blacklistedTracks: Flow> = database.blacklistDataStore.data + .map { prefs -> + val tracksJson = prefs[BLACKLISTED_TRACKS_KEY] + if (tracksJson != null) { + try { + json.decodeFromString>(tracksJson) + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + } + + open val blacklistedArtists: Flow> = database.blacklistDataStore.data + .map { prefs -> + val artistsJson = prefs[BLACKLISTED_ARTISTS_KEY] + if (artistsJson != null) { + try { + json.decodeFromString>(artistsJson) + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + } + + open suspend fun toggleTrack(track: MetadataTrack) { + val currentTracks = getTracksSnapshot().toMutableList() + val existingIndex = currentTracks.indexOfFirst { it.id == track.id } + + if (existingIndex >= 0) { + currentTracks.removeAt(existingIndex) + } else { + currentTracks.add(track) + } + + database.blacklistDataStore.edit { prefs -> + prefs[BLACKLISTED_TRACKS_KEY] = json.encodeToString(currentTracks) + } + } + + open suspend fun toggleArtist(artist: MetadataArtist) { + val currentArtists = getArtistsSnapshot().toMutableList() + val existingIndex = currentArtists.indexOfFirst { it.id == artist.id } + + if (existingIndex >= 0) { + currentArtists.removeAt(existingIndex) + } else { + currentArtists.add(artist) + } + + database.blacklistDataStore.edit { prefs -> + prefs[BLACKLISTED_ARTISTS_KEY] = json.encodeToString(currentArtists) + } + } + + suspend fun getTracksSnapshot(): List { + return database.blacklistDataStore.data.map { prefs -> + val tracksJson = prefs[BLACKLISTED_TRACKS_KEY] + if (tracksJson != null) { + try { + json.decodeFromString>(tracksJson) + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + }.first() + } + + suspend fun getArtistsSnapshot(): List { + return database.blacklistDataStore.data.map { prefs -> + val artistsJson = prefs[BLACKLISTED_ARTISTS_KEY] + if (artistsJson != null) { + try { + json.decodeFromString>(artistsJson) + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + }.first() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistScreen.kt new file mode 100644 index 00000000..dac469e7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistScreen.kt @@ -0,0 +1,303 @@ +/* + * Copyright (C) 2026 Kingmor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.blacklist + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumTextField +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch +import org.spotube.spotube.resources.iconsax.IconsaxMusicCircle +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled +import org.spotube.spotube.resources.iconsax.IconsaxUserRemove +import org.spotube.spotube.resources.iconsax.User +import org.koin.compose.viewmodel.koinViewModel +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun BlacklistScreen() { + val viewModel = koinViewModel() + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + backButton = true, + title = { Text(stringResource(Res.string.settings_blacklist_title)) }) + BlacklistContent( + viewModel = viewModel, + modifier = Modifier.weight(1f)) + } +} + +/** + * The blacklist UI without its own app bar, so it can be embedded + * as a tab in the large-screen settings layout as well as hosted by [BlacklistScreen]. + */ +@Composable +internal fun BlacklistContent( + viewModel: BlacklistViewModel, + modifier: Modifier = Modifier) { + val blacklistedTracks by viewModel.blacklistedTracks.collectAsStateWithLifecycle() + val blacklistedArtists by viewModel.blacklistedArtists.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + + val filterState = rememberTextFieldState("") + val filterQuery = filterState.text.toString() + val normalizedQuery = filterQuery.trim().lowercase() + + val filteredTracks = remember(blacklistedTracks, normalizedQuery) { + if (normalizedQuery.isBlank()) { + blacklistedTracks + } else { + blacklistedTracks.filter { track -> + val searchableText = buildString { + append(track.title).append(' ') + append(track.album?.title.orEmpty()).append(' ') + append(track.artists.joinToString(" ") { it.name }) + }.lowercase() + searchableText.contains(normalizedQuery) + } + } + } + + val filteredArtists = remember(blacklistedArtists, normalizedQuery) { + if (normalizedQuery.isBlank()) { + blacklistedArtists + } else { + blacklistedArtists.filter { artist -> + artist.name.lowercase().contains(normalizedQuery) + } + } + } + + val isEmpty = filteredTracks.isEmpty() && filteredArtists.isEmpty() + val hasActiveFilter = normalizedQuery.isNotBlank() + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumTextField( + state = filterState, + label = "", + modifier = Modifier.fillMaxWidth(), + placeholder = stringResource(Res.string.blacklist_filter_placeholder)) + + if (isEmpty) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center) { + Text( + text = if (hasActiveFilter) { + stringResource(Res.string.blacklist_no_results, filterQuery) + } else { + stringResource(Res.string.blacklist_empty) + }, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + bottom = 16.dp + shellBottomInset)) { + if (filteredTracks.isNotEmpty()) { + item { + Text( + text = stringResource(Res.string.blacklist_section_tracks), + style = AppTheme.typography.titleSmall, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp)) + } + items(filteredTracks, key = { "track_${it.id}" }) { track -> + BlacklistedTrackRow( + track = track, + onRemove = { viewModel.toggleTrack(track) }) + } + } + + if (filteredArtists.isNotEmpty()) { + item { + Text( + text = stringResource(Res.string.library_tab_artists), + style = AppTheme.typography.titleSmall, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp)) + } + items(filteredArtists, key = { "artist_${it.id}" }) { artist -> + BlacklistedArtistRow( + artist = artist, + onRemove = { viewModel.toggleArtist(artist) }) + } + } + } + } + } +} + +@Composable +private fun BlacklistedTrackRow( + track: MetadataTrack, + onRemove: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusicCircle, + contentDescription = stringResource(Res.string.blacklist_item_track), + modifier = Modifier.size(20.dp), + tint = AppTheme.colors.onSurfaceVariant) + + AsyncImage( + model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(48.dp) + .clip(AppTheme.shapes.small) + .shimmerApply()) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = track.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Text( + text = track.artists.joinToString(", ") { it.name }, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + } + + MillenniumIconButton( + onClick = onRemove, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusicSquareRemoveFilled, + contentDescription = stringResource(Res.string.blacklist_remove), + tint = AppTheme.colors.error) + } + } +} + +@Composable +private fun BlacklistedArtistRow( + artist: MetadataArtist, + onRemove: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumIcon( + imageVector = Iconsax.User, + contentDescription = stringResource(Res.string.media_artist), + modifier = Modifier.size(20.dp), + tint = AppTheme.colors.onSurfaceVariant) + + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + contentAlignment = Alignment.Center) { + val imageUrl = artist.thumbnails.firstOrNull()?.url + if (!imageUrl.isNullOrBlank()) { + AsyncImage( + model = imageUrl, + contentDescription = artist.name, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + .shimmerApply()) + } else { + MillenniumIcon( + imageVector = Iconsax.User, + contentDescription = artist.name, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(24.dp)) + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = artist.name, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Text( + text = stringResource(Res.string.media_artist), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + + MillenniumIconButton( + onClick = onRemove, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = stringResource(Res.string.blacklist_remove), + tint = AppTheme.colors.error) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModek.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModek.kt new file mode 100644 index 00000000..be47579b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModek.kt @@ -0,0 +1,2 @@ +package org.spotube.spotube.modules.blacklist + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModel.kt new file mode 100644 index 00000000..a4b1ae81 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModel.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2026 Kingmor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.blacklist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class BlacklistViewModel( + private val repository: BlacklistRepository) : ViewModel() { + + val blacklistedTracks: StateFlow> = repository.blacklistedTracks + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val blacklistedArtists: StateFlow> = repository.blacklistedArtists + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun toggleTrack(track: MetadataTrack) { + viewModelScope.launch { + repository.toggleTrack(track) + } + } + + fun toggleArtist(artist: MetadataArtist) { + viewModelScope.launch { + repository.toggleArtist(artist) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt new file mode 100644 index 00000000..e9d946d1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt @@ -0,0 +1,414 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.devices + + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSpokeSpinner +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.shape.CircleShape + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.modules.settings.sections.SettingsItemIcon +import org.spotube.spotube.modules.settings.sections.remoteControlSettingCards +import org.spotube.spotube.modules.settings.sections.settingsSectionCard +import org.spotube.spotube.modules.settings.sections.settingsSectionHeader +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxInformation +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight +import org.spotube.spotube.resources.iconsax.IconsaxWifiSquare +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_disconnect +import spotube.composeapp.generated.resources.devices_empty +import spotube.composeapp.generated.resources.devices_empty_hint +import spotube.composeapp.generated.resources.devices_open_remote +import spotube.composeapp.generated.resources.devices_refresh +import spotube.composeapp.generated.resources.devices_resolving +import spotube.composeapp.generated.resources.devices_search_again +import spotube.composeapp.generated.resources.devices_searching +import spotube.composeapp.generated.resources.devices_searching_more +import spotube.composeapp.generated.resources.devices_section_available +import spotube.composeapp.generated.resources.devices_section_connected +import spotube.composeapp.generated.resources.devices_section_this_device +import spotube.composeapp.generated.resources.devices_status_connected +import spotube.composeapp.generated.resources.devices_title +import spotube.composeapp.generated.resources.devices_unknown_device +import spotube.composeapp.generated.resources.jam_error_dismiss + +private val ContentMaxWidth = 720.dp + +@Composable +fun DevicesScreen( + viewModel: DevicesViewModel = koinViewModel(), + settingsViewModel: SettingsViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val settings by settingsViewModel.settingsState.collectAsStateWithLifecycle() + val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() + val shellBottomInset = LocalAppShellBottomInset.current + + DisposableEffect(Unit) { + // Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works. + requestLocalNetworkPermission() + viewModel.startDiscovery() + onDispose { + // The remote connection itself outlives this screen; only discovery stops. + viewModel.stopDiscovery() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) + ) { + ApplicationMainBar( + backButton = true, + title = { Text(stringResource(Res.string.devices_title)) }, + actions = { + MillenniumIconButton( + onClick = viewModel::startDiscovery, + enabled = !state.isConnecting, + loading = state.isDiscovering, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxRefreshRight, + contentDescription = stringResource(Res.string.devices_refresh) + ) + } + } + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.TopCenter + ) { + LazyColumn( + modifier = Modifier + .widthIn(max = ContentMaxWidth) + .fillMaxSize(), + contentPadding = PaddingValues( + start = 8.dp, + end = 8.dp, + top = 8.dp, + bottom = shellBottomInset + 16.dp + ) + ) { + state.error?.let { error -> + item { + ErrorBanner( + message = error.rawMessage + ?: stringResource(error.message, *error.args.toTypedArray()), + onDismiss = viewModel::clearError + ) + } + } + + state.connectedDevice?.let { device -> + settingsSectionHeader(Res.string.devices_section_connected) + item { + ConnectedDeviceCard( + device = device, + onOpenRemote = viewModel::openRemote, + onDisconnect = viewModel::disconnect + ) + } + } + + if (state.devices.isNotEmpty()) { + settingsSectionHeader(Res.string.devices_section_available) + settingsSectionCard( + items = state.devices.map { device -> + { + DeviceTile( + device = device, + enabled = !state.isConnecting || device.isConnecting, + onClick = { viewModel.onDeviceClick(device.key) } + ) + } + } + ) + if (state.isDiscovering) { + item { + Text( + text = stringResource(Res.string.devices_searching_more), + style = AppTheme.typography.bodySmall, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + ) + } + } + } else if (state.showEmptyState) { + item { + EmptyState( + isDiscovering = state.isDiscovering, + onSearchAgain = viewModel::startDiscovery + ) + } + } + + // Same cards as Settings > Playback, so making this device controllable (or renaming + // it) doesn't require a detour through Settings. + settings?.let { current -> + settingsSectionHeader(Res.string.devices_section_this_device) + settingsSectionCard( + items = remoteControlSettingCards(current, settingsViewModel, requestLocalNetworkPermission) + ) + } + } + } + } +} + +@Composable +private fun DeviceTile( + device: DeviceItemUi, + enabled: Boolean, + onClick: () -> Unit +) { + val name = device.name ?: stringResource(Res.string.devices_unknown_device) + SettingCardItem( + enabled = enabled, + title = name, + subtitle = device.address ?: stringResource(Res.string.devices_resolving), + icon = { SettingsItemIcon(Iconsax.IconsaxMirroringScreen, name) }, + trailingContent = { + when { + device.isConnecting -> MillenniumSpokeSpinner(size = 20.dp) + device.isConnected -> Text( + text = stringResource(Res.string.devices_status_connected), + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.primary + ) + else -> MillenniumIcon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant + ) + } + }, + onClick = onClick + ) +} + +@Composable +private fun LazyItemScope.ConnectedDeviceCard( + device: ConnectedDeviceUi, + onOpenRemote: () -> Unit, + onDisconnect: () -> Unit +) { + val name = device.name ?: stringResource(Res.string.devices_unknown_device) + MillenniumCard( + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + maxWidth = ContentMaxWidth, + contentPadding = PaddingValues(16.dp), + contentSpacing = 16.dp, + shadowElevation = 6.dp + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + SettingsItemIcon(Iconsax.IconsaxMirroringScreen, name) + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = AppTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = device.address, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + MillenniumButton( + onClick = onDisconnect, + loading = device.isDisconnecting, + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.devices_disconnect)) + } + MillenniumButton( + onClick = onOpenRemote, + style = MillenniumButtonStyle.Primary + ) { + Text(stringResource(Res.string.devices_open_remote)) + } + } + } +} + +@Composable +private fun ErrorBanner( + message: String, + onDismiss: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .clip(AppTheme.shapes.medium) + .background(AppTheme.colors.errorContainer) + .padding(start = 12.dp, top = 4.dp, bottom = 4.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxInformation, + contentDescription = null, + tint = AppTheme.colors.onErrorContainer, + modifier = Modifier.size(20.dp) + ) + Text( + text = message, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onErrorContainer, + modifier = Modifier.weight(1f) + ) + MillenniumIconButton( + onClick = onDismiss, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = stringResource(Res.string.jam_error_dismiss), + tint = AppTheme.colors.onErrorContainer + ) + } + } +} + +@Composable +private fun LazyItemScope.EmptyState( + isDiscovering: Boolean, + onSearchAgain: () -> Unit +) { + // Fixed padding rather than filling the viewport, so the "This device" section below stays visible. + Box( + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = 24.dp, vertical = 48.dp), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.widthIn(max = 360.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + if (isDiscovering) { + MillenniumSpokeSpinner(size = 32.dp) + } else { + MillenniumIcon( + imageVector = Iconsax.IconsaxWifiSquare, + contentDescription = null, + tint = AppTheme.colors.primary, + modifier = Modifier.size(32.dp) + ) + } + } + Text( + text = stringResource( + if (isDiscovering) Res.string.devices_searching else Res.string.devices_empty + ), + style = AppTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = stringResource(Res.string.devices_empty_hint), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center + ) + if (!isDiscovering) { + MillenniumButton( + onClick = onSearchAgain, + style = MillenniumButtonStyle.Secondary, + modifier = Modifier.padding(top = 8.dp) + ) { + Text(stringResource(Res.string.devices_search_again)) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt new file mode 100644 index 00000000..19f365ee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt @@ -0,0 +1,314 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.devices + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import org.spotube.spotube.core.discovery.DeviceDiscoveryService +import org.spotube.spotube.core.discovery.DiscoveredDevice +import org.spotube.spotube.core.discovery.DiscoveryState +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.remote.RemoteControlClient +import org.spotube.spotube.core.remote.RemoteControlService +import org.spotube.spotube.modules.settings.SettingsProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_error_connect_failed +import spotube.composeapp.generated.resources.devices_error_discovery_failed + +/** + * A user-facing error for the devices screen. [rawMessage] (an untranslated + * cause reported by the transport) takes precedence when present; otherwise the + * composable resolves [message] with [args]. + */ +data class DevicesError( + val message: StringResource, + val args: List = emptyList(), + val rawMessage: String? = null) + +data class DeviceItemUi( + val key: String, + /** Null when the device advertised a blank name. */ + val name: String?, + /** `host:port`, or null while the address is still being resolved. */ + val address: String?, + val isConnecting: Boolean, + val isConnected: Boolean) + +data class ConnectedDeviceUi( + val name: String?, + val address: String, + val isDisconnecting: Boolean = false) + +data class DevicesUiState( + val devices: List = emptyList(), + val isDiscovering: Boolean = false, + val isConnecting: Boolean = false, + val connectedDevice: ConnectedDeviceUi? = null, + val error: DevicesError? = null) { + val showEmptyState: Boolean get() = devices.isEmpty() && !isConnecting +} + +class DevicesViewModel( + private val navigationCommands: NavigationCommands) : ViewModel(), KoinComponent { + private val logger = Logger.withTag("DevicesViewModel") + private val discoveryService: DeviceDiscoveryService by inject() + private val remoteControlClient: RemoteControlClient by inject() + private val remoteControlService: RemoteControlService by inject() + private val settingsProvider: SettingsProvider by inject() + + private val _devices = MutableStateFlow>(emptyMap()) + private val _isDiscovering = MutableStateFlow(false) + private val _connectingToDevice = MutableStateFlow(null) + private val _error = MutableStateFlow(null) + private val _isDisconnecting = MutableStateFlow(false) + + val uiState: StateFlow = combine( + _devices, + _isDiscovering, + _connectingToDevice, + remoteControlClient.connectionState, + combine(_error, _isDisconnecting, ::Pair) + ) { devices, isDiscovering, connectingTo, connection, (error, isDisconnecting) -> + val connected = connection as? ConnectionState.Connected + DevicesUiState( + devices = devices.values.map { device -> + DeviceItemUi( + key = device.key, + name = device.name.ifBlank { null }, + address = device.address(), + isConnecting = connectingTo?.key == device.key, + isConnected = connected != null && device.matches(connected)) + }, + isDiscovering = isDiscovering, + isConnecting = connectingTo != null, + connectedDevice = connected?.let { + ConnectedDeviceUi( + name = it.deviceName.ifBlank { null }, + address = "${it.host}:${it.port}", + isDisconnecting = isDisconnecting) + }, + error = error) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DevicesUiState()) + + private var discoveryJob: Job? = null + + init { + viewModelScope.launch { + var previous: ConnectionState = remoteControlClient.connectionState.value + remoteControlClient.connectionState.collect { state -> + val wasConnecting = previous is ConnectionState.Connecting + previous = state + when (state) { + is ConnectionState.Error -> { + _error.value = DevicesError(message = state.fallback, rawMessage = state.message) + _connectingToDevice.value = null + } + // Only a drop *during* an attempt means it failed. Switching devices + // disconnects the old one first, and that must not cancel the new attempt. + is ConnectionState.Disconnected -> if (wasConnecting) _connectingToDevice.value = null + is ConnectionState.Connected -> { + // Only navigate for a connection this screen started. A connection + // that already existed when the screen opened must not bounce the + // user straight to the remote screen. + if (_connectingToDevice.value != null) { + _connectingToDevice.value = null + navigationCommands.navigateTo(Routes.RemoteControl) + } + } + is ConnectionState.Connecting -> {} + } + } + } + // Whenever the local device id is resolved, drop any of our own + // advertisements that may have been picked up before we knew our id. + viewModelScope.launch { + remoteControlService.localDeviceId.collect { id -> + if (id.isNotBlank()) { + removeSelf() + } + } + } + } + + fun startDiscovery() { + if (discoveryJob?.isActive == true) return + _isDiscovering.value = true + _error.value = null + logger.i { "Starting device discovery" } + // Advertising may have failed before the local-network permission was + // granted; give it another chance now that discovery is being used. + remoteControlService.retryAdvertising() + discoveryJob = viewModelScope.launch { + try { + discoveryService.discover().collect { event -> + logger.d { "Discovery event: $event" } + when (event) { + is DiscoveryState.Discovered -> { + // Don't hold up the rest of the events while this device resolves. + launch { event.resolve() } + if (!isSelf(event.device)) { + _devices.update { it + (event.device.key to event.device.copy()) } + } + } + is DiscoveryState.Resolved -> { + if (isSelf(event.device)) { + // Resolved now carries our deviceId in TXT; drop self. + _devices.update { it - event.device.key } + } else { + _devices.update { it + (event.device.key to event.device) } + } + } + is DiscoveryState.Removed -> { + _devices.update { it - event.device.key } + } + } + } + } catch (e: CancellationException) { + // stopDiscovery() (e.g. leaving the screen) is not a failure. + throw e + } catch (e: Exception) { + logger.e(e) { "Discovery failed" } + _error.value = DevicesError(Res.string.devices_error_discovery_failed, listOf(e.message.toString())) + _isDiscovering.value = false + } + } + } + + /** + * True when [device] is this device's own advertisement. On the initial + * `Discovered` event dns-sd hasn't resolved the TXT record yet (deviceId is + * empty), so we match by the name we advertise; once resolved we also have + * the authoritative deviceId. + */ + private fun isSelf(device: DiscoveredDevice): Boolean { + val localId = remoteControlService.localDeviceId.value.ifBlank { + settingsProvider.settingsState.value?.remoteControlDeviceId ?: "" + } + if (localId.isNotBlank() && device.deviceId == localId) return true + // Match by the deterministic advertised name as a fallback for the + // pre-resolution event where deviceId isn't available yet. + return device.name.isNotBlank() && device.name == remoteControlService.advertisedName() + } + + private fun removeSelf() { + val localId = remoteControlService.localDeviceId.value + if (localId.isBlank()) return + _devices.update { map -> + map.filterNot { (_, device) -> device.deviceId == localId } + } + } + + fun stopDiscovery() { + discoveryJob?.cancel() + discoveryJob = null + _isDiscovering.value = false + } + + fun onDeviceClick(key: String) { + val device = _devices.value[key] ?: return + val connected = remoteControlClient.connectionState.value as? ConnectionState.Connected + when { + // Already controlling it (e.g. came back from the remote screen): just reopen it. + connected != null && device.matches(connected) -> openRemote() + connected != null -> viewModelScope.launch { + remoteControlClient.disconnect() + connectToDevice(device) + } + else -> connectToDevice(device) + } + } + + fun openRemote() { + navigationCommands.navigateTo(Routes.RemoteControl) + } + + private fun connectToDevice(device: DiscoveredDevice) { + if (_connectingToDevice.value != null) { + logger.w { "Already connecting to a device" } + return + } + if (isSelf(device)) { + logger.w { "Refusing to connect to self: ${device.name}" } + return + } + + _connectingToDevice.value = device + _error.value = null + logger.i { "Connecting to device ${device.name} at ${device.host}:${device.port}" } + + viewModelScope.launch { + try { + val settings = settingsProvider.settingsState.value + val deviceId = settings?.remoteControlDeviceId ?: "" + val deviceName = settings?.remoteControlDeviceName?.ifBlank { "Spotube Controller" } + ?: "Spotube Controller" + + remoteControlClient.connect( + host = device.host, + port = device.port, + deviceId = deviceId, + deviceName = deviceName, + remoteDeviceName = device.name) + // connect() returns once the socket is open, but the host may still be + // asking its user for permission. The connection-state collector clears + // the connecting flag once the outcome is known. + } catch (e: Exception) { + logger.e(e) { "Failed to connect to device" } + _error.value = DevicesError(Res.string.devices_error_connect_failed, listOf(e.message.toString())) + _connectingToDevice.value = null + } + } + } + + fun disconnect() { + if (_isDisconnecting.value) return + _isDisconnecting.value = true + viewModelScope.launch { + try { + remoteControlClient.disconnect() + } finally { + _isDisconnecting.value = false + } + } + } + + fun clearError() { + _error.value = null + } +} + +private fun DiscoveredDevice.address(): String? = + if (host.isNotBlank() && port > 0) "$host:$port" else null + +private fun DiscoveredDevice.matches(connected: ConnectionState.Connected): Boolean = + host == connected.host && port == connected.port diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/PlayDestinationPicker.kt new file mode 100644 index 00000000..849b7c5c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/PlayDestinationPicker.kt @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.devices + + +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogFooter +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.remote.PlaybackDestinationAction +import org.spotube.spotube.core.remote.RemoteControlClient +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_destination_action_add_to_queue +import spotube.composeapp.generated.resources.devices_destination_action_play +import spotube.composeapp.generated.resources.devices_destination_action_play_next +import spotube.composeapp.generated.resources.common_cancel +import spotube.composeapp.generated.resources.devices_destination_prompt +import spotube.composeapp.generated.resources.devices_destination_remote_device +import spotube.composeapp.generated.resources.devices_destination_remote_device_host +import spotube.composeapp.generated.resources.devices_destination_remote_subtitle +import spotube.composeapp.generated.resources.devices_destination_this_device +import spotube.composeapp.generated.resources.devices_destination_this_device_subtitle +import spotube.composeapp.generated.resources.devices_destination_title + +/** + * Globally hosted dialog shown when the user tries to play / add to queue / + * play next and there is more than one place it could go (a connected remote + * device and/or an active jam session). Lets the user choose the destination. + */ +@Composable +fun PlayDestinationPickerHost() { + val controller = koinInject() + val remoteControlClient = koinInject() + val request by controller.pendingRequest.collectAsStateWithLifecycle() + val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() + + val pendingRequest = request ?: return + + val remoteDeviceName = when (val state = connectionState) { + is ConnectionState.Connected -> stringResource(Res.string.devices_destination_remote_device_host, state.host) + else -> stringResource(Res.string.devices_destination_remote_device) + } + + val actionLabel = when (pendingRequest.action) { + PlaybackDestinationAction.Play -> stringResource(Res.string.devices_destination_action_play) + PlaybackDestinationAction.AddToQueue -> stringResource(Res.string.devices_destination_action_add_to_queue) + PlaybackDestinationAction.PlayNext -> stringResource(Res.string.devices_destination_action_play_next) + } + + MillenniumDialog(visible = true, onDismissRequest = controller::dismissPicker) { + MillenniumDialogHeader { + MillenniumDialogTitle(stringResource(Res.string.devices_destination_title)) + MillenniumDialogClose() + } + MillenniumDialogBody { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(Res.string.devices_destination_prompt, actionLabel, pendingRequest.title), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) + + MillenniumListTile( + onClick = controller::playLocally, + modifier = Modifier.fillMaxWidth(), + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxCd, + contentDescription = null, + tint = AppTheme.colors.primary) + }, + title = { + Text( + text = stringResource(Res.string.devices_destination_this_device), + style = AppTheme.typography.bodyLarge) + }, + subtitle = { + Text( + text = stringResource(Res.string.devices_destination_this_device_subtitle), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + }) + + if (connectionState is ConnectionState.Connected) { + MillenniumListTile( + onClick = controller::playOnRemote, + modifier = Modifier.fillMaxWidth(), + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = null, + tint = AppTheme.colors.primary) + }, + title = { + Text( + text = remoteDeviceName, + style = AppTheme.typography.bodyLarge) + }, + subtitle = { + Text( + text = stringResource(Res.string.devices_destination_remote_subtitle), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + }) + } + + } + } + MillenniumDialogFooter { + MillenniumButton( + onClick = controller::dismissPicker, + style = MillenniumButtonStyle.Quiet) { + Text(stringResource(Res.string.common_cancel)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt new file mode 100644 index 00000000..765df60b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt @@ -0,0 +1,833 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.devices + + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumSlider +import org.spotube.millennium.composables.MillenniumSpokeSpinner +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import com.composeunstyled.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.modules.shell.player_queue.QueueSheet +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemove +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxPrevious +import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne +import org.spotube.spotube.resources.iconsax.IconsaxShuffle +import org.spotube.spotube.resources.iconsax.IconsaxVolumeCross +import org.spotube.spotube.resources.iconsax.IconsaxVolumeHigh +import org.spotube.spotube.resources.iconsax.IconsaxVolumeLow +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_connecting +import spotube.composeapp.generated.resources.devices_disconnect +import spotube.composeapp.generated.resources.devices_remote_back_to_devices +import spotube.composeapp.generated.resources.devices_remote_connecting_hint +import spotube.composeapp.generated.resources.devices_remote_connection_error +import spotube.composeapp.generated.resources.devices_remote_disconnected +import spotube.composeapp.generated.resources.devices_remote_disconnected_hint +import spotube.composeapp.generated.resources.devices_remote_more_options +import spotube.composeapp.generated.resources.devices_remote_nothing_playing +import spotube.composeapp.generated.resources.devices_remote_nothing_playing_hint +import spotube.composeapp.generated.resources.devices_remote_playing_on +import spotube.composeapp.generated.resources.devices_remote_remove_from_queue +import spotube.composeapp.generated.resources.devices_remote_title +import spotube.composeapp.generated.resources.devices_remote_unknown_artist +import spotube.composeapp.generated.resources.devices_remote_unknown_track +import spotube.composeapp.generated.resources.devices_remote_volume +import spotube.composeapp.generated.resources.player_disable_shuffle_content_description +import spotube.composeapp.generated.resources.player_enable_shuffle_content_description +import spotube.composeapp.generated.resources.player_loop_mode_all +import spotube.composeapp.generated.resources.player_loop_mode_none +import spotube.composeapp.generated.resources.player_loop_mode_one +import spotube.composeapp.generated.resources.player_next_content_description +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.player_previous_content_description +import spotube.composeapp.generated.resources.player_seek_label +import spotube.composeapp.generated.resources.queue_no_queue_entries +import spotube.composeapp.generated.resources.queue_title + +// Below this content width the queue moves into a sheet; above it, it sits beside the player. +private val WideLayoutMinWidth = 760.dp +private val QueuePanelWidth = 380.dp +private val NowPlayingMaxWidth = 520.dp + +// Rough height of everything under the artwork (track info, seek bar, controls, volume). +// The artwork gets what's left, so the whole player fits without scrolling. +private val ControlsReservedHeight = 330.dp +private val MinArtworkSize = 140.dp +private val MaxArtworkSize = 460.dp + +@Composable +fun RemoteControlScreen( + onDisconnect: () -> Unit, + viewModel: RemoteControlViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + val isConnected = state.connection is ConnectionState.Connected + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) + ) { + val isWide = maxWidth >= WideLayoutMinWidth + + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { Text(stringResource(Res.string.devices_remote_title)) }, + subtitle = { + if (isConnected && state.deviceName.isNotBlank()) { + Text(stringResource(Res.string.devices_remote_playing_on, state.deviceName)) + } + }, + backButton = true, + actions = { + if (isConnected && !isWide) { + MillenniumIconButton( + onClick = viewModel::toggleQueueVisibility, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = stringResource(Res.string.queue_title), + tint = if (state.isQueueVisible) { + AppTheme.colors.primary + } else { + AppTheme.colors.onSurfaceVariant + } + ) + } + } + if (isConnected) { + MillenniumIconButton( + onClick = { + viewModel.disconnect() + onDisconnect() + }, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = stringResource(Res.string.devices_disconnect) + ) + } + } + } + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(bottom = shellBottomInset) + ) { + when (val connection = state.connection) { + is ConnectionState.Connected -> { + if (isWide) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + NowPlayingPane( + state = state, + viewModel = viewModel, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) + MillenniumCard( + modifier = Modifier + .width(QueuePanelWidth) + .fillMaxHeight(), + maxWidth = QueuePanelWidth, + contentPadding = PaddingValues(12.dp), + shadowElevation = 2.dp + ) { + RemoteQueueList( + state = state, + onPlayQueueItem = viewModel::playQueueItem, + onRemoveQueueItem = viewModel::removeQueueItem + ) + } + } + } else { + NowPlayingPane( + state = state, + viewModel = viewModel, + modifier = Modifier.fillMaxSize() + ) + } + } + + is ConnectionState.Connecting -> ConnectionStatus( + title = stringResource(Res.string.devices_connecting), + message = stringResource(Res.string.devices_remote_connecting_hint), + leading = { MillenniumSpokeSpinner(size = 40.dp) } + ) + + is ConnectionState.Disconnected -> ConnectionStatus( + title = stringResource(Res.string.devices_remote_disconnected), + message = stringResource(Res.string.devices_remote_disconnected_hint), + leading = { StatusIcon(Iconsax.IconsaxMirroringScreen, isError = false) }, + actionLabel = stringResource(Res.string.devices_remote_back_to_devices), + onAction = onDisconnect + ) + + is ConnectionState.Error -> ConnectionStatus( + title = stringResource(Res.string.devices_remote_disconnected), + message = stringResource( + Res.string.devices_remote_connection_error, + connection.message ?: stringResource(connection.fallback) + ), + leading = { StatusIcon(Iconsax.IconsaxMirroringScreen, isError = true) }, + actionLabel = stringResource(Res.string.devices_remote_back_to_devices), + onAction = onDisconnect + ) + } + } + } + + if (isConnected && !isWide) { + // Click-outside scrim for the sliding drawer variant of the queue sheet. + // (The bottom sheet variant has its own built-in scrim.) + if (state.isQueueVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = viewModel::dismissQueue + ) + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset) + ) { + QueueSheet( + isVisible = state.isQueueVisible, + onDismiss = viewModel::dismissQueue, + modifier = Modifier.fillMaxSize() + ) { + RemoteQueueList( + state = state, + onPlayQueueItem = viewModel::playQueueItem, + onRemoveQueueItem = viewModel::removeQueueItem + ) + } + } + } + } +} + +@Composable +private fun NowPlayingPane( + state: RemoteControlUiState, + viewModel: RemoteControlViewModel, + modifier: Modifier = Modifier +) { + BoxWithConstraints(modifier = modifier) { + val artworkSize = minOf( + maxWidth - 32.dp, + maxHeight - ControlsReservedHeight, + MaxArtworkSize + ).coerceAtLeast(MinArtworkSize) + + // Centered when it fits; scrolls only on very short windows. + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Column( + modifier = Modifier.widthIn(max = NowPlayingMaxWidth), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Artwork( + coverUrl = state.coverUrl, + contentDescription = state.title, + size = artworkSize + ) + TrackInfo( + state = state, + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp) + ) + SeekBar( + state = state, + onSeekChange = viewModel::onSeekChange, + onSeekFinished = viewModel::onSeekFinished, + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp) + ) + PlaybackControls( + state = state, + onToggleShuffle = viewModel::toggleShuffle, + onSkipPrevious = viewModel::skipPrevious, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onCycleLoopMode = viewModel::cycleLoopMode, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 12.dp) + ) + VolumeControl( + state = state, + onVolumeChange = viewModel::onVolumeChange, + onVolumeChangeFinished = viewModel::onVolumeChangeFinished, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} + +@Composable +private fun Artwork( + coverUrl: String?, + contentDescription: String?, + size: Dp +) { + Box( + modifier = Modifier + .size(size) + .clip(RoundedCornerShape(22.dp)) + .background(AppTheme.colors.surfaceContainerHighest), + contentAlignment = Alignment.Center + ) { + if (coverUrl != null) { + AsyncImage( + model = coverUrl, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusic, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(56.dp) + ) + } + } +} + +@Composable +private fun TrackInfo( + state: RemoteControlUiState, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (!state.hasTrack) { + Text( + text = stringResource(Res.string.devices_remote_nothing_playing), + style = AppTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + maxLines = 1 + ) + Text( + text = stringResource(Res.string.devices_remote_nothing_playing_hint), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp) + ) + return@Column + } + + Text( + text = state.title ?: stringResource(Res.string.devices_remote_unknown_track), + style = AppTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = state.artists ?: stringResource(Res.string.devices_remote_unknown_artist), + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + state.album?.let { album -> + Text( + text = album, + style = AppTheme.typography.bodySmall, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp) + ) + } + } +} + +@Composable +private fun SeekBar( + state: RemoteControlUiState, + onSeekChange: (Float) -> Unit, + onSeekFinished: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + MillenniumSlider( + value = state.seekProgress, + onValueChange = onSeekChange, + onValueChangeFinished = onSeekFinished, + enabled = state.canSeek, + label = stringResource(Res.string.player_seek_label), + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = state.positionLabel, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + Text( + text = state.durationLabel, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } +} + +@Composable +private fun PlaybackControls( + state: RemoteControlUiState, + onToggleShuffle: () -> Unit, + onSkipPrevious: () -> Unit, + onTogglePlayPause: () -> Unit, + onSkipNext: () -> Unit, + onCycleLoopMode: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + MillenniumIconButton( + onClick = onToggleShuffle, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxShuffle, + contentDescription = stringResource( + if (state.isShuffling) { + Res.string.player_disable_shuffle_content_description + } else { + Res.string.player_enable_shuffle_content_description + } + ), + tint = if (state.isShuffling) AppTheme.colors.primary else AppTheme.colors.onSurfaceVariant + ) + } + MillenniumIconButton( + onClick = onSkipPrevious, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxPrevious, + contentDescription = stringResource(Res.string.player_previous_content_description) + ) + } + MillenniumIconButton( + onClick = onTogglePlayPause, + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + imageVector = if (state.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = stringResource( + if (state.isPlaying) { + Res.string.player_pause_content_description + } else { + Res.string.player_play_content_description + } + ), + modifier = Modifier.size(30.dp), + tint = LocalTextStyle.current.color + ) + } + MillenniumIconButton( + onClick = onSkipNext, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxNext, + contentDescription = stringResource(Res.string.player_next_content_description) + ) + } + MillenniumIconButton( + onClick = onCycleLoopMode, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = when (state.loopMode) { + RemoteLoopMode.NONE -> Iconsax.IconsaxRepeateMusic + RemoteLoopMode.ONE -> Iconsax.IconsaxRepeateOne + RemoteLoopMode.ALL -> Iconsax.IconsaxRepeatMusic + }, + contentDescription = stringResource( + when (state.loopMode) { + RemoteLoopMode.NONE -> Res.string.player_loop_mode_none + RemoteLoopMode.ONE -> Res.string.player_loop_mode_one + RemoteLoopMode.ALL -> Res.string.player_loop_mode_all + } + ), + tint = if (state.loopMode == RemoteLoopMode.NONE) { + AppTheme.colors.onSurfaceVariant + } else { + AppTheme.colors.primary + } + ) + } + } +} + +@Composable +private fun VolumeControl( + state: RemoteControlUiState, + onVolumeChange: (Float) -> Unit, + onVolumeChangeFinished: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + MillenniumIcon( + imageVector = when (state.volumeLevel) { + RemoteVolumeLevel.MUTED -> Iconsax.IconsaxVolumeCross + RemoteVolumeLevel.LOW -> Iconsax.IconsaxVolumeLow + RemoteVolumeLevel.HIGH -> Iconsax.IconsaxVolumeHigh + }, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + MillenniumSlider( + value = state.volume, + onValueChange = onVolumeChange, + onValueChangeFinished = onVolumeChangeFinished, + label = stringResource(Res.string.devices_remote_volume), + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun ConnectionStatus( + title: String, + message: String, + leading: @Composable () -> Unit, + actionLabel: String? = null, + onAction: () -> Unit = {} +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.widthIn(max = 360.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + leading() + Text( + text = title, + style = AppTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = message, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center + ) + if (actionLabel != null) { + MillenniumButton( + onClick = onAction, + style = MillenniumButtonStyle.Secondary, + modifier = Modifier.padding(top = 8.dp) + ) { + Text(actionLabel) + } + } + } + } +} + +@Composable +private fun StatusIcon(icon: ImageVector, isError: Boolean) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background( + if (isError) AppTheme.colors.errorContainer else AppTheme.colors.surfaceContainerHighest + ), + contentAlignment = Alignment.Center + ) { + MillenniumIcon( + imageVector = icon, + contentDescription = null, + tint = if (isError) AppTheme.colors.onErrorContainer else AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(32.dp) + ) + } +} + +@Composable +private fun RemoteQueueList( + state: RemoteControlUiState, + onPlayQueueItem: (Int) -> Unit, + onRemoveQueueItem: (String) -> Unit +) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = stringResource(Res.string.queue_title), + style = AppTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 4.dp) + ) + + if (state.queue.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(Res.string.queue_no_queue_entries), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant + ) + } + } else { + // Open with the current track in view; after that the user owns the scroll position. + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = state.currentQueueIndex.coerceAtLeast(0) + ) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + state = listState, + contentPadding = PaddingValues(bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(items = state.queue, key = { it.key }) { item -> + RemoteQueueItemRow( + item = item, + onPlayClick = { onPlayQueueItem(item.index) }, + onRemoveClick = { onRemoveQueueItem(item.mediaUrl) } + ) + } + } + } + } +} + +@Composable +private fun RemoteQueueItemRow( + item: RemoteQueueItemUi, + onPlayClick: () -> Unit, + onRemoveClick: () -> Unit +) { + var showMenu by remember { mutableStateOf(false) } + + MillenniumListTile( + onClick = onPlayClick, + selected = item.isCurrent, + leading = { + Box( + modifier = Modifier + .size(40.dp) + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceVariant), + contentAlignment = Alignment.Center + ) { + if (item.coverUrl != null) { + AsyncImage( + model = item.coverUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Text( + text = "${item.index + 1}", + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant + ) + } + } + }, + title = { + Text( + text = item.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (item.isCurrent) { + AppTheme.colors.onSecondaryContainer + } else { + AppTheme.colors.onSurface + } + ) + }, + subtitle = { + Text( + text = item.artists, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + trailing = { + Text( + text = item.durationLabel, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + + MillenniumDropdownMenu( + expanded = showMenu, + onExpandedChange = { showMenu = it }, + anchor = { + MillenniumIconButton( + onClick = { showMenu = true }, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.Iconsax3DotsMore, + contentDescription = stringResource(Res.string.devices_remote_more_options) + ) + } + }) { + MillenniumDropdownMenuItem( + label = stringResource(Res.string.devices_remote_remove_from_queue), + icon = Iconsax.IconsaxMusicSquareRemove, + destructive = true, + onClick = { + onRemoveClick() + showMenu = false + }) + } + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt new file mode 100644 index 00000000..0c5d78c1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt @@ -0,0 +1,272 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.devices + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.remote.RemoteControlClient +import org.spotube.spotube.core.remote.RemoteControlCommand +import org.spotube.spotube.core.remote.RemoteControlEvent +import org.spotube.spotube.modules.shell.formatPlayerTime +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.time.Duration.Companion.milliseconds + +enum class RemoteLoopMode(val wireValue: String) { + NONE("none"), + ONE("one"), + ALL("all"); + + fun next(): RemoteLoopMode = when (this) { + NONE -> ONE + ONE -> ALL + ALL -> NONE + } + + companion object { + fun fromWire(value: String): RemoteLoopMode = entries.firstOrNull { it.wireValue == value } ?: NONE + } +} + +enum class RemoteVolumeLevel { MUTED, LOW, HIGH } + +data class RemoteQueueItemUi( + val key: String, + val index: Int, + val mediaUrl: String, + val title: String, + val artists: String, + val coverUrl: String?, + val durationLabel: String, + val isCurrent: Boolean) + +data class RemoteControlUiState( + val connection: ConnectionState = ConnectionState.Disconnected, + val deviceName: String = "", + val hasTrack: Boolean = false, + val title: String? = null, + val artists: String? = null, + val album: String? = null, + val coverUrl: String? = null, + val isPlaying: Boolean = false, + val isShuffling: Boolean = false, + val loopMode: RemoteLoopMode = RemoteLoopMode.NONE, + val canSeek: Boolean = false, + val seekProgress: Float = 0f, + val positionLabel: String = formatPlayerTime(0.milliseconds), + val durationLabel: String = formatPlayerTime(0.milliseconds), + val volume: Float = 1f, + val volumeLevel: RemoteVolumeLevel = RemoteVolumeLevel.HIGH, + val queue: List = emptyList(), + val currentQueueIndex: Int = -1, + val isQueueVisible: Boolean = false) + +private data class LocalOverrides( + val seekProgress: Float?, + val volume: Float?, + val isQueueVisible: Boolean) + +class RemoteControlViewModel : ViewModel(), KoinComponent { + private val remoteControlClient: RemoteControlClient by inject() + + // While the user drags a slider these hold the local value, so the host's + // periodic state pushes don't yank the thumb back mid-gesture. + private val seekOverride = MutableStateFlow(null) + private val volumeOverride = MutableStateFlow(null) + private val isQueueVisible = MutableStateFlow(false) + + private val overrides = combine(seekOverride, volumeOverride, isQueueVisible) { seek, volume, queueVisible -> + LocalOverrides(seek, volume, queueVisible) + } + + val uiState: StateFlow = combine( + remoteControlClient.connectionState, + remoteControlClient.latestPlayerState, + remoteControlClient.latestQueue, + overrides + ) { connection, player, queue, local -> + buildUiState(connection, player, queue, local) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RemoteControlUiState()) + + init { + // Stream volume changes while dragging, throttled so a fast drag doesn't + // flood the socket with one command per frame. + viewModelScope.launch { + volumeOverride.filterNotNull().collect { volume -> + remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume)) + delay(VOLUME_THROTTLE_MS) + } + } + } + + private fun buildUiState( + connection: ConnectionState, + player: RemoteControlEvent.PlayerState?, + queue: RemoteControlEvent.QueueUpdated?, + local: LocalOverrides + ): RemoteControlUiState { + val durationMs = player?.durationMs ?: 0L + val canSeek = durationMs > 0L + val liveProgress = if (canSeek) { + ((player?.positionMs ?: 0L).toFloat() / durationMs).coerceIn(0f, 1f) + } else { + 0f + } + val seekProgress = local.seekProgress ?: liveProgress + val currentIndex = queue?.currentIndex ?: -1 + val volume = local.volume ?: player?.volume ?: 1f + + return RemoteControlUiState( + connection = connection, + deviceName = (connection as? ConnectionState.Connected)?.deviceName.orEmpty(), + hasTrack = player?.currentTrackId != null || player?.currentTrackTitle != null, + title = player?.currentTrackTitle, + artists = player?.currentTrackArtists, + album = player?.currentTrackAlbum, + coverUrl = player?.currentTrackCoverUrl, + isPlaying = player?.isPlaying ?: false, + isShuffling = player?.shuffleEnabled ?: false, + loopMode = RemoteLoopMode.fromWire(player?.loopMode ?: RemoteLoopMode.NONE.wireValue), + canSeek = canSeek, + seekProgress = seekProgress, + positionLabel = formatPlayerTime((durationMs * seekProgress).toLong().milliseconds), + durationLabel = formatPlayerTime(durationMs.milliseconds), + volume = volume, + volumeLevel = when { + volume <= 0f -> RemoteVolumeLevel.MUTED + volume < 0.5f -> RemoteVolumeLevel.LOW + else -> RemoteVolumeLevel.HIGH + }, + queue = queue?.entries.orEmpty().mapIndexed { index, entry -> + RemoteQueueItemUi( + key = "${entry.mediaUrl}@$index", + index = index, + mediaUrl = entry.mediaUrl, + title = entry.title, + artists = entry.artists, + coverUrl = entry.coverUrl, + durationLabel = formatPlayerTime(entry.durationMs.milliseconds), + isCurrent = index == currentIndex) + }, + currentQueueIndex = currentIndex, + isQueueVisible = local.isQueueVisible) + } + + private fun send(command: RemoteControlCommand) { + viewModelScope.launch { + remoteControlClient.sendCommand(command) + } + } + + /** + * Keeps [override] at [value] until the host reports back (or a timeout), so + * the slider doesn't flash the stale value in between. Skips the reset if the + * user already started a new drag. + */ + private suspend fun releaseOverrideAfterEcho(override: MutableStateFlow, value: Float) { + withTimeoutOrNull(OVERRIDE_RELEASE_TIMEOUT_MS) { + remoteControlClient.latestPlayerState.drop(1).first() + } + override.compareAndSet(value, null) + } + + fun togglePlayPause() = send(RemoteControlCommand.TogglePlayPause) + + fun skipNext() = send(RemoteControlCommand.SkipNext) + + fun skipPrevious() = send(RemoteControlCommand.SkipPrevious) + + fun onSeekChange(progress: Float) { + seekOverride.value = progress.coerceIn(0f, 1f) + } + + fun onSeekFinished() { + val progress = seekOverride.value ?: return + val durationMs = remoteControlClient.latestPlayerState.value?.durationMs ?: 0L + if (durationMs <= 0L) { + seekOverride.value = null + return + } + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.Seek((durationMs * progress).toLong())) + releaseOverrideAfterEcho(seekOverride, progress) + } + } + + fun onVolumeChange(volume: Float) { + volumeOverride.value = volume.coerceIn(0f, 1f) + } + + fun onVolumeChangeFinished() { + val volume = volumeOverride.value ?: return + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume)) + releaseOverrideAfterEcho(volumeOverride, volume) + } + } + + fun toggleShuffle() { + val shuffling = remoteControlClient.latestPlayerState.value?.shuffleEnabled ?: false + send(RemoteControlCommand.SetShuffle(!shuffling)) + } + + fun cycleLoopMode() { + val current = RemoteLoopMode.fromWire( + remoteControlClient.latestPlayerState.value?.loopMode ?: RemoteLoopMode.NONE.wireValue) + send(RemoteControlCommand.SetLoopMode(current.next().wireValue)) + } + + fun playQueueItem(index: Int) { + if (index < 0) return + send(RemoteControlCommand.PlayIndex(index)) + } + + fun removeQueueItem(mediaUrl: String) = send(RemoteControlCommand.RemoveFromQueue(mediaUrl)) + + fun toggleQueueVisibility() { + isQueueVisible.update { !it } + } + + fun dismissQueue() { + isQueueVisible.value = false + } + + fun disconnect() { + viewModelScope.launch { + remoteControlClient.disconnect() + } + } + + private companion object { + const val VOLUME_THROTTLE_MS = 100L + const val OVERRIDE_RELEASE_TIMEOUT_MS = 1_500L + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt new file mode 100644 index 00000000..2cbd6775 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt @@ -0,0 +1,573 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.downloads + +import org.spotube.spotube.core.network.HttpClientFactory +import co.touchlab.kermit.Logger +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.server.StreamInfo +import org.spotube.spotube.core.server.StreamRequest +import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.core.server.urlSummary +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.spotube.modules.settings.SettingsRepository +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.get +import io.ktor.client.request.head +import io.ktor.client.request.headers +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentLength +import io.ktor.http.isSuccess +import io.ktor.utils.io.readRemaining +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.io.readByteArray +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath +import okio.SYSTEM +import okio.buffer +import okio.use +import kotlin.time.Clock +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.* + +data class DownloadItem( + val id: String, + val title: String, + val artists: String, + val album: String?, + val status: DownloadStatus = DownloadStatus.Queued, + val progress: Float = 0f, + val totalBytes: Long = 0L, + val downloadedBytes: Long = 0L, + val errorMessage: String? = null, + val track: MetadataTrack? = null) + +sealed interface DownloadStatus { + data object Queued : DownloadStatus + data object Downloading : DownloadStatus + data object Completed : DownloadStatus + data class Failed(val error: String) : DownloadStatus + data object Cancelled : DownloadStatus +} + +class DownloadManager( + private val settingsRepository: SettingsRepository, + private val paths: Paths, + private val streamingUrlRepository: StreamingUrlRepository, + private val httpClientFactory: HttpClientFactory +) { + companion object { + const val MAX_CONCURRENT_DOWNLOADS = 4 + private const val SEGMENT_COUNT = 4 + private const val CHUNK_SIZE = 256 * 1024 + } + + private val logger = Logger.withTag("DownloadManager") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val fileSystem = FileSystem.SYSTEM + private val queueMutex = Mutex() + + private val httpClient = httpClientFactory.create { + install(HttpTimeout) { + requestTimeoutMillis = 300_000 + connectTimeoutMillis = 30_000 + } + expectSuccess = false + } + + private val downloadsMap = MutableStateFlow>(emptyMap()) + + val downloadsFlow: StateFlow> = downloadsMap + .map { map -> map.values.sortedByDescending { it.id } } + .stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList()) + + private val cachedDownloadFolder = MutableStateFlow(null) + + /** Where downloads are saved right now: the user's override, else the platform default. */ + val downloadFolder: StateFlow = cachedDownloadFolder + .map { it?.takeIf(String::isNotBlank) ?: paths.getUserDownloadsDirPath() } + .stateIn(scope, SharingStarted.Eagerly, paths.getUserDownloadsDirPath()) + + private val activeJobs = mutableMapOf() + + init { + scope.launch { + settingsRepository.userSettings.collect { settings -> + cachedDownloadFolder.value = settings.overloadedDownloadFolder + } + } + } + + fun enqueue(track: MetadataTrack) { + val timestamp = Clock.System.now().toEpochMilliseconds() + val id = "${track.id}_$timestamp" + val artistNames = track.artists.joinToString(", ") { it.name } + + val item = DownloadItem( + id = id, + title = track.title, + artists = artistNames, + album = track.album?.title, + track = track) + + downloadsMap.update { it + (id to item) } + val settings = settingsRepository.userSettings.value + logger.i { + "[Download $id] enqueued '${track.title}' by $artistNames (track=${track.id}) " + + "downloadPrefs=${settings.downloadMusicFormat.codec}/${settings.downloadMusicFormat.container} " + + "${settings.downloadMusicQuality} streamingPrefs=${settings.streamingMusicFormat.codec}/" + + "${settings.streamingMusicFormat.container} ${settings.streamingMusicQuality} folder=${downloadFolder.value}" + } + scope.launch { processQueue() } + } + + fun cancel(id: String) { + activeJobs[id]?.cancel() + activeJobs.remove(id) + downloadsMap.update { map -> + map[id]?.let { item -> + map + (id to item.copy(status = DownloadStatus.Cancelled)) + } ?: map + } + cleanupTempFiles(id) + } + + fun retry(id: String) { + val item = downloadsMap.value[id] ?: return + if (item.status !is DownloadStatus.Failed && item.status != DownloadStatus.Cancelled) return + + downloadsMap.update { map -> + map + (id to item.copy( + status = DownloadStatus.Queued, + progress = 0f, + downloadedBytes = 0L, + errorMessage = null)) + } + scope.launch { processQueue() } + } + + fun remove(id: String) { + cancel(id) + downloadsMap.update { it - id } + } + + fun clearCompleted() { + downloadsMap.update { map -> + map.filter { it.value.status != DownloadStatus.Completed } + } + } + + private suspend fun processQueue() { + queueMutex.withLock { + while (true) { + val activeCount = activeJobs.values.count { it.isActive } + if (activeCount >= MAX_CONCURRENT_DOWNLOADS) break + + val nextItem = + downloadsMap.value.values.firstOrNull { it.status == DownloadStatus.Queued } + ?: break + + val id = nextItem.id + downloadsMap.update { map -> + map + (id to nextItem.copy(status = DownloadStatus.Downloading)) + } + + val job = scope.launch { executeDownload(nextItem) } + activeJobs[id] = job + } + } + } + + private suspend fun executeDownload(item: DownloadItem) { + val track = item.track ?: run { + logger.e { "Download item has no track metadata" } + val noMetadataMessage = getString(Res.string.downloads_error_no_metadata) + downloadsMap.update { map -> + map[item.id]?.let { + map + (item.id to it.copy( + status = DownloadStatus.Failed(noMetadataMessage), + errorMessage = noMetadataMessage)) + } ?: map + } + return + } + + try { + logger.i { "[Download ${item.id}] resolving stream for '${track.title}'" } + val settings = settingsRepository.userSettings.value + val streamInfo = streamingUrlRepository.resolveStreamInfo( + track, + request = StreamRequest( + format = settings.downloadMusicFormat, + quality = settings.downloadMusicQuality, + // A manifest URL would be saved as-is instead of the audio it points to. + progressiveOnly = true)) + if (streamInfo == null) { + logger.w { "Could not resolve stream URL for ${track.title}" } + val unresolvedStatus = getString(Res.string.downloads_error_stream_unresolved_short) + val unresolvedMessage = getString(Res.string.downloads_error_stream_unresolved) + downloadsMap.update { map -> + map[item.id]?.let { + map + (item.id to it.copy( + status = DownloadStatus.Failed(unresolvedStatus), + errorMessage = unresolvedMessage)) + } ?: map + } + return + } + + val url = streamInfo.url + logResolvedStream(item, streamInfo) + + val filename = buildFilename(item, streamInfo) + + val downloadDir = resolveDownloadDir() + fileSystem.createDirectories(downloadDir) + + val tempDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "spotube_downloads".toPath() + fileSystem.createDirectories(tempDir) + + val (totalSize, supportsRanges) = probeUrl(item.id, url) + + if (totalSize > 0 && supportsRanges) { + logger.i { "[Download ${item.id}] multi-segment download ($SEGMENT_COUNT segments, $totalSize bytes) -> ${downloadDir / filename}" } + executeMultiSegmentDownload(url, filename, item, tempDir, downloadDir, totalSize) + } else { + logger.i { "[Download ${item.id}] single-connection download (size=$totalSize ranges=$supportsRanges) -> ${downloadDir / filename}" } + executeSingleConnectionDownload(url, filename, item, tempDir, downloadDir) + } + + val finalPath = downloadDir / filename.toPath() + val writtenBytes = fileSystem.metadataOrNull(finalPath)?.size + logger.i { "[Download ${item.id}] wrote $finalPath ($writtenBytes bytes, expected $totalSize)" } + if (totalSize > 0 && writtenBytes != null && writtenBytes != totalSize) { + logger.w { "[Download ${item.id}] SIZE MISMATCH: wrote $writtenBytes bytes, server reported $totalSize" } + } + + applyMetadata(item, filename, downloadDir) + // Without this Android music players and the Files app's Audio view don't see the + // file until the next full media scan. + addToMediaLibrary((downloadDir / filename.toPath()).toString()) + logger.i { "[Download ${item.id}] completed '${item.title}'" } + + downloadsMap.update { map -> + map[item.id]?.let { + map + (item.id to it.copy(status = DownloadStatus.Completed, progress = 1f)) + } ?: map + } + } catch (e: CancellationException) { + logger.i { "[Download ${item.id}] cancelled" } + downloadsMap.update { map -> + map[item.id]?.let { + map + (item.id to it.copy(status = DownloadStatus.Cancelled)) + } ?: map + } + throw e + } catch (e: Exception) { + logger.e(e) { "[Download ${item.id}] FAILED '${item.title}': ${e.message}" } + val failureMessage = e.message ?: getString(Res.string.common_unknown_error) + downloadsMap.update { map -> + map[item.id]?.let { + map + (item.id to it.copy( + status = DownloadStatus.Failed(failureMessage), + errorMessage = e.message)) + } ?: map + } + } finally { + activeJobs.remove(item.id) + processQueue() + } + } + + private suspend fun executeMultiSegmentDownload( + url: String, + filename: String, + item: DownloadItem, + tempDir: Path, + downloadDir: Path, + totalSize: Long) { + val segmentSize = totalSize / SEGMENT_COUNT + val segments = (0 until SEGMENT_COUNT).map { i -> + val start = i * segmentSize + val end = if (i == SEGMENT_COUNT - 1) totalSize - 1 else (i + 1) * segmentSize - 1 + Segment(index = i, start = start, end = end) + } + + val segmentFiles = segments.map { segment -> + tempDir / "${item.id}_seg${segment.index}.tmp" + } + + coroutineScope { + segments.mapIndexed { index, segment -> + async { + downloadSegment(url, item, segment, segmentFiles[index], totalSize) + } + }.awaitAll() + } + + val finalPath = downloadDir / filename.toPath() + fileSystem.sink(finalPath).buffer().use { sink -> + for (segmentFile in segmentFiles) { + fileSystem.source(segmentFile).buffer().use { source -> + sink.writeAll(source) + } + } + } + + segmentFiles.forEach { fileSystem.delete(it) } + } + + private suspend fun downloadSegment( + url: String, + item: DownloadItem, + segment: Segment, + outputPath: Path, + totalSize: Long) { + val response = httpClient.get(url) { + headers { + append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}") + } + } + logger.d { + "[Download ${item.id}] segment ${segment.index} bytes=${segment.start}-${segment.end} -> " + + "HTTP ${response.status.value} type=${response.headers[HttpHeaders.ContentType]} " + + "range=${response.headers[HttpHeaders.ContentRange]}" + } + // expectSuccess = false, so without these checks an error page, or the whole file once per + // segment (a 200 to a range request), would be written into the "audio" file. + if (!response.status.isSuccess()) { + logger.w { "[Download ${item.id}] segment ${segment.index} got HTTP ${response.status}; failing download" } + throw DownloadException(getString(Res.string.downloads_error_http, response.status.value)) + } + if (response.status != HttpStatusCode.PartialContent) { + logger.w { "[Download ${item.id}] segment ${segment.index} got ${response.status} instead of 206; range ignored" } + throw DownloadException(getString(Res.string.downloads_error_range_ignored)) + } + val channel = response.bodyAsChannel() + + fileSystem.sink(outputPath).buffer().use { sink -> + val packet = channel.readRemaining() + val bytes = packet.readByteArray() + packet.close() + sink.write(bytes) + + updateProgress(item.id, bytes.size.toLong(), totalSize) + } + } + + private suspend fun executeSingleConnectionDownload( + url: String, + filename: String, + item: DownloadItem, + tempDir: Path, + downloadDir: Path) { + val tempFile = tempDir / "${item.id}.tmp" + + val response = httpClient.get(url) + val totalSize = response.contentLength() ?: 0L + logger.i { + "[Download ${item.id}] GET -> HTTP ${response.status.value} " + + "type=${response.headers[HttpHeaders.ContentType]} length=$totalSize" + } + if (!response.status.isSuccess()) { + logger.w { "[Download ${item.id}] GET got HTTP ${response.status}; failing download" } + throw DownloadException(getString(Res.string.downloads_error_http, response.status.value)) + } + rejectVideo(item.id, response.headers[HttpHeaders.ContentType]) + val channel = response.bodyAsChannel() + + fileSystem.sink(tempFile).buffer().use { sink -> + val packet = channel.readRemaining() + val bytes = packet.readByteArray() + packet.close() + sink.write(bytes) + + updateProgress(item.id, bytes.size.toLong(), totalSize) + } + + val finalPath = downloadDir / filename.toPath() + fileSystem.copy(tempFile, finalPath) + fileSystem.delete(tempFile) + } + + private fun updateProgress(id: String, bytesJustRead: Long, totalSize: Long) { + downloadsMap.update { map -> + map[id]?.let { current -> + val newDownloaded = current.downloadedBytes + bytesJustRead + val progress = if (totalSize > 0) { + (newDownloaded.toFloat() / totalSize.toFloat()).coerceIn(0f, 1f) + } else 0f + map + (id to current.copy( + progress = progress, + downloadedBytes = newDownloaded, + totalBytes = totalSize)) + } ?: map + } + } + + private suspend fun probeUrl(id: String, url: String): Pair { + return try { + val response = httpClient.head(url) + val contentLength = response.contentLength() ?: 0L + val acceptRanges = response.headers[HttpHeaders.AcceptRanges] + val supportsRanges = acceptRanges?.equals("bytes", ignoreCase = true) == true + val contentType = response.headers[HttpHeaders.ContentType] + logger.i { + "[Download $id] HEAD -> HTTP ${response.status.value} type=$contentType " + + "length=$contentLength acceptRanges=$acceptRanges" + } + rejectVideo(id, contentType) + Pair(contentLength, supportsRanges) + } catch (e: DownloadException) { + throw e + } catch (e: Exception) { + logger.w(e) { "[Download $id] HEAD request failed, falling back to single connection" } + Pair(0L, false) + } + } + + /** + * Some sources only offer muxed video (e.g. NewPipe falls back to video streams when a video + * has no audio-only ones). Saving that as a "song" is worse than failing with a clear reason. + */ + private suspend fun rejectVideo(id: String, contentType: String?) { + if (contentType?.startsWith("video/", ignoreCase = true) == true) { + logger.w { "[Download $id] server says this is VIDEO ($contentType), not audio; failing download" } + throw DownloadException(getString(Res.string.downloads_error_video_only)) + } + } + + private fun logResolvedStream(item: DownloadItem, info: StreamInfo) { + val settings = settingsRepository.userSettings.value + val wanted = settings.downloadMusicFormat + logger.i { + "[Download ${item.id}] resolved stream codec=${info.codec} container=${info.container} " + + "protocol=${info.protocol} ${urlSummary(info.url)}" + } + if (info.protocol != StreamProtocol.PROGRESSIVE) { + logger.w { + "[Download ${item.id}] stream is a ${info.protocol} MANIFEST; a plain GET saves the manifest, not audio" + } + } + if (!info.codec.equals(wanted.codec, ignoreCase = true) || !info.container.equals(wanted.container, ignoreCase = true)) { + logger.w { + "[Download ${item.id}] FORMAT FALLBACK: download setting is ${wanted.codec}/${wanted.container} " + + "${settings.downloadMusicQuality}, but the closest downloadable stream offered is ${info.codec}/${info.container}" + } + } + } + + private suspend fun applyMetadata(item: DownloadItem, filename: String, downloadDir: Path) { + val filePath = downloadDir / filename.toPath() + if (!fileSystem.exists(filePath)) { + logger.w { "[Download ${item.id}] skipping metadata: $filePath does not exist" } + return + } + logger.d { "[Download ${item.id}] writing metadata to $filePath" } + + val track = item.track ?: return + + val coverBytes = try { + val thumbnail = track.thumbnails?.maxByOrNull { it.width * it.height } + if (thumbnail != null) { + val response = httpClient.get(thumbnail.url) + val channel = response.bodyAsChannel() + val packet = channel.readRemaining() + val bytes = packet.readByteArray() + packet.close() + bytes + } else { + null + } + } catch (e: Exception) { + logger.w(e) { "Failed to download cover art for ${item.title}" } + null + } + + try { + uniffi.compose_app.writeAudioMetadata( + filePath = filePath.toString(), + title = item.title, + artists = item.artists, + album = item.album, + trackNumber = track.trackNumber, + discNumber = track.discNumber, + coverBytes = coverBytes) + } catch (e: Exception) { + logger.w(e) { "[Download ${item.id}] failed to write audio metadata for ${item.title}" } + } + } + + private fun resolveDownloadDir(): Path = downloadFolder.value.toPath() + + private fun cleanupTempFiles(id: String) { + try { + val tempDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "spotube_downloads".toPath() + if (fileSystem.exists(tempDir)) { + fileSystem.list(tempDir).forEach { file -> + if (file.name.startsWith(id)) { + fileSystem.delete(file) + } + } + } + } catch (e: Exception) { + logger.w(e) { "Failed to cleanup temp files for $id" } + } + } + + private fun buildFilename(downloadItem: DownloadItem, streamInfo: StreamInfo): String { + val safeTitle = downloadItem.title.sanitizeFilename() + val safeArtist = downloadItem.artists.sanitizeFilename() + val extension = fileExtension(streamInfo) + return "$safeArtist - $safeTitle.$extension" + } + + /** AAC in an MP4 container is audio-only, so give it the audio extension players expect. */ + private fun fileExtension(info: StreamInfo): String = + if (info.codec.equals("aac", ignoreCase = true) && info.container.equals("mp4", ignoreCase = true)) "m4a" + else info.container + + private fun String.sanitizeFilename(): String { + return this.replace(Regex("[^a-zA-Z0-9.\\-_' ]"), "_").take(200) + } + + private data class Segment(val index: Int, val start: Long, val end: Long) +} + +/** A download failure whose [message] is already localized and safe to show the user. */ +private class DownloadException(message: String) : Exception(message) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadProgressIcon.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadProgressIcon.kt new file mode 100644 index 00000000..4045940c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadProgressIcon.kt @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.downloads + + +import org.spotube.millennium.composables.MillenniumIcon +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun DownloadProgressIcon( + track: MetadataTrack?, + icon: ImageVector, + contentDescription: String, + modifier: Modifier = Modifier, + size: Dp = 24.dp, + strokeWidth: Dp = 2.dp, + tint: Color = AppTheme.colors.onSurface, + progressTint: Color = AppTheme.colors.primary) { + val downloadsViewModel = koinViewModel() + val downloads by downloadsViewModel.downloads.collectAsStateWithLifecycle() + + val currentDownload = track?.let { t -> + downloads.firstOrNull { item -> + item.track?.id == t.id && item.status is DownloadStatus.Downloading + } + } + + val progress by animateFloatAsState( + targetValue = currentDownload?.progress ?: 0f, + label = "download_progress" + ) + + if (currentDownload != null && progress > 0f) { + Box( + modifier = modifier.size(size), + contentAlignment = Alignment.Center + ) { + Canvas(Modifier.size(size)) { + val diameter = this.size.minDimension + val stroke = strokeWidth.toPx() + drawArc( + color = progressTint, + startAngle = -90f, + sweepAngle = 360f * progress, + useCenter = false, + style = Stroke(width = stroke), + topLeft = Offset(stroke / 2f, stroke / 2f), + size = Size(diameter - stroke, diameter - stroke)) + } + MillenniumIcon( + imageVector = icon, + contentDescription = contentDescription, + tint = tint, + modifier = Modifier.size(size * 0.6f)) + } + } else { + MillenniumIcon( + imageVector = icon, + contentDescription = contentDescription, + tint = tint, + modifier = modifier.size(size)) + } +} + +@Composable +fun DownloadBadgeIndicator( + modifier: Modifier = Modifier) { + val downloadsViewModel = koinViewModel() + val downloads by downloadsViewModel.downloads.collectAsStateWithLifecycle() + + val activeDownloads = downloads.count { it.status is DownloadStatus.Downloading } + + if (activeDownloads > 0) { + Box( + modifier = modifier + .size(8.dp) + .clip(CircleShape) + .background(AppTheme.colors.primary) + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt new file mode 100644 index 00000000..8bcee0fa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt @@ -0,0 +1,259 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.downloads + +import org.spotube.millennium.composables.MillenniumStripedProgress +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.component.cards.MediaArtwork +import org.spotube.spotube.core.ui.component.cards.MediaEmptyState +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* + +@Composable +fun DownloadsScreen( + modifier: Modifier = Modifier, + viewModel: DownloadsViewModel = koinViewModel(), + headerInAppBar: Boolean = false, + topContent: @Composable () -> Unit = {} +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Column(modifier.fillMaxSize()) { + if (headerInAppBar) { + ApplicationMainBar( + backButton = false, + title = { DownloadsToolbarTitle() }, + subtitle = { DownloadsToolbarSummary(state) }, + actions = { DownloadsToolbarActions(state, viewModel::clearCompleted) } + ) + topContent() + } + DownloadsContent( + state, + viewModel::cancel, + viewModel::retry, + viewModel::remove, + viewModel::clearCompleted, + Modifier.weight(1f) + .then(if (headerInAppBar) Modifier.padding(horizontal = 12.dp) else Modifier), + showHeader = !headerInAppBar + ) + } +} + +@Composable +internal fun DownloadsToolbarTitle() { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxDirectboxReceive, + null, + tint = AppTheme.colors.primary, + modifier = Modifier.size(20.dp) + ) + Text( + stringResource(Res.string.library_tab_downloads), + style = AppTheme.typography.titleMedium + ) + } +} + +@Composable +internal fun DownloadsToolbarSummary(state: DownloadsUiState) { + Column(Modifier.padding(start = 28.dp)) { + if (state.items.isNotEmpty()) Text( + stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount), + style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant + ) + // Always visible (empty list included), so users know where their files end up. + if (state.folder.isNotBlank()) Text( + stringResource(Res.string.downloads_saved_to, state.folder), + style = AppTheme.typography.bodySmall, color = MillenniumTheme.colors.muted, + maxLines = 2, overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +internal fun DownloadsToolbarActions(state: DownloadsUiState, onClearCompleted: () -> Unit) { + if (state.canClearCompleted) MillenniumButton( + onClick = onClearCompleted, + style = MillenniumButtonStyle.Secondary + ) { + Text( + stringResource(Res.string.downloads_clear_completed), + style = AppTheme.typography.labelMedium + ) + } +} + +@Composable +internal fun DownloadsContent( + state: DownloadsUiState, + onCancel: (String) -> Unit, + onRetry: (String) -> Unit, + onRemove: (String) -> Unit, + onClearCompleted: () -> Unit, + modifier: Modifier = Modifier, + showHeader: Boolean = true +) { + val inset = LocalAppShellBottomInset.current + Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Column(Modifier.widthIn(max = 960.dp).fillMaxSize()) { + if (showHeader) Row( + Modifier.fillMaxWidth().padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Column(Modifier.weight(1f)) { + DownloadsToolbarTitle() + DownloadsToolbarSummary(state) + } + DownloadsToolbarActions(state, onClearCompleted) + } + if (state.items.isEmpty()) MediaEmptyState( + Iconsax.IconsaxDirectboxReceive, + stringResource(Res.string.downloads_empty), + Modifier.padding(bottom = inset) + ) + else LazyColumn( + Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues( + top = if (showHeader) 0.dp else 16.dp, + bottom = inset + 16.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.items, key = { it.id }) { row -> + DownloadListRow( + row, + { onCancel(row.id) }, + { onRetry(row.id) }, + { onRemove(row.id) }) + } + } + } + } +} + +@Composable +internal fun DownloadListRow( + item: DownloadRowUiState, + onCancel: () -> Unit, + onRetry: () -> Unit, + onRemove: () -> Unit +) { + val colors = MillenniumTheme.colors + Row( + Modifier.fillMaxWidth().millenniumSurface( + background = millenniumPanelBrush(colors), + shape = RoundedCornerShape(8.dp), + borderColor = colors.border, + borderWidth = 1.dp, + innerGlowColor = colors.highlight.copy(alpha = 0.2f) + ).padding(12.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + MediaArtwork(item.artworkUrl, item.title, Modifier.size(52.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + item.title, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + item.artists, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + when (item.status) { + DownloadStatus.Downloading -> stringResource( + Res.string.downloads_status_downloading, + item.percent + ) + + DownloadStatus.Queued -> stringResource(Res.string.downloads_status_queued) + DownloadStatus.Completed -> stringResource(Res.string.downloads_status_completed) + DownloadStatus.Cancelled -> stringResource(Res.string.downloads_status_cancelled) + is DownloadStatus.Failed -> if (item.error != null) stringResource( + Res.string.downloads_status_failed_error, + item.error + ) else stringResource(Res.string.downloads_status_failed) + }, + style = AppTheme.typography.labelSmall, + color = when (item.status) { + is DownloadStatus.Failed -> AppTheme.colors.error + DownloadStatus.Completed -> AppTheme.colors.primary + else -> AppTheme.colors.onSurfaceVariant + }, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + if (item.status == DownloadStatus.Downloading) MillenniumStripedProgress( + progress = item.progress, + modifier = Modifier.fillMaxWidth(), + height = 6.dp + ) + } + MillenniumIconButton( + onClick = when (item.action) { + DownloadRowAction.Cancel -> onCancel; DownloadRowAction.Retry -> onRetry; DownloadRowAction.Remove -> onRemove + }, + modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + when (item.action) { + DownloadRowAction.Cancel -> Iconsax.IconsaxCloseSquare; DownloadRowAction.Retry -> Iconsax.IconsaxRefreshRight; DownloadRowAction.Remove -> Iconsax.IconsaxTrash + }, + stringResource( + when (item.action) { + DownloadRowAction.Cancel -> Res.string.downloads_action_cancel; DownloadRowAction.Retry -> Res.string.downloads_action_retry; DownloadRowAction.Remove -> Res.string.downloads_action_remove + } + ) + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt new file mode 100644 index 00000000..8ee69171 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.downloads + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.combine + +enum class DownloadRowAction { Cancel, Retry, Remove } + +data class DownloadRowUiState( + val id: String, + val title: String, + val artists: String, + val artworkUrl: String?, + val status: DownloadStatus, + val progress: Float, + val percent: Int, + val error: String?, + val action: DownloadRowAction +) + +data class DownloadsUiState( + val items: List = emptyList(), + val activeCount: Int = 0, + val completedCount: Int = 0, + val canClearCompleted: Boolean = false, + /** Folder new downloads are saved to, shown so users can find their files. */ + val folder: String = "" +) + +internal fun downloadsUiState(items: List, folder: String = ""): DownloadsUiState { + val completedCount = items.count { it.status == DownloadStatus.Completed } + return DownloadsUiState( + items = items.map { item -> + val progress = item.progress.takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: 0f + DownloadRowUiState( + item.id, item.title, item.artists, + (item.track?.album?.thumbnails + ?: item.track?.thumbnails)?.firstOrNull()?.url?.takeIf { it.isNotBlank() }, + item.status, progress, (progress * 100).toInt(), + item.errorMessage ?: (item.status as? DownloadStatus.Failed)?.error, + when (item.status) { + DownloadStatus.Completed -> DownloadRowAction.Remove + is DownloadStatus.Failed, DownloadStatus.Cancelled -> DownloadRowAction.Retry + else -> DownloadRowAction.Cancel + } + ) + }, + activeCount = items.count { it.status == DownloadStatus.Downloading || it.status == DownloadStatus.Queued }, + completedCount = completedCount, + canClearCompleted = completedCount > 0, + folder = folder + ) +} + +class DownloadsViewModel( + private val downloadManager: DownloadManager +) : ViewModel() { + val downloads: StateFlow> = downloadManager.downloadsFlow.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyList() + ) + + val uiState = combine(downloadManager.downloadsFlow, downloadManager.downloadFolder, ::downloadsUiState) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState()) + + fun downloadTrack(track: MetadataTrack) { + downloadManager.enqueue(track) + } + + fun downloadTracks(tracks: List) { + tracks.forEach { track -> + downloadManager.enqueue(track) + } + } + + fun cancel(id: String) { + downloadManager.cancel(id) + } + + fun retry(id: String) { + downloadManager.retry(id) + } + + fun remove(id: String) { + downloadManager.remove(id) + } + + fun clearCompleted() { + downloadManager.clearCompleted() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.kt new file mode 100644 index 00000000..75340bb2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.downloads + +/** Tells the OS media index about a finished download so other apps can see it right away. */ +internal expect fun addToMediaLibrary(path: String) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt new file mode 100644 index 00000000..2a8a8944 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt @@ -0,0 +1,465 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.home + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumPullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.ui.component.AlbumCard +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.component.ArtistCard +import org.spotube.spotube.core.ui.component.ErrorDisplay +import org.spotube.spotube.core.ui.component.PlaylistCard +import org.spotube.spotube.core.ui.component.TrackCard +import org.spotube.spotube.core.ui.component.UserCard +import org.spotube.spotube.core.ui.component.FeaturedCarousel +import org.spotube.spotube.core.ui.component.VerticalScrollbar +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.core.ui.component.dragScrollable +import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.shell.ConnectStatusBanner +import org.spotube.spotube.resources.iconsax.IconsaxSoundTwotone +import org.spotube.spotube.getPlatform +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.modules.support.LocalSupportPrompt +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.support_button_label +import spotube.composeapp.generated.resources.devices_title +import spotube.composeapp.generated.resources.home_title_browse +import spotube.composeapp.generated.resources.jam_screen_title +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import org.jetbrains.compose.resources.Font +import org.spotube.millennium.adaptive.WindowBreakpoints +import org.spotube.spotube.resources.iconsax.IconsaxMirrorScreenRegular +import spotube.composeapp.generated.resources.cookie_regular + +@Composable +fun HomeScreen(viewModel: HomeScreenViewModel) { + val navigationCommands = koinInject() + val openSupport = LocalSupportPrompt.current + val isSmallScreen = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium + val state by viewModel.uiState.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + + val selectedGenreId = when (val s = state) { + is HomeScreenState.Data -> s.selectedGenreId + else -> null + } + + LaunchedEffect(selectedGenreId) { + listState.scrollToItem(0) + } + + LaunchedEffect(listState) { + snapshotFlow { listState.layoutInfo } + .map { layoutInfo -> + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index + val totalItems = layoutInfo.totalItemsCount + Pair(lastVisibleIndex, totalItems) + } + .distinctUntilChanged() + .collect { (lastVisibleIndex, totalItems) -> + if ( + lastVisibleIndex != null && + totalItems > 0 && + lastVisibleIndex >= totalItems - 2 + ) { + viewModel.loadMoreData() + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) + ) { + ApplicationMainBar( + backButton = false, + title = { + if (isSmallScreen) { + Text( + "Spotube", + style = AppTheme.typography.headlineLarge.copy( + fontFamily = FontFamily( + listOf(Font(Res.font.cookie_regular, weight = FontWeight.Normal)) + ) + ), + color = AppTheme.colors.onSurface + ) + } else { + Text(stringResource(Res.string.home_title_browse)) + } + + }, + actions = { + if (isSmallScreen) { + MillenniumIconButton( + onClick = { navigationCommands.navigateTo(Routes.Devices) }, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxMirrorScreenRegular, + contentDescription = stringResource(Res.string.devices_title) + ) + } + MillenniumIconButton( + onClick = { navigationCommands.navigateTo(Routes.Jam) }, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxSoundTwotone, + contentDescription = stringResource(Res.string.jam_screen_title) + ) + } + val supportLabel = stringResource(Res.string.support_button_label) + MillenniumIconButton( + onClick = openSupport, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxHeart2, + contentDescription = supportLabel + ) + } + } + }) + if (isSmallScreen) { + ConnectStatusBanner( + onOpenRemote = { navigationCommands.navigateTo(Routes.RemoteControl) }, + onOpenJam = { navigationCommands.navigateTo(Routes.Jam) } + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f) + ) { + if (!isSmallScreen) { + HomeContent( + listState = listState, + state = state, + onRetry = { viewModel.refresh() }, + onGenreSelected = { viewModel.selectGenre(it) }) + } else { + MillenniumPullToRefreshBox( + isRefreshing = state is HomeScreenState.Loading, + onRefresh = { + viewModel.refresh() + }, + modifier = Modifier.fillMaxSize() + ) { + HomeContent( + listState = listState, + state = state, + onRetry = { viewModel.refresh() }, + onGenreSelected = { viewModel.selectGenre(it) }) + } + } + + VerticalScrollbar( + listState = listState, + modifier = Modifier + .align(Alignment.CenterEnd) + ) + } + } +} + +@Composable +private fun HomeContent( + listState: androidx.compose.foundation.lazy.LazyListState, + state: HomeScreenState, + onRetry: () -> Unit, + onGenreSelected: (String) -> Unit +) { + val shellBottomInset = LocalAppShellBottomInset.current + val contentPadding = remember(shellBottomInset) { + PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + when (state) { + is HomeScreenState.Loading -> { + item { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + repeat(3) { + SkeletonTree(true) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .padding(horizontal = 16.dp) + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + items(4) { + PlayableCard( + title = "Item Title", + subtitle = "Subtitle", + imageURL = "https://placehold.co/600x400" + ) + } + } + } + } + } + } + } + } + + is HomeScreenState.Error -> { + item { + ErrorDisplay( + errorMessage = state.message, + onRetry = onRetry + ) + } + } + + is HomeScreenState.Data -> { + if (state.featuredItems.isNotEmpty()) { + item { + FeaturedCarousel(items = state.featuredItems) + } + } + + if (state.genres.isNotEmpty()) { + item { + GenreTabs( + genres = state.genres, + selectedGenreId = state.selectedGenreId, + onGenreSelected = onGenreSelected + ) + } + } + + val currentSections = state.selectedGenreId?.let { state.browseSections[it] } + + if (currentSections == null) { + item { + SkeletonTree(true) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .padding(horizontal = 16.dp) + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + items(4) { + PlayableCard( + title = "Item Title", + subtitle = "Subtitle", + imageURL = "https://placehold.co/600x400" + ) + } + } + } + } + } + } else { + items(currentSections) { section -> + HomeSection( + title = section.title, + subtitle = section.description, + items = section.items + ) + } + } + + if (state is HomeScreenState.Data.LoadingMore) { + item { + SkeletonTree(true) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .padding(horizontal = 16.dp) + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + items(4) { + PlayableCard( + title = "Item Title", + subtitle = "Subtitle", + imageURL = "https://placehold.co/600x400" + ) + } + } + } + } + } + } + } + } + + } +} + +@Composable +private fun HomeSection( + title: String, + subtitle: String? = null, + items: List, + modifier: Modifier = Modifier +) { + val rowState = rememberLazyListState() + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (!subtitle.isNullOrEmpty()) Text( + text = subtitle, + style = AppTheme.typography.labelMedium.copy( + color = AppTheme.colors.secondary, + fontWeight = FontWeight.Medium + ), + modifier = Modifier.padding(horizontal = 16.dp) + ) + Text( + text = title, + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-12).dp) + ) + + LazyRow( + state = rowState, + modifier = Modifier.dragScrollable(rowState), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + items(items) { browseItem -> + when (browseItem) { + is MetadataBrowseItem.Album -> AlbumCard(album = browseItem.data) + is MetadataBrowseItem.Artist -> ArtistCard(artist = browseItem.data) + is MetadataBrowseItem.Playlist -> PlaylistCard(playlist = browseItem.data) + is MetadataBrowseItem.Track -> TrackCard(track = browseItem.data) + is MetadataBrowseItem.User -> UserCard(user = browseItem.data) + } + } + } + } +} + +@Composable +private fun GenreTabs( + genres: List, + selectedGenreId: String?, + onGenreSelected: (String) -> Unit +) { + val rowState = rememberLazyListState() + + LazyRow( + state = rowState, + modifier = Modifier.dragScrollable(rowState), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(genres) { genre -> + MillenniumButton( + onClick = { onGenreSelected(genre.id) }, + style = if (selectedGenreId == genre.id) { + MillenniumButtonStyle.Primary + } else { + MillenniumButtonStyle.Secondary + } + ) { + Text(genre.name) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenRepository.kt new file mode 100644 index 00000000..8c981e73 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenRepository.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.home + +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.spotube.modules.plugin.PluginManager +import io.github.reactivecircus.cache4k.Cache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeScreenRepository( + private val pluginManager: PluginManager +) { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + val plugin + get() = pluginManager.selectedMetadataPlugin.value + + private val featuredItemCache = Cache.Builder>().build() + private val genresCache = Cache.Builder>().build() + private val browseItemCache = + Cache.Builder, PaginationResult>() + .build() + private val sublistItemCache = + Cache.Builder, PaginationResult>() + .build() + + init { + scope.launch { + pluginManager.selectedMetadataPlugin + .filterNotNull() + .flatMapLatest { it.loggedInFlow } + .distinctUntilChanged() + .collect { + invalidateCaches() + } + } + } + + fun invalidateCaches() { + featuredItemCache.invalidateAll() + genresCache.invalidateAll() + browseItemCache.invalidateAll() + sublistItemCache.invalidateAll() + } + + suspend fun featuredItems() = plugin?.let { plugin -> + featuredItemCache.get("featured_items") { + pluginManager.withScope { + plugin.use { + metadataBrowseAPI.featured() + } + } + } + } + + suspend fun genres() = plugin?.let { plugin -> + genresCache.get("genres") { + pluginManager.withScope { + plugin.use { + metadataBrowseAPI.genres() + } + } + } + } + + suspend fun list(genreId: String, paginationStrategy: PaginationStrategy? = null) = + plugin?.let { plugin -> + browseItemCache.get( + key = genreId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20)) + ) { + pluginManager.withScope { + plugin.use { + metadataBrowseAPI.list(genreId, paginationStrategy) + } + } + } + } + + suspend fun sublist( + genreId: String, + parentId: String, + paginationStrategy: PaginationStrategy? = null + ) = plugin?.let { plugin -> + sublistItemCache.get( + "$genreId/$parentId" to (paginationStrategy ?: PaginationStrategy.Offset( + 0, + 20 + )) + ) { + pluginManager.withScope { + plugin.use { + metadataBrowseAPI.sublist(genreId, parentId, paginationStrategy) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenViewModel.kt new file mode 100644 index 00000000..6e0e1397 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenViewModel.kt @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.plugin.PluginManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error + +sealed interface HomeScreenState { + data object Loading : HomeScreenState + + sealed interface Data : HomeScreenState { + val featuredItems: List + val genres: List + val selectedGenreId: String? + val browseSections: Map?> + val paginationStrategies: Map + + data class Loaded( + override val featuredItems: List, + override val genres: List, + override val selectedGenreId: String?, + override val browseSections: Map?>, + override val paginationStrategies: Map) : Data { + fun toLoadingMore(): LoadingMore = LoadingMore( + featuredItems = featuredItems, + genres = genres, + selectedGenreId = selectedGenreId, + browseSections = browseSections, + paginationStrategies = paginationStrategies) + } + + data class LoadingMore( + override val featuredItems: List, + override val genres: List, + override val selectedGenreId: String?, + override val browseSections: Map?>, + override val paginationStrategies: Map) : Data + } + + data class Error(val message: String) : HomeScreenState +} + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeScreenViewModel( + private val repository: HomeScreenRepository, private val pluginManager: PluginManager +) : ViewModel(), KoinComponent { + private val logger by injectLogger() + + private val state = MutableStateFlow(HomeScreenState.Loading) + val uiState = state.asStateFlow() + + init { + viewModelScope.launch { + pluginManager.selectedMetadataPlugin + .filterNotNull() + .flatMapLatest { it.loggedInFlow } + .distinctUntilChanged().collect { + loadInitialData() + } + } + } + + private suspend fun loadInitialData() = runCatching { + state.value = HomeScreenState.Loading + val featuredItems = repository.featuredItems() ?: emptyList() + val genres = repository.genres() ?: emptyList() + val firstGenreId = genres.firstOrNull()?.id + val browseSections = mutableMapOf?>() + val paginationStrategies = mutableMapOf() + if (firstGenreId != null) { + val result = repository.list(firstGenreId) + browseSections[firstGenreId] = result?.items ?: emptyList() + paginationStrategies[firstGenreId] = result?.nextPagination + } + state.value = HomeScreenState.Data.Loaded( + featuredItems = featuredItems, + genres = genres, + selectedGenreId = firstGenreId, + browseSections = browseSections, + paginationStrategies = paginationStrategies) + }.onFailure { e -> + logger.e(e) { "Failed to load home screen data" } + state.value = HomeScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + + fun selectGenre(genreId: String) { + val currentState = state.value + if (currentState is HomeScreenState.Data) { + state.value = when (currentState) { + is HomeScreenState.Data.Loaded -> currentState.copy(selectedGenreId = genreId) + is HomeScreenState.Data.LoadingMore -> currentState.copy(selectedGenreId = genreId) + } + if (currentState.browseSections[genreId] == null) { + viewModelScope.launch { + val result = repository.list(genreId) + val current = state.value + if (current is HomeScreenState.Data) { + state.value = HomeScreenState.Data.Loaded( + featuredItems = current.featuredItems, + genres = current.genres, + selectedGenreId = genreId, + browseSections = current.browseSections + (genreId to (result?.items ?: emptyList())), + paginationStrategies = current.paginationStrategies + (genreId to result?.nextPagination)) + } + } + } + } + } + + suspend fun loadMoreData() = runCatching { + val currentState = state.value + if (currentState is HomeScreenState.Data.Loaded) { + val genreId = currentState.selectedGenreId ?: return@runCatching + val pagination = currentState.paginationStrategies[genreId] ?: return@runCatching + state.value = currentState.toLoadingMore() + val result = repository.list(genreId, pagination) + val existingSections = currentState.browseSections[genreId] ?: emptyList() + state.value = HomeScreenState.Data.Loaded( + featuredItems = currentState.featuredItems, + genres = currentState.genres, + selectedGenreId = genreId, + browseSections = currentState.browseSections + (genreId to (existingSections + (result?.items ?: emptyList()))), + paginationStrategies = currentState.paginationStrategies + (genreId to result?.nextPagination)) + } + }.onFailure { e -> + logger.e(e) { "Failed to load more home screen data" } + state.value = HomeScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + + fun refresh() { + viewModelScope.launch { + repository.invalidateCaches() + loadInitialData() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt new file mode 100644 index 00000000..568a018b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt @@ -0,0 +1,490 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.jam + +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import org.spotube.spotube.resources.iconsax.IconsaxUserRemove +import org.spotube.spotube.resources.iconsax.IconsaxForbidden +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxSetting2 +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSegmentedButton +import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults +import org.spotube.millennium.composables.MillenniumSegmentedButtonRow +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.composables.MillenniumTooltip +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* + +@Composable +fun JamScreen(navigationCommands: NavigationCommands) { + val viewModel = koinViewModel() + val state by viewModel.uiState.collectAsStateWithLifecycle() + val clipboard = LocalClipboardManager.current + val shellBottomInset = LocalAppShellBottomInset.current + + Column(Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) { + ApplicationMainBar(title = { Text(stringResource(Res.string.jam_screen_title)) }) + Column( + Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 20.dp, bottom = shellBottomInset + 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + Modifier.widthIn(max = 520.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + state.error?.let { error -> + JamPanel { + Text( + error, + color = AppTheme.colors.error, + style = AppTheme.typography.bodySmall + ) + MillenniumButton( + onClick = viewModel::clearError, + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.jam_error_dismiss)) + } + } + } + if (state.isActive) { + SessionPanel( + state = state, + onCopy = { + clipboard.setText(AnnotatedString(state.roomCode.orEmpty())) + viewModel.onCodeCopied() + }, + onShare = viewModel::shareRoomCode, + onLeave = viewModel::leave, + onKick = viewModel::kickParticipant, + onBan = viewModel::banParticipant, + onMenuChange = viewModel::showParticipantMenu + ) + } else { + SetupPanel( + state, + viewModel::selectSetupMode, + viewModel::createSession, + viewModel::joinSession + ) + } + BrokerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) } + } + } + } +} + +@Composable +private fun JamPanel(content: @Composable ColumnScope.() -> Unit) { + Column( + Modifier.fillMaxWidth().millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(10.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + innerGlowColor = MillenniumTheme.colors.highlight.copy(alpha = 0.25f) + ).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + content = content + ) +} + +@Composable +internal fun SetupPanel( + state: JamUiState, + onMode: (JamSetupMode) -> Unit, + onCreate: () -> Unit, + onJoin: () -> Unit +) { + JamPanel { + MillenniumSegmentedButtonRow(Modifier.fillMaxWidth()) { + MillenniumSegmentedButton( + selected = state.setupMode == JamSetupMode.Create, + onClick = { onMode(JamSetupMode.Create) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2) + ) { Text(stringResource(Res.string.jam_setup_create_tab)) } + MillenniumSegmentedButton( + selected = state.setupMode == JamSetupMode.Join, + onClick = { onMode(JamSetupMode.Join) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2) + ) { Text(stringResource(Res.string.jam_setup_join_tab)) } + } + if (state.setupMode == JamSetupMode.Create) { + SharedRoomIllustration(Modifier.fillMaxWidth().height(100.dp)) + Text( + stringResource(Res.string.jam_setup_create_hint), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } else { + MillenniumTextField( + state = state.codeInput, + label = stringResource(Res.string.jam_setup_code_label), + placeholder = stringResource(Res.string.jam_setup_code_placeholder), + enabled = !state.isBusy, + textStyle = AppTheme.typography.headlineSmall.copy( + fontFamily = FontFamily.Monospace, + letterSpacing = 4.sp + ), + modifier = Modifier.fillMaxWidth() + ) + } + MillenniumButton( + onClick = if (state.setupMode == JamSetupMode.Create) onCreate else onJoin, + enabled = if (state.setupMode == JamSetupMode.Create) state.canCreate else state.canJoin, + loading = state.isBusy, + modifier = Modifier.fillMaxWidth() + ) { + Text( + stringResource( + when { + state.setupMode == JamSetupMode.Create -> Res.string.jam_setup_create_button + else -> Res.string.jam_setup_join_button + } + ) + ) + } + } +} + +/** A small media-player diagram: one source feeding two listeners. No stock artwork. */ +@Composable +private fun SharedRoomIllustration(modifier: Modifier) { + val colors = MillenniumTheme.colors + Canvas(modifier) { + val scale = minOf(size.width / 260f, size.height / 100f) + val origin = Offset((size.width - 260f * scale) / 2f, (size.height - 100f * scale) / 2f) + fun point(x: Float, y: Float) = origin + Offset(x * scale, y * scale) + val stroke = 1.2f * scale + val metal = Brush.verticalGradient( + listOf(colors.raised, colors.surface), + startY = origin.y, + endY = origin.y + 100f * scale + ) + drawLine(colors.border, point(45f, 50f), point(215f, 50f), 2f * scale) + drawLine(colors.accent.copy(alpha = 0.7f), point(45f, 49f), point(215f, 49f), stroke) + // Two miniature speakers, with an accent power LED and concentric drivers. + for (x in listOf(15f, 207f)) { + drawRoundRect( + metal, + point(x, 18f), + Size(38f * scale, 64f * scale), + CornerRadius(5f * scale) + ) + drawRoundRect( + colors.border, + point(x, 18f), + Size(38f * scale, 64f * scale), + CornerRadius(5f * scale), + style = Stroke(stroke) + ) + drawCircle(colors.background, 10f * scale, point(x + 19f, 57f)) + drawCircle(colors.border, 10f * scale, point(x + 19f, 57f), style = Stroke(stroke)) + drawCircle(colors.raised, 5f * scale, point(x + 19f, 57f)) + drawCircle(colors.background, 4f * scale, point(x + 19f, 34f)) + drawCircle(colors.accent, 1.5f * scale, point(x + 19f, 74f)) + } + // Brushed-metal source unit with a backlit equalizer display. + drawRoundRect( + metal, + point(82f, 10f), + Size(96f * scale, 80f * scale), + CornerRadius(8f * scale) + ) + drawRoundRect( + colors.border, + point(82f, 10f), + Size(96f * scale, 80f * scale), + CornerRadius(8f * scale), + style = Stroke(stroke) + ) + drawRoundRect( + colors.highlight.copy(alpha = 0.25f), + point(86f, 13f), + Size(88f * scale, 1f * scale), + CornerRadius(scale) + ) + drawRoundRect( + colors.background, + point(92f, 22f), + Size(76f * scale, 30f * scale), + CornerRadius(3f * scale) + ) + val bars = listOf(8f, 16f, 12f, 22f, 18f, 10f, 15f, 7f) + bars.forEachIndexed { index, height -> + drawRoundRect( + colors.accent.copy(alpha = 0.85f), + point(99f + index * 8f, 47f - height), + Size(4f * scale, height * scale), + CornerRadius(scale) + ) + } + drawCircle(colors.background, 11f * scale, point(130f, 70f)) + drawCircle(colors.border, 11f * scale, point(130f, 70f), style = Stroke(stroke)) + drawPath(Path().apply { + moveTo(point(127f, 65f).x, point(127f, 65f).y) + lineTo(point(135f, 70f).x, point(135f, 70f).y) + lineTo(point(127f, 75f).x, point(127f, 75f).y) + close() + }, colors.text) + for (x in listOf(101f, 159f)) drawRoundRect( + colors.border, + point(x - 4f, 68f), + Size(8f * scale, 4f * scale), + CornerRadius(scale) + ) + } +} + +@Composable +internal fun SessionPanel( + state: JamUiState, + onCopy: () -> Unit, + onShare: () -> Unit, + onLeave: () -> Unit, + onKick: (String) -> Unit, + onBan: (String) -> Unit, + onMenuChange: (String?) -> Unit +) { + JamPanel { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + Modifier.size(7.dp).background( + if (state.isConnected) AppTheme.colors.primary else AppTheme.colors.onSurfaceVariant, + CircleShape + ) + ) + Text( + stringResource(if (state.isConnected) Res.string.jam_session_connected else Res.string.jam_session_connecting), + style = AppTheme.typography.labelMedium + ) + } + Column( + Modifier.fillMaxWidth().millenniumSurface( + background = SolidColor(MillenniumTheme.colors.background), + shape = RoundedCornerShape(5.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + stringResource(Res.string.jam_setup_code_label), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + SelectionContainer { + Text( + state.roomCode.orEmpty(), + style = AppTheme.typography.headlineMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + letterSpacing = 5.sp, + color = AppTheme.colors.primary + ) + } + } + if (state.isHost) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumButton( + onClick = onCopy, + modifier = Modifier.weight(1f), + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(if (state.codeCopied) Res.string.jam_invite_copied else Res.string.jam_invite_copy)) + } + if (state.supportsNativeShare) MillenniumButton( + onClick = onShare, + modifier = Modifier.weight(1f), + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.jam_invite_share)) + } + } + } + MillenniumSeparator() + Text( + stringResource(Res.string.jam_participants_heading, state.participants.size), + style = AppTheme.typography.titleSmall + ) + Column( + Modifier.fillMaxWidth().millenniumSurface( + background = SolidColor(MillenniumTheme.colors.background), + shape = RoundedCornerShape(5.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ).padding(10.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (state.participants.isEmpty()) Text( + stringResource(Res.string.jam_participants_waiting), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + state.participants.forEach { participant -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + Modifier.size(32.dp).millenniumSurface( + background = millenniumBevelBrush(MillenniumTheme.colors), + shape = CircleShape, + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ), contentAlignment = Alignment.Center + ) { + Text(participant.initial, style = AppTheme.typography.labelMedium) + } + Column(Modifier.weight(1f)) { + Text( + participant.displayName, + style = AppTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + stringResource(if (participant.isHost) Res.string.jam_participants_host else Res.string.jam_participants_guest), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + if (participant.canModerate) { + // Keep destructive moderation out of the participant's primary row. + org.spotube.millennium.composables.MillenniumDropdownMenu( + expanded = participant.menuExpanded, + onExpandedChange = { expanded -> onMenuChange(if (expanded) participant.id else null) }, + anchor = { + MillenniumButton( + onClick = { onMenuChange(participant.id) }, + style = MillenniumButtonStyle.Secondary + ) { Text(stringResource(Res.string.jam_participants_manage)) } + } + ) { + MillenniumDropdownMenuItem( + label = stringResource(Res.string.jam_participants_remove), + icon = Iconsax.IconsaxUserRemove, + onClick = { onKick(participant.id) }) + MillenniumDropdownMenuItem( + label = stringResource(Res.string.jam_participants_ban), + icon = Iconsax.IconsaxForbidden, + destructive = true, + onClick = { onBan(participant.id) }) + } + } + } + } + } + MillenniumButton( + onClick = onLeave, + loading = state.isLeaving, + style = MillenniumButtonStyle.Secondary, + modifier = Modifier.align(Alignment.End) + ) { + Text(stringResource(Res.string.jam_session_leave)) + } + } +} + +@Composable +internal fun BrokerStatus(state: JamUiState, onConfigure: () -> Unit) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + if (state.brokerConfigured) stringResource( + Res.string.jam_connection_host, + state.brokerHost + ) else stringResource(Res.string.jam_connection_missing), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.weight(1f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + MillenniumTooltip(stringResource(Res.string.jam_connection_settings)) { + MillenniumIconButton(onClick = onConfigure, style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + Iconsax.IconsaxSetting2, + contentDescription = stringResource(Res.string.jam_connection_settings) + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt new file mode 100644 index 00000000..5041e371 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.jam + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.snapshotFlow +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.jam.JamParticipant +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomCode +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.getPlatform +import org.spotube.spotube.modules.settings.SettingsProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +import kotlinx.coroutines.Job +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.* + +enum class JamSetupMode { Create, Join } + +data class JamParticipantUi( + val id: String, + val displayName: String, + val initial: String, + val isHost: Boolean, + val canModerate: Boolean, + val menuExpanded: Boolean = false +) + +data class JamUiState( + val isActive: Boolean = false, + val isConnected: Boolean = false, + val role: JamRole? = null, + val participants: List = emptyList(), + val roomCode: String? = null, + val brokerHost: String = "", + val brokerConfigured: Boolean = false, + val error: String? = null, + val setupMode: JamSetupMode = JamSetupMode.Create, + val codeInput: TextFieldState = TextFieldState(), + val canCreate: Boolean = false, + val canJoin: Boolean = false, + val isBusy: Boolean = false, + val isLeaving: Boolean = false, + val isHost: Boolean = false, + val supportsNativeShare: Boolean = false, + val codeCopied: Boolean = false +) + +class JamViewModel( + private val jamRoomService: JamRoomService, + private val shareService: ShareService, + private val settingsProvider: SettingsProvider +) : ViewModel() { + + private val _localError = MutableStateFlow(null) + private val setupMode = MutableStateFlow(JamSetupMode.Create) + private val isBusy = MutableStateFlow(false) + private val isLeaving = MutableStateFlow(false) + private val codeCopied = MutableStateFlow(false) + private val codeInput = TextFieldState() + private var copyFeedbackJob: Job? = null + private val participantMenu = MutableStateFlow(null) + + val supportsNativeShare: Boolean = + getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS + + val uiState: StateFlow = combine( + jamRoomService.role, + jamRoomService.participants, + jamRoomService.isConnected, + jamRoomService.roomCode, + jamRoomService.connectionError, + settingsProvider.settingsState, + _localError, + setupMode, + isBusy, + codeCopied, + snapshotFlow { codeInput.text.toString() }, + participantMenu, + isLeaving + ) { values -> + @Suppress("UNCHECKED_CAST") + val role = values[0] as JamRole? + + @Suppress("UNCHECKED_CAST") + val participants = values[1] as List + val isConnected = values[2] as Boolean + val roomCode = values[3] as String? + val connectionError = values[4] as String? + val settings = values[5] as? org.spotube.spotube.modules.settings.UserSettings + val localError = values[6] as String? + val busy = values[8] as Boolean + val brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank() + + JamUiState( + isActive = role != null, + isConnected = isConnected, + role = role, + participants = participants.sortedByDescending { it.isHost }.map { participant -> + JamParticipantUi( + id = participant.id, + displayName = participant.displayName, + initial = participant.displayName.firstOrNull()?.uppercase() ?: "?", + isHost = participant.isHost, + canModerate = role == JamRole.Host && !participant.isHost, + menuExpanded = values[11] == participant.id + ) + }, + roomCode = roomCode, + brokerHost = settings?.jamBroker?.host.orEmpty(), + brokerConfigured = brokerConfigured, + error = localError ?: connectionError, + setupMode = values[7] as JamSetupMode, + isBusy = busy, + isLeaving = values[12] as Boolean, + codeCopied = values[9] as Boolean, + codeInput = codeInput, + canCreate = brokerConfigured, + canJoin = brokerConfigured && + JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)), + isHost = role == JamRole.Host, + supportsNativeShare = supportsNativeShare + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + JamUiState(codeInput = codeInput) + ) + + // ---------- Session lifecycle ---------- + + fun createSession() { + if (isBusy.value) return + isBusy.value = true + viewModelScope.launch { + try { + jamRoomService.createRoom() + .onFailure { e -> + _localError.value = e.message ?: getString(Res.string.jam_error_create) + } + .onSuccess { _localError.value = null } + } finally { + isBusy.value = false + } + } + } + + fun selectSetupMode(mode: JamSetupMode) { + setupMode.value = mode + _localError.value = null + } + + fun joinSession() = joinWithCode(codeInput.text.toString()) + + fun onCodeCopied() { + copyFeedbackJob?.cancel() + copyFeedbackJob = viewModelScope.launch { + codeCopied.value = true + delay(2_000) + codeCopied.value = false + } + } + + fun joinWithCode(input: String) { + if (isBusy.value) return + val code = JamRoomCode.normalize(input) + if (!JamRoomCode.isValid(code)) { + viewModelScope.launch { + _localError.value = getString(Res.string.jam_error_invalid_code) + } + return + } + isBusy.value = true + viewModelScope.launch { + try { + jamRoomService.joinRoom(code) + .onFailure { e -> + _localError.value = e.message ?: getString(Res.string.jam_error_join) + } + .onSuccess { _localError.value = null } + } finally { + isBusy.value = false + } + } + } + + fun shareRoomCode() { + val code = uiState.value.roomCode ?: return + viewModelScope.launch { + shareService.share( + getString(Res.string.jam_share_message, code), + getString(Res.string.jam_screen_title) + ) + } + } + + fun leave() { + if (isLeaving.value) return + isLeaving.value = true + viewModelScope.launch { + try { + jamRoomService.leaveRoom() + _localError.value = null + } finally { + isLeaving.value = false + } + } + } + + fun clearError() { + _localError.value = null + } + + // ---------- Host moderation ---------- + + fun kickParticipant(participantId: String) { + participantMenu.value = null + viewModelScope.launch { jamRoomService.kickParticipant(participantId) } + } + + fun banParticipant(participantId: String) { + participantMenu.value = null + viewModelScope.launch { jamRoomService.banParticipant(participantId) } + } + + fun showParticipantMenu(participantId: String?) { + participantMenu.value = participantId + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/PlayDestinationPicker.kt new file mode 100644 index 00000000..15a5ec0a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/PlayDestinationPicker.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.jam + +import org.spotube.millennium.composables.MillenniumAlertDialog +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_destination_message +import spotube.composeapp.generated.resources.jam_destination_play_here +import spotube.composeapp.generated.resources.jam_destination_suggest +import spotube.composeapp.generated.resources.jam_destination_title +import spotube.composeapp.generated.resources.common_cancel + +/** + * Dialog shown when the user tries to play a collection while a jam session is active. + * The user picks between playing locally on their device or suggesting it to the jam session. + */ +@Composable +fun PlayDestinationPicker( + visible: Boolean, + onDismiss: () -> Unit, + onPlayLocally: () -> Unit, + onSuggestToJam: () -> Unit) { + if (!visible) return + + MillenniumAlertDialog( + visible = true, + onDismissRequest = onDismiss, + title = { Text(stringResource(Res.string.jam_destination_title)) }, + description = { Text(stringResource(Res.string.jam_destination_message)) }) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Quiet) { + Text(stringResource(Res.string.common_cancel)) + } + MillenniumButton( + onClick = onPlayLocally, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.jam_destination_play_here)) + } + MillenniumButton(onClick = onSuggestToJam) { + Text(stringResource(Res.string.jam_destination_suggest)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryMediaCollection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryMediaCollection.kt new file mode 100644 index 00000000..467881d8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryMediaCollection.kt @@ -0,0 +1,79 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import kotlinx.coroutines.flow.distinctUntilChanged + +/** Only reports viewport geometry. The owning ViewModel decides when to load another page. */ +@Composable +internal fun LibraryMediaCollection( + items: List, + itemKey: (T) -> String, + viewMode: LibraryViewMode, + onViewportChanged: (Int?, Int) -> Unit, + itemContent: @Composable (T, Boolean) -> Unit, + skeleton: @Composable (Boolean) -> Unit, + loading: Boolean = false, + loadingMore: Boolean = false, + header: (@Composable (Boolean) -> Unit)? = null, + modifier: Modifier = Modifier +) { + val gridState = rememberLazyGridState() + val listState = rememberLazyListState() + val reportViewport by rememberUpdatedState(onViewportChanged) + val padding = PaddingValues(top = 16.dp, bottom = LocalAppShellBottomInset.current + 16.dp) + LaunchedEffect(viewMode, gridState, listState) { + val positions = if (viewMode == LibraryViewMode.List) snapshotFlow { + listState.layoutInfo.let { it.visibleItemsInfo.lastOrNull()?.index to it.totalItemsCount } + } else snapshotFlow { + gridState.layoutInfo.let { it.visibleItemsInfo.lastOrNull()?.index to it.totalItemsCount } + } + positions.distinctUntilChanged().collect { (last, total) -> reportViewport(last, total) } + } + if (viewMode == LibraryViewMode.List) { + LazyColumn( + modifier.fillMaxSize(), state = listState, contentPadding = padding, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (loading) items(8) { SkeletonTree(true) { skeleton(true) } } + else { + if (header != null) item(key = "library_header") { header(true) } + items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, true) } + if (loadingMore) items(4) { SkeletonTree(true) { skeleton(true) } } + } + } + } else { + LazyVerticalGrid( + GridCells.Adaptive(160.dp), modifier.fillMaxSize(), state = gridState, + contentPadding = padding, horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (loading) items(8) { SkeletonTree(true) { skeleton(false) } } + else { + if (header != null) item(key = "library_header") { header(false) } + items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, false) } + if (loadingMore) items(4) { SkeletonTree(true) { skeleton(false) } } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryRepository.kt new file mode 100644 index 00000000..7fb8bd0f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryRepository.kt @@ -0,0 +1,357 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library + +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.spotube.modules.plugin.PluginManager +import io.github.reactivecircus.cache4k.Cache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +class LibraryRepository( + val pluginManager: PluginManager +) { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + val plugin + get() = pluginManager.selectedMetadataPlugin.value + + private val playlistCache = + Cache.Builder>().build() + private val albumCache = + Cache.Builder>().build() + private val artistCache = + Cache.Builder>().build() + + private val savedPlaylistIds = MutableStateFlow>(emptySet()) + val savedPlaylistIdsFlow: StateFlow> = savedPlaylistIds.asStateFlow() + + private val savedAlbumIds = MutableStateFlow>(emptySet()) + val savedAlbumIdsFlow: StateFlow> = savedAlbumIds.asStateFlow() + + private val savedArtistIds = MutableStateFlow>(emptySet()) + val savedArtistIdsFlow: StateFlow> = savedArtistIds.asStateFlow() + + init { + scope.launch { + pluginManager.selectedMetadataPlugin + .filterNotNull() + .flatMapLatest { it.loggedInFlow } + .distinctUntilChanged() + .collect { loggedIn -> + isLoggedIn = loggedIn + invalidateCaches() + } + } + } + + private var isLoggedIn: Boolean = false + + private val authPlugin get() = if (isLoggedIn) plugin else null + + fun invalidateCaches() { + playlistCache.invalidateAll() + albumCache.invalidateAll() + artistCache.invalidateAll() + savedPlaylistIds.value = emptySet() + savedAlbumIds.value = emptySet() + savedArtistIds.value = emptySet() + } + + suspend fun savedPlaylists(paginationStrategy: PaginationStrategy? = null) = + authPlugin?.let { plugin -> + playlistCache.get( + key = paginationStrategy ?: PaginationStrategy.Offset(0, 20) + ) { + pluginManager.withScope { + plugin.use { + val result = metadataPlaylistAPI.savedPlaylists(paginationStrategy) + savedPlaylistIds.value += result.items.map { it.id }.toSet() + result + } + } + } + } + + suspend fun savedAlbums(paginationStrategy: PaginationStrategy? = null) = + authPlugin?.let { plugin -> + albumCache.get( + key = paginationStrategy ?: PaginationStrategy.Offset(0, 20) + ) { + pluginManager.withScope { + plugin.use { + val result = metadataAlbumAPI.savedAlbums(paginationStrategy) + savedAlbumIds.value += result.items.map { it.id }.toSet() + result + } + } + } + } + + suspend fun savedArtists(paginationStrategy: PaginationStrategy? = null) = + authPlugin?.let { plugin -> + artistCache.get( + key = paginationStrategy ?: PaginationStrategy.Offset(0, 20) + ) { + pluginManager.withScope { + plugin.use { + val result = metadataArtistAPI.savedArtists(paginationStrategy) + savedArtistIds.value += result.items.map { it.id }.toSet() + result + } + } + } + } + + suspend fun isSavedPlaylists(ids: List): List { + val unknownIds = ids.filterNot { savedPlaylistIds.value.contains(it) } + + if (unknownIds.isEmpty()) { + return ids.map { true } + } + + val unknownStates = authPlugin?.let { plugin -> + val savedStates = pluginManager.withScope { + plugin.use { + metadataPlaylistAPI.isSavedPlaylists(unknownIds) + } + } + val savedIds = unknownIds.filterIndexed { index, id -> + savedStates.getOrNull(index) == true + }.toSet() + savedPlaylistIds.value += savedIds + savedStates + } ?: unknownIds.map { false } + + return ids.map { id -> + savedPlaylistIds.value.contains(id) || + unknownStates.getOrNull(unknownIds.indexOf(id)) ?: false + } + } + + suspend fun savePlaylists(ids: List) { + authPlugin?.let { plugin -> + pluginManager.withScope { + plugin.use { + metadataPlaylistAPI.savePlaylists(ids) + } + } + playlistCache.invalidateAll() + savedPlaylistIds.value += ids.toSet() + } + } + + suspend fun removeSavedPlaylists(ids: List) { + authPlugin?.let { plugin -> + pluginManager.withScope { + plugin.use { + metadataPlaylistAPI.removeSavedPlaylists(ids) + } + } + playlistCache.invalidateAll() + savedPlaylistIds.value -= ids.toSet() + } + } + + suspend fun isSavedAlbums(ids: List): List { + val unknownIds = ids.filterNot { savedAlbumIds.value.contains(it) } + + if (unknownIds.isEmpty()) { + return ids.map { true } + } + + val unknownStates = authPlugin?.let { plugin -> + val savedStates = pluginManager.withScope { + plugin.use { + metadataAlbumAPI.isSavedAlbums(unknownIds) + } + } + val savedIds = unknownIds.filterIndexed { index, id -> + savedStates.getOrNull(index) == true + }.toSet() + savedAlbumIds.value += savedIds + savedStates + } ?: unknownIds.map { false } + + return ids.map { id -> + savedAlbumIds.value.contains(id) || + unknownStates.getOrNull(unknownIds.indexOf(id)) ?: false + } + } + + suspend fun saveAlbums(ids: List) { + authPlugin?.let { plugin -> + pluginManager.withScope { + plugin.use { + metadataAlbumAPI.saveAlbums(ids) + } + } + albumCache.invalidateAll() + savedAlbumIds.value += ids.toSet() + } + } + + suspend fun removeSavedAlbums(ids: List) { + authPlugin?.let { plugin -> + pluginManager.withScope { + plugin.use { + metadataAlbumAPI.removeSavedAlbums(ids) + } + } + albumCache.invalidateAll() + savedAlbumIds.value -= ids.toSet() + } + } + + suspend fun isSavedArtists(ids: List): List { + val unknownIds = ids.filterNot { savedArtistIds.value.contains(it) } + + if (unknownIds.isEmpty()) { + return ids.map { true } + } + + val unknownStates = authPlugin?.let { plugin -> + val savedStates = pluginManager.withScope { + plugin.use { + metadataArtistAPI.isSavedArtists(unknownIds) + } + } + val savedIds = unknownIds.filterIndexed { index, id -> + savedStates.getOrNull(index) == true + }.toSet() + savedArtistIds.value += savedIds + savedStates + } ?: unknownIds.map { false } + + return ids.map { id -> + savedArtistIds.value.contains(id) || + unknownStates.getOrNull(unknownIds.indexOf(id)) ?: false + } + } + + suspend fun saveArtists(ids: List) { + authPlugin?.let { plugin -> + pluginManager.withScope { + plugin.use { + metadataArtistAPI.saveArtists(ids) + } + } + artistCache.invalidateAll() + savedArtistIds.value += ids.toSet() + } + } + + suspend fun removeSavedArtists(ids: List) { + authPlugin?.let { plugin -> + pluginManager.withScope { + plugin.use { + metadataArtistAPI.removeSavedArtists(ids) + } + } + artistCache.invalidateAll() + savedArtistIds.value -= ids.toSet() + } + } + + suspend fun currentUser(): MetadataUser? { + return authPlugin?.let { plugin -> + pluginManager.withScope { + plugin.use { + metadataUserAPI.getUser("") + } + } + } + } + + suspend fun createPlaylist( + name: String, + description: String?, + isPublic: Boolean, + isCollaborating: Boolean, + imageBase64: String, + trackIds: List): MetadataPlaylist? { + return authPlugin?.let { plugin -> + pluginManager.withScope { + plugin.use { + val result = metadataPlaylistAPI.createPlaylist( + name = name, + description = description, + isPublic = isPublic, + isCollaborating = isCollaborating, + imageBase64 = imageBase64, + trackIds = trackIds) + playlistCache.invalidateAll() + savedPlaylistIds.value += result.id + result + } + } + } + } + + suspend fun updatePlaylist( + id: String, + name: String?, + description: String?, + isPublic: Boolean?, + isCollaborating: Boolean?, + imageBase64: String?, + trackIds: List?): MetadataPlaylist? { + return authPlugin?.let { plugin -> + pluginManager.withScope { + plugin.use { + val result = metadataPlaylistAPI.updatePlaylist( + id = id, + name = name, + description = description, + isPublic = isPublic, + isCollaborating = isCollaborating, + imageBase64 = imageBase64, + trackIds = trackIds) + playlistCache.invalidateAll() + result + } + } + } + } + + suspend fun addTracksToPlaylist(playlistId: String, trackIds: List) { + authPlugin?.let { plugin -> + pluginManager.withScope { + plugin.use { + metadataPlaylistAPI.addTracksToPlaylist(playlistId, trackIds) + } + } + playlistCache.invalidateAll() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt new file mode 100644 index 00000000..144286a0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt @@ -0,0 +1,265 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.spotube.getPlatform +import org.spotube.spotube.isDesktop +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.downloads.DownloadsScreen +import org.spotube.spotube.modules.library.album.LibraryAlbumsScreen +import org.spotube.spotube.modules.library.artist.LibraryArtistsScreen +import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksScreen +import org.spotube.spotube.modules.library.playlist.LibraryPlaylistsScreen +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight +import org.spotube.spotube.resources.iconsax.IconsaxSearchBroken +import org.jetbrains.compose.resources.StringResource +import org.spotube.spotube.modules.plugin.guard.MetadataPluginGuard +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.spotube.millennium.adaptive.WindowBreakpoints +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumTextField +import compose.icons.FeatherIcons +import compose.icons.feathericons.Grid +import compose.icons.feathericons.List +import spotube.composeapp.generated.resources.* + +@Composable +fun LibraryScreen(libraryState: LibraryState = koinInject()) { + val state by libraryState.uiState.collectAsStateWithLifecycle() + val expandedDesktop = getPlatform().isDesktop() && + (currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded) + val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Expanded + val compactDesktop = getPlatform().isDesktop() && isCompact + val viewMode = state.viewModeOverride ?: defaultLibraryViewMode(isCompact) + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current + + if (state.currentTab == LibraryTab.Downloads && !isCompact) { + DownloadsScreen( + headerInAppBar = true, + topContent = { if (!expandedDesktop) LibraryTabs(state, libraryState::onTabSelected) } + ) + return + } + + Column(Modifier.fillMaxSize()) { + ApplicationMainBar( + backButton = false, + title = { LibraryToolbarTitle(state, expandedDesktop, compactDesktop) }, + actions = { + LibraryToolbarActions( + state = state, + expandedDesktop = expandedDesktop, + onOpenSearch = libraryState::openSearch, + onCloseSearch = { + focusManager.clearFocus() + keyboard?.hide() + libraryState.closeSearch() + }, + onCreatePlaylist = libraryState::showCreatePlaylist, + onRefresh = libraryState::refreshLocalTracks, + onToggleView = { libraryState.toggleViewMode(isCompact) }, + viewMode = viewMode, + compactDesktop = compactDesktop + ) + } + ) + if (!expandedDesktop) { + LibraryTabs(state, libraryState::onTabSelected) + } + Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { + when (state.currentTab) { + LibraryTab.Playlists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { + LibraryPlaylistsScreen( + showCreatePlaylist = state.showCreatePlaylist, + viewMode = viewMode + ) + } + + LibraryTab.Albums -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { + LibraryAlbumsScreen(viewMode = viewMode) + } + LibraryTab.Artists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { + LibraryArtistsScreen(viewMode = viewMode) + } + LibraryTab.LocalTracks -> LibraryLocalTracksScreen() + LibraryTab.Downloads -> DownloadsScreen() + } + } + } +} + +@Composable +private fun LibraryTabs(state: LibraryToolbarUiState, onTabSelected: (LibraryTab) -> Unit) { + LazyRow( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(LibraryTab.entries) { tab -> + MillenniumButton( + onClick = { onTabSelected(tab) }, + style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary + ) { Text(stringResource(tab.titleResource)) } + } + } +} + +@Composable +internal fun LibraryToolbarTitle( + state: LibraryToolbarUiState, + expandedDesktop: Boolean, + compactDesktop: Boolean = false +) { + if (!expandedDesktop && state.searchMode) { + LibraryFilterField( + state.searchState, + state.currentTab.filterPlaceholder, + Modifier.fillMaxWidth(), + autoFocus = true + ) + } else if (!expandedDesktop) { + Text( + stringResource(if (compactDesktop) Res.string.library_toolbar_compact_title else Res.string.library_screen_title), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +internal fun RowScope.LibraryToolbarActions( + state: LibraryToolbarUiState, + expandedDesktop: Boolean, + onOpenSearch: () -> Unit, + onCloseSearch: () -> Unit, + onCreatePlaylist: () -> Unit, + onRefresh: () -> Unit, + onToggleView: () -> Unit = {}, + viewMode: LibraryViewMode = state.viewModeOverride ?: defaultLibraryViewMode(!expandedDesktop), + compactDesktop: Boolean = false +) { + val actionModifier = Modifier.size(if (compactDesktop) 32.dp else 40.dp) + if (!expandedDesktop && state.searchMode) { + MillenniumIconButton( + icon = Iconsax.IconsaxCloseSquare, + contentDescription = stringResource(Res.string.library_action_close_search), + onClick = onCloseSearch, + modifier = actionModifier, + style = MillenniumIconButtonStyle.Quiet + ) + } else { + if (state.canFilter) { + if (expandedDesktop) { + LibraryFilterField( + state.searchState, + state.currentTab.filterPlaceholder, + Modifier.width(300.dp) + ) + } else { + MillenniumIconButton( + icon = Iconsax.IconsaxSearchBroken, + contentDescription = stringResource(Res.string.library_action_search), + onClick = onOpenSearch, + modifier = actionModifier, + style = MillenniumIconButtonStyle.Quiet + ) + } + } + if (state.canCreatePlaylist) { + MillenniumIconButton( + icon = Iconsax.IconsaxAddSquare, + contentDescription = stringResource(Res.string.library_action_create_playlist), + onClick = onCreatePlaylist, + modifier = actionModifier, + style = MillenniumIconButtonStyle.Quiet + ) + } + if (state.canRefresh) { + MillenniumIconButton( + icon = Iconsax.IconsaxRefreshRight, + contentDescription = stringResource(Res.string.library_action_refresh_local_tracks), + onClick = onRefresh, + loading = state.isRefreshing, + modifier = actionModifier, + style = MillenniumIconButtonStyle.Quiet + ) + } + if (state.canChangeView) { + MillenniumIconButton( + icon = if (viewMode == LibraryViewMode.List) FeatherIcons.Grid else FeatherIcons.List, + contentDescription = stringResource(if (viewMode == LibraryViewMode.List) Res.string.library_action_grid_view else Res.string.library_action_list_view), + onClick = onToggleView, + modifier = actionModifier, + style = MillenniumIconButtonStyle.Quiet + ) + } + } +} + +@Composable +private fun LibraryFilterField( + state: TextFieldState, + placeholder: StringResource, + modifier: Modifier, + autoFocus: Boolean = false +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(autoFocus) { + if (autoFocus) focusRequester.requestFocus() + } + MillenniumTextField( + state = state, + placeholder = stringResource(placeholder), + minimumHeight = 36.dp, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), + modifier = modifier.padding(vertical = 4.dp).focusRequester(focusRequester) + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt new file mode 100644 index 00000000..cb85e965 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update + +enum class LibraryViewMode { Grid, List } + +data class LibraryToolbarUiState( + val currentTab: LibraryTab = LibraryTab.Playlists, + val searchMode: Boolean = false, + val searchState: TextFieldState = TextFieldState(), + val canFilter: Boolean = true, + val canCreatePlaylist: Boolean = true, + val canRefresh: Boolean = false, + val isRefreshing: Boolean = false, + val showCreatePlaylist: Boolean = false, + val canChangeView: Boolean = true, + val viewModeOverride: LibraryViewMode? = null +) + +/** Shared with the sidebar for the app lifetime; each library tab retains its own filter. */ +@OptIn(ExperimentalCoroutinesApi::class) +class LibraryState : ViewModel() { + private val searchMode = MutableStateFlow(false) + val currentTab = MutableStateFlow(LibraryTab.Playlists) + private val searchStates = LibraryTab.entries.associateWith { TextFieldState() } + private val createPlaylistVisible = MutableStateFlow(false) + private val viewModes = MutableStateFlow>(emptyMap()) + private val refreshRequests = MutableSharedFlow(extraBufferCapacity = 1) + val localRefreshRequests = refreshRequests.asSharedFlow() + private val localRefreshing = MutableStateFlow(false) + + val uiState = combine( + currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } }, + searchMode, + createPlaylistVisible, + viewModes, + localRefreshing + ) { (tab, query), searching, creating, modes, refreshing -> + LibraryToolbarUiState( + currentTab = tab, + searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()), + searchState = searchStates.getValue(tab), + canFilter = tab != LibraryTab.Downloads, + canCreatePlaylist = tab == LibraryTab.Playlists, + canRefresh = tab == LibraryTab.LocalTracks, + isRefreshing = tab == LibraryTab.LocalTracks && refreshing, + showCreatePlaylist = tab == LibraryTab.Playlists && creating, + canChangeView = tab == LibraryTab.Playlists || tab == LibraryTab.Albums || tab == LibraryTab.Artists, + viewModeOverride = modes[tab] + ) + }.stateIn( + viewModelScope, SharingStarted.Eagerly, + LibraryToolbarUiState(searchState = searchStates.getValue(LibraryTab.Playlists)) + ) + + fun queryFlow(tab: LibraryTab) = snapshotFlow { searchStates.getValue(tab).text.toString() } + + fun setQuery(tab: LibraryTab, query: String) { + searchStates.getValue(tab).setTextAndPlaceCursorAtEnd(query) + } + + fun onTabSelected(tab: LibraryTab) { + currentTab.value = tab + createPlaylistVisible.value = false + if (tab == LibraryTab.Downloads) searchMode.value = false + } + + fun openSearch() { + if (currentTab.value != LibraryTab.Downloads) searchMode.value = true + } + + fun closeSearch() { + setQuery(currentTab.value, "") + searchMode.value = false + } + + fun showCreatePlaylist() { + if (currentTab.value == LibraryTab.Playlists) createPlaylistVisible.value = true + } + + fun dismissCreatePlaylist() { + createPlaylistVisible.value = false + } + + fun refreshLocalTracks() { + refreshRequests.tryEmit(Unit) + } + + /** Reported by the local tracks ViewModel so the toolbar's refresh action can show progress. */ + fun setLocalRefreshing(refreshing: Boolean) { + localRefreshing.value = refreshing + } + + fun toggleViewMode(isCompact: Boolean) { + val tab = currentTab.value + if (tab != LibraryTab.Playlists && tab != LibraryTab.Albums && tab != LibraryTab.Artists) return + viewModes.update { modes -> + val current = modes[tab] ?: defaultLibraryViewMode(isCompact) + modes + (tab to if (current == LibraryViewMode.Grid) LibraryViewMode.List else LibraryViewMode.Grid) + } + } +} + +internal fun defaultLibraryViewMode(isCompact: Boolean) = + if (isCompact) LibraryViewMode.List else LibraryViewMode.Grid diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryTab.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryTab.kt new file mode 100644 index 00000000..c2e9aa22 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryTab.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library + +import androidx.compose.ui.graphics.vector.ImageVector +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxMusicDashboard +import org.spotube.spotube.resources.iconsax.IconsaxMusicLibrary +import org.spotube.spotube.resources.iconsax.User +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.* + +enum class LibraryTab(val icon: ImageVector) { + Playlists(Iconsax.IconsaxMusicDashboard), + Albums(Iconsax.IconsaxCd), + Artists(Iconsax.User), + LocalTracks(Iconsax.IconsaxMusicLibrary), + Downloads(Iconsax.IconsaxDirectboxReceive); + + val titleResource: StringResource + get() = when (this) { + Playlists -> Res.string.library_tab_playlists + Albums -> Res.string.library_tab_albums + Artists -> Res.string.library_tab_artists + LocalTracks -> Res.string.library_tab_local_tracks + Downloads -> Res.string.library_tab_downloads + } + + val filterPlaceholder: StringResource + get() = when (this) { + Playlists -> Res.string.library_filter_playlists + Albums -> Res.string.library_filter_albums + Artists -> Res.string.library_filter_artists + LocalTracks -> Res.string.library_filter_local_tracks + Downloads -> Res.string.search_filter_placeholder + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsScreen.kt new file mode 100644 index 00000000..572f31e9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsScreen.kt @@ -0,0 +1,76 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.album + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.spotube.core.ui.component.AlbumCard +import org.spotube.spotube.core.ui.component.ErrorDisplay +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.modules.library.LibraryMediaCollection +import org.spotube.spotube.modules.library.LibraryViewMode +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.* + +@Composable +fun LibraryAlbumsScreen( + modifier: Modifier = Modifier, + viewMode: LibraryViewMode = LibraryViewMode.Grid, + viewModel: LibraryAlbumsViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + when (val current = state) { + is LibraryAlbumsState.Error -> Box( + modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh) + } + + else -> { + val data = current as? LibraryAlbumsState.Data + if (data != null && data.items.isEmpty() && current !is LibraryAlbumsState.Data.LoadingMore) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (data.query.isNotBlank()) stringResource( + Res.string.library_albums_no_results, + data.query + ) + else stringResource(Res.string.library_albums_empty) + ) + } + } else LibraryMediaCollection( + items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode, + loading = current is LibraryAlbumsState.Loading, + loadingMore = current is LibraryAlbumsState.Data.LoadingMore, + onViewportChanged = viewModel::onItemsVisible, + itemContent = { item, list -> + AlbumCard( + item, + Modifier.fillMaxWidth(), + listMode = list + ) + }, + skeleton = { list -> + PlayableCard( + stringResource(Res.string.media_preview_title), + stringResource(Res.string.media_preview_subtitle), + modifier = Modifier.fillMaxWidth(), + listMode = list + ) + }, + modifier = modifier + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsViewModel.kt new file mode 100644 index 00000000..a373f98d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsViewModel.kt @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.album + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error + +sealed interface LibraryAlbumsState { + data object Loading : LibraryAlbumsState + + sealed interface Data : LibraryAlbumsState { + val query: String + val allItems: List + val items: List + val nextPagination: PaginationStrategy? + + data class Loaded( + override val query: String = "", + override val allItems: List = emptyList(), + override val nextPagination: PaginationStrategy? = null + ) : Data { + override val items: List = if (query.isNotBlank()) { + allItems.filter { album -> + album.title.contains(query, ignoreCase = true) || + album.artists.any { artist -> + artist.name.contains(query, ignoreCase = true) + } + } + } else { + allItems + } + + fun toLoadingMore(): LoadingMore = LoadingMore( + query = query, + allItems = allItems, + nextPagination = nextPagination + ) + } + + data class LoadingMore( + override val query: String = "", + override val allItems: List = emptyList(), + override val nextPagination: PaginationStrategy? = null + ) : Data { + override val items: List = if (query.isNotBlank()) { + allItems.filter { album -> + album.title.contains(query, ignoreCase = true) || + album.artists.any { artist -> + artist.name.contains(query, ignoreCase = true) + } + } + } else { + allItems + } + } + } + + data class Error(val message: String) : LibraryAlbumsState +} + +@OptIn(ExperimentalCoroutinesApi::class) +class LibraryAlbumsViewModel( + private val repository: LibraryRepository, + private val libraryState: LibraryState +) : ViewModel(), KoinComponent { + private val logger by injectLogger() + + private val _state = MutableStateFlow(LibraryAlbumsState.Loading) + val uiState: StateFlow = combine( + _state, libraryState.queryFlow(LibraryTab.Albums) + ) { state, query -> + when (state) { + is LibraryAlbumsState.Data.Loaded -> state.copy(query = query) + is LibraryAlbumsState.Data.LoadingMore -> state.copy(query = query) + else -> state + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryAlbumsState.Loading) + + init { + viewModelScope.launch { + combine( + repository.pluginManager.selectedMetadataPlugin + .filterNotNull() + .flatMapLatest { it.loggedInFlow } + .distinctUntilChanged(), + repository.savedAlbumIdsFlow + ) { _, _ -> }.collect { + loadInitialData() + } + } + } + + private suspend fun loadInitialData() = runCatching { + _state.value = LibraryAlbumsState.Loading + val result = repository.savedAlbums() + _state.value = LibraryAlbumsState.Data.Loaded( + allItems = result?.items ?: emptyList(), + nextPagination = result?.nextPagination + ) + }.onFailure { e -> + logger.e(e) { "Failed to load albums" } + _state.value = LibraryAlbumsState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + + suspend fun loadMoreData() = runCatching { + val currentState = _state.value + if (currentState is LibraryAlbumsState.Data.Loaded && currentState.nextPagination != null) { + _state.value = currentState.toLoadingMore() + val result = repository.savedAlbums(currentState.nextPagination) + _state.value = LibraryAlbumsState.Data.Loaded( + query = currentState.query, + allItems = currentState.allItems + (result?.items ?: emptyList()), + nextPagination = result?.nextPagination + ) + } + }.onFailure { e -> + logger.e(e) { "Failed to load more albums" } + _state.value = LibraryAlbumsState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + + fun onQueryChange(query: String) { + libraryState.setQuery(LibraryTab.Albums, query) + } + + fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) { + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) { + viewModelScope.launch { loadMoreData() } + } + } + + fun refresh() { + viewModelScope.launch { + repository.invalidateCaches() + loadInitialData() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsScreen.kt new file mode 100644 index 00000000..1ab6c08f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsScreen.kt @@ -0,0 +1,76 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.artist + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.spotube.core.ui.component.ArtistCard +import org.spotube.spotube.core.ui.component.ErrorDisplay +import org.spotube.spotube.core.ui.component.cards.AvatarCard +import org.spotube.spotube.modules.library.LibraryMediaCollection +import org.spotube.spotube.modules.library.LibraryViewMode +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.* + +@Composable +fun LibraryArtistsScreen( + modifier: Modifier = Modifier, + viewMode: LibraryViewMode = LibraryViewMode.Grid, + viewModel: LibraryArtistsViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + when (val current = state) { + is LibraryArtistsState.Error -> Box( + modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh) + } + + else -> { + val data = current as? LibraryArtistsState.Data + if (data != null && data.items.isEmpty() && current !is LibraryArtistsState.Data.LoadingMore) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (data.query.isNotBlank()) stringResource( + Res.string.library_artists_no_results, + data.query + ) + else stringResource(Res.string.library_artists_empty) + ) + } + } else LibraryMediaCollection( + items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode, + loading = current is LibraryArtistsState.Loading, + loadingMore = current is LibraryArtistsState.Data.LoadingMore, + onViewportChanged = viewModel::onItemsVisible, + itemContent = { item, list -> + ArtistCard( + item, + Modifier.fillMaxWidth(), + listMode = list + ) + }, + skeleton = { list -> + AvatarCard( + stringResource(Res.string.media_preview_subtitle), + stringResource(Res.string.media_artist), + modifier = Modifier.fillMaxWidth(), + listMode = list + ) + }, + modifier = modifier + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsViewModel.kt new file mode 100644 index 00000000..01608629 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsViewModel.kt @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.artist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error + +sealed interface LibraryArtistsState { + data object Loading : LibraryArtistsState + + sealed interface Data : LibraryArtistsState { + val query: String + val allItems: List + val items: List + val nextPagination: PaginationStrategy? + + data class Loaded( + override val query: String = "", + override val allItems: List = emptyList(), + override val nextPagination: PaginationStrategy? = null + ) : Data { + override val items: List = if (query.isNotBlank()) { + allItems.filter { artist -> + artist.name.contains(query, ignoreCase = true) + } + } else { + allItems + } + + fun toLoadingMore(): LoadingMore = LoadingMore( + query = query, + allItems = allItems, + nextPagination = nextPagination + ) + } + + data class LoadingMore( + override val query: String = "", + override val allItems: List = emptyList(), + override val nextPagination: PaginationStrategy? = null + ) : Data { + override val items: List = if (query.isNotBlank()) { + allItems.filter { artist -> + artist.name.contains(query, ignoreCase = true) + } + } else { + allItems + } + } + } + + data class Error(val message: String) : LibraryArtistsState +} + +@OptIn(ExperimentalCoroutinesApi::class) +class LibraryArtistsViewModel( + private val repository: LibraryRepository, + private val libraryState: LibraryState +) : ViewModel(), KoinComponent { + private val logger by injectLogger() + + private val _state = MutableStateFlow(LibraryArtistsState.Loading) + val uiState: StateFlow = combine( + _state, libraryState.queryFlow(LibraryTab.Artists) + ) { state, query -> + when (state) { + is LibraryArtistsState.Data.Loaded -> state.copy(query = query) + is LibraryArtistsState.Data.LoadingMore -> state.copy(query = query) + else -> state + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryArtistsState.Loading) + + init { + viewModelScope.launch { + combine( + repository.pluginManager.selectedMetadataPlugin + .filterNotNull() + .flatMapLatest { it.loggedInFlow } + .distinctUntilChanged(), + repository.savedArtistIdsFlow + ) { _, _ -> }.collect { + loadInitialData() + } + } + } + + private suspend fun loadInitialData() = runCatching { + _state.value = LibraryArtistsState.Loading + val result = repository.savedArtists() + _state.value = LibraryArtistsState.Data.Loaded( + allItems = result?.items?.map { + MetadataArtist.Basic( + id = it.id, + name = it.name, + thumbnails = it.thumbnails, + externalUri = it.externalUri + ) + } ?: emptyList(), + nextPagination = result?.nextPagination) + }.onFailure { e -> + logger.e(e) { "Failed to load artists" } + _state.value = LibraryArtistsState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + + suspend fun loadMoreData() = runCatching { + val currentState = _state.value + if (currentState is LibraryArtistsState.Data.Loaded && currentState.nextPagination != null) { + _state.value = currentState.toLoadingMore() + val result = repository.savedArtists(currentState.nextPagination) + val newItems = result?.items?.map { + MetadataArtist.Basic( + id = it.id, + name = it.name, + thumbnails = it.thumbnails, + externalUri = it.externalUri + ) + } ?: emptyList() + _state.value = LibraryArtistsState.Data.Loaded( + query = currentState.query, + allItems = currentState.allItems + newItems, + nextPagination = result?.nextPagination + ) + } + }.onFailure { e -> + logger.e(e) { "Failed to load more artists" } + _state.value = LibraryArtistsState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + + fun onQueryChange(query: String) { + libraryState.setQuery(LibraryTab.Artists, query) + } + + fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) { + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) { + viewModelScope.launch { loadMoreData() } + } + } + + fun refresh() { + viewModelScope.launch { + repository.invalidateCaches() + loadInitialData() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt new file mode 100644 index 00000000..e379c8db --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -0,0 +1,342 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.local_tracks + +import org.spotube.millennium.composables.MillenniumCircularProgress +import org.spotube.millennium.composables.MillenniumSpokeSpinner +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.DpSize +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import org.spotube.spotube.core.ui.component.cards.MediaArtwork +import org.spotube.spotube.core.ui.component.cards.MediaEmptyState +import org.spotube.spotube.core.ui.component.cards.PlayableListTile +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState +import org.spotube.spotube.modules.settings.LocalMediaFoldersViewModel +import org.spotube.spotube.modules.settings.components.LocalMediaFoldersDialogHost +import org.spotube.spotube.resources.iconsax.IconsaxFolderAdd +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxFolderOpen +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.pluralStringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* + +@Composable +fun LibraryLocalTracksScreen( + modifier: Modifier = Modifier, + viewModel: LibraryLocalTracksViewModel = koinViewModel(), + foldersViewModel: LocalMediaFoldersViewModel = koinViewModel(), + navigationCommands: NavigationCommands = koinInject() +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val permission = rememberLocalMediaPermissionState() + // Platform permission events only; the ViewModel decides whether/when to index media. + LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) } + LocalTracksContent( + state, + onOpenFolder = { navigationCommands.navigateTo(Routes.LocalFolder(it)) }, + onPlayTrack = {}, onNavigateUp = {}, onPlayFolder = {}, + onRequestPermission = permission.requestPermission, modifier = modifier, + onManageFolders = foldersViewModel::open + ) + LocalMediaFoldersDialogHost(foldersViewModel) +} + +@Composable +internal fun LocalTracksContent( + state: LibraryLocalTracksState, + onOpenFolder: (String) -> Unit, + onPlayTrack: (String) -> Unit, + onNavigateUp: () -> Unit, + onPlayFolder: () -> Unit, + onRequestPermission: () -> Unit, + modifier: Modifier = Modifier, + onManageFolders: () -> Unit = {} +) { + val inset = LocalAppShellBottomInset.current + Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Column( + Modifier.widthIn(max = 960.dp).fillMaxSize() + ) { + LocalTracksBreadcrumb(state, onNavigateUp, onPlayFolder, onManageFolders = onManageFolders) + when { + !state.canAccessMedia -> MediaEmptyState( + Iconsax.IconsaxFolderOpen, + stringResource(Res.string.library_local_permission_title), + Modifier.padding(bottom = inset), + stringResource(Res.string.library_local_permission_description), + action = { + MillenniumButton(onClick = onRequestPermission) { + Text( + stringResource( + Res.string.library_local_grant_access + ) + ) + } + }) + + state.isRefreshing && state.visibleFolders.isEmpty() && state.visibleTracks.isEmpty() -> Box( + Modifier.fillMaxSize().padding(bottom = inset), + contentAlignment = Alignment.Center + ) { + MillenniumCircularProgress(size = 40.dp) + } + + state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn( + Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(bottom = inset + 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(state.visibleFolders, key = { it.path }) { folder -> + LocalFolderRow(folder) { onOpenFolder(folder.path) } + } + } + + state.currentFolderPath != null && state.visibleTracks.isNotEmpty() -> LazyColumn( + Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(bottom = inset + 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(state.visibleTracks, key = { it.path }) { track -> + PlayableListTile( + track.title, + track.subtitle ?: stringResource(Res.string.media_unknown_artist), + imageModel = track.artwork, + onClick = { onPlayTrack(track.path) }, + onPlay = { onPlayTrack(track.path) }, + compact = true, + trailingContent = { + track.durationLabel?.let { + Text( + it, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + }) + } + } + + else -> { + val noFolders = state.currentFolderPath == null && state.query.isBlank() + MediaEmptyState( + Iconsax.IconsaxFolderOpen, + state.error ?: stringResource( + when { + state.query.isNotBlank() -> Res.string.library_local_no_results + state.currentFolderPath == null -> Res.string.library_local_empty_folders + else -> Res.string.library_local_empty_tracks + } + ), Modifier.padding(bottom = inset), + description = if (noFolders && state.error == null) + stringResource(Res.string.library_local_empty_folders_description) else null, + action = if (noFolders) ({ + MillenniumButton(onClick = onManageFolders) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxFolderAdd, null, + modifier = Modifier.size(18.dp) + ) + Text(stringResource(Res.string.library_local_add_folder)) + } + } + }) else null + ) + } + } + } + } +} + +@Composable +internal fun LocalTracksBreadcrumb( + state: LibraryLocalTracksState, + onNavigateUp: () -> Unit, + onPlayFolder: () -> Unit, + modifier: Modifier = Modifier, + onManageFolders: (() -> Unit)? = null +) { + val backDescription = stringResource(Res.string.library_local_back) + Row( + modifier.fillMaxWidth().height(40.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxFolderOpen, + null, + modifier = Modifier.size(14.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + if (state.currentFolderPath != null) { + MillenniumButton( + onClick = onNavigateUp, + style = MillenniumButtonStyle.Quiet, + modifier = Modifier.height(28.dp) + .semantics { contentDescription = backDescription }, + padding = PaddingValues(0.dp), + minSize = DpSize(0.dp, 28.dp), + foreground = AppTheme.colors.primary + ) { + Text( + stringResource(Res.string.library_local_folders), + style = AppTheme.typography.labelSmall + ) + } + MillenniumIcon( + FeatherIcons.ChevronRight, + null, + modifier = Modifier.size(12.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + state.currentFolder?.name ?: stringResource(Res.string.library_local_folder), + style = AppTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } else Text( + stringResource(Res.string.library_local_folders), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + Text( + if (state.currentFolderPath != null) pluralStringResource( + Res.plurals.library_local_tracks_count, + state.visibleTracks.size, + state.visibleTracks.size + ) + else stringResource( + Res.string.library_local_summary, + pluralStringResource( + Res.plurals.library_local_folders_count, + state.totalFolderCount, + state.totalFolderCount + ), + pluralStringResource( + Res.plurals.library_local_tracks_count, + state.totalTrackCount, + state.totalTrackCount + ) + ), + style = AppTheme.typography.labelSmall, color = AppTheme.colors.onSurfaceVariant, + maxLines = 1 + ) + if (state.isRefreshing) MillenniumSpokeSpinner(size = 14.dp) + if (state.currentFolderPath == null && onManageFolders != null) MillenniumIconButton( + onClick = onManageFolders, + modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxFolderAdd, + stringResource(Res.string.library_local_add_folder), + modifier = Modifier.size(22.dp) + ) + } + if (state.currentFolderPath != null) MillenniumIconButton( + onClick = onPlayFolder, enabled = state.canPlayFolder, + modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxPlay, + stringResource(Res.string.library_local_play_folder), + modifier = Modifier.size(22.dp) + ) + } + } +} + +@Composable +internal fun LocalFolderRow(folder: LocalFolderUi, onClick: () -> Unit) { + MillenniumListTile( + onClick = onClick, + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(8.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + contentPadding = PaddingValues(8.dp), + minimumHeight = 52.dp, + leading = { + MediaArtwork( + folder.artwork, + folder.name, + Modifier.size(36.dp), + placeholderIcon = Iconsax.IconsaxFolderOpen + ) + }, + title = { + Text( + folder.name, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + subtitle = { + Text( + pluralStringResource( + Res.plurals.library_local_tracks_count, + folder.trackCount, + folder.trackCount + ), style = AppTheme.typography.bodySmall + ) + }, + trailing = { + MillenniumIcon( + FeatherIcons.ChevronRight, + null, + modifier = Modifier.size(16.dp) + ) + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt new file mode 100644 index 00000000..3fd0c16c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt @@ -0,0 +1,199 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.local_tracks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFolder +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException +import org.spotube.spotube.PlatformType +import org.spotube.spotube.getPlatform +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_local_refresh_failed + +data class LocalFolderUi( + val path: String, + val name: String, + val trackCount: Int, + val artwork: ByteArray? +) + +data class LocalTrackUi( + val path: String, + val title: String, + val subtitle: String?, + val durationLabel: String?, + val artwork: ByteArray? +) + +data class LibraryLocalTracksState( + val isRefreshing: Boolean = false, + val folders: List = emptyList(), + val currentFolderPath: String? = null, + val query: String = "", + val error: String? = null, + val canAccessMedia: Boolean = true, + val visibleFolders: List = emptyList(), + val visibleTracks: List = emptyList(), + val currentFolder: LocalMediaFolder? = null, + val totalFolderCount: Int = 0, + val totalTrackCount: Int = 0, + val canPlayFolder: Boolean = false +) + +internal fun localTracksUiState( + source: LibraryLocalTracksState, + query: String +): LibraryLocalTracksState { + val normalized = query.trim() + val folder = source.folders.firstOrNull { it.path == source.currentFolderPath } + val folders = source.folders.filter { + normalized.isEmpty() || it.name.contains( + normalized, + true + ) || it.tracks.any { track -> localTrackMatches(track, normalized) } + } + val tracks = folder?.tracks.orEmpty().filter { localTrackMatches(it, normalized) } + return source.copy( + query = query, + currentFolder = folder, + visibleFolders = folders.map { + LocalFolderUi( + it.path, + it.name, + it.trackCount, + it.tracks.firstOrNull { track -> track.coverBytes?.isNotEmpty() == true }?.coverBytes + ) + }, + visibleTracks = tracks.map { track -> + val seconds = track.durationMs / 1000 + LocalTrackUi( + track.path, track.name, + listOfNotNull( + track.artists.joinToString(", ").takeIf { it.isNotBlank() }, + track.album?.takeIf { it.isNotBlank() }).joinToString(" • ") + .takeIf { it.isNotBlank() }, + if (seconds > 0) "${seconds / 60}:${ + (seconds % 60).toString().padStart(2, '0') + }" else null, + track.coverBytes?.takeIf { it.isNotEmpty() }) + }, + totalFolderCount = source.folders.size, + totalTrackCount = source.folders.sumOf { it.trackCount }, + canPlayFolder = source.canAccessMedia && tracks.isNotEmpty() + ) +} + +private fun localTrackMatches(track: LocalMediaTrack, query: String): Boolean = query.isBlank() || + track.name.contains(query, true) || track.album?.contains( + query, + true +) == true || track.artists.any { it.contains(query, true) } + +class LibraryLocalTracksViewModel( + private val coordinator: LocalMediaLibraryCoordinator, + private val libraryState: LibraryState +) : ViewModel() { + private val requiresPermission = getPlatform().type == PlatformType.Android + private val _state = + MutableStateFlow(LibraryLocalTracksState(canAccessMedia = !requiresPermission)) + val state: StateFlow = combine( + _state, libraryState.queryFlow(LibraryTab.LocalTracks) + ) { state, query -> localTracksUiState(state, query) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), _state.value) + + init { + viewModelScope.launch { + libraryState.localRefreshRequests.collect { refresh() } + } + viewModelScope.launch { + coordinator.cacheState + .collect { cache -> + _state.update { + it.copy( + folders = cache.folders, + error = null + ) + } + } + } + + refreshIfEmpty() + refreshIfStale() + } + + fun onQueryChange(query: String) { + libraryState.setQuery(LibraryTab.LocalTracks, query) + } + + fun onPermissionChanged(granted: Boolean) { + val accessible = !requiresPermission || granted + val wasAccessible = _state.value.canAccessMedia + _state.update { it.copy(canAccessMedia = accessible) } + if (accessible && !wasAccessible) refresh() + } + + fun refresh() { + if (!_state.value.canAccessMedia || _state.value.isRefreshing) return + _state.update { it.copy(isRefreshing = true, error = null) } + libraryState.setLocalRefreshing(true) + viewModelScope.launch { + try { + runCatching { + coordinator.refreshNow("manual") + }.onFailure { throwable -> + if (throwable is CancellationException) throw throwable + val message = + throwable.message ?: getString(Res.string.library_local_refresh_failed) + _state.update { + it.copy(error = message) + } + } + } finally { + _state.update { it.copy(isRefreshing = false) } + libraryState.setLocalRefreshing(false) + } + } + } + + private fun refreshIfEmpty() { + if (_state.value.folders.isNotEmpty()) return + refresh() + } + + private fun refreshIfStale() { + if (!_state.value.canAccessMedia) return + viewModelScope.launch { + runCatching { + coordinator.refreshIfStale() + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderScreen.kt new file mode 100644 index 00000000..d3583913 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderScreen.kt @@ -0,0 +1,35 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.local_tracks + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.component.ApplicationMainBar + +@Composable +fun LocalFolderScreen(viewModel: LocalFolderViewModel, onBack: () -> Unit) { + val state by viewModel.state.collectAsStateWithLifecycle() + Column { + ApplicationMainBar( + backButton = true, + title = { Text(state.currentFolder?.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis) } + ) + LocalTracksContent( + state, + onOpenFolder = {}, + onPlayTrack = viewModel::playTrack, + onNavigateUp = onBack, + onPlayFolder = viewModel::playFolder, + onRequestPermission = {}, + modifier = Modifier.padding(horizontal = 12.dp) + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderViewModel.kt new file mode 100644 index 00000000..01e4f6d9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderViewModel.kt @@ -0,0 +1,66 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.local_tracks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack + +class LocalFolderViewModel( + private val folderPath: String, + coordinator: LocalMediaLibraryCoordinator, + private val audioPlayerQueue: AudioPlayerQueue +) : ViewModel() { + val state: StateFlow = coordinator.cacheState + .map { cache -> + localTracksUiState( + LibraryLocalTracksState(folders = cache.folders, currentFolderPath = folderPath), + "" + ) + } + .stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5_000), + localTracksUiState(LibraryLocalTracksState(currentFolderPath = folderPath), "") + ) + + fun playFolder() = play(0) + + fun playTrack(trackPath: String) { + val index = tracks().indexOfFirst { it.path == trackPath } + if (index >= 0) play(index) + } + + private fun tracks(): List = + state.value.currentFolder?.tracks.orEmpty() + + private fun play(startPosition: Int) { + val tracks = tracks() + if (tracks.isEmpty()) return + viewModelScope.launch { + audioPlayerQueue.load( + entries = tracks.map { + QueueEntry.LocalTrack( + name = it.name, + artists = it.artists, + duration = it.durationMs, + album = it.album, + coverBytes = it.coverBytes, + url = it.path + ) + }, + autoPlay = true, + startPosition = startPosition, + collectionEntry = null + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt new file mode 100644 index 00000000..44775f22 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.local_tracks.media + +import androidx.datastore.preferences.core.edit +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.db.DatabaseKeys +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json + +class LocalMediaCacheRepository(private val database: Database) { + private val json = Json { + ignoreUnknownKeys = true + } + + val cacheFlow: Flow = database.localMediaDataStore.data.map { prefs -> + prefs[DatabaseKeys.LOCAL_MEDIA_CACHE_STATE_KEY]?.let { raw -> + runCatching { + json.decodeFromString(raw) + }.getOrDefault(LocalMediaCache()) + } ?: LocalMediaCache() + } + + suspend fun save(cache: LocalMediaCache) { + database.localMediaDataStore.edit { prefs -> + prefs[DatabaseKeys.LOCAL_MEDIA_CACHE_STATE_KEY] = json.encodeToString(cache) + prefs[DatabaseKeys.LOCAL_MEDIA_LAST_SCAN_AT_KEY] = cache.indexedAtEpochMs + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt new file mode 100644 index 00000000..9de2054d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.local_tracks.media + +typealias LocalMediaChangeCallback = () -> Unit + +fun interface LocalMediaObservation { + fun stop() +} + +interface LocalMediaDiscoveryService { + suspend fun discoverFolders(roots: List): List + suspend fun ensureInitialized() {} + fun observeChanges(roots: List, onChanged: LocalMediaChangeCallback): LocalMediaObservation? +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt new file mode 100644 index 00000000..9b0f1740 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.local_tracks.media + +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class LocalMediaFoldersConfig( + private val settingsRepository: SettingsRepository, + private val paths: Paths) { + val configuredRootsFlow: Flow> = settingsRepository.userSettings.map { settings -> + val cacheRoot = if (settings.enableMusicCaching) { + listOf(settings.cacheFolder ?: paths.getMusicCacheDirPath()) + } else { + emptyList() + } + (listOfNotNull(settings.overloadedDownloadFolder) + settings.localMediaFolders + cacheRoot) + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt new file mode 100644 index 00000000..f2d17713 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.local_tracks.media + +import org.spotube.spotube.core.di.injectLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.time.Clock +import org.koin.core.component.KoinComponent + +class LocalMediaLibraryCoordinator( + private val discoveryService: LocalMediaDiscoveryService, + private val cacheRepository: LocalMediaCacheRepository, + private val foldersConfig: LocalMediaFoldersConfig) : KoinComponent { + private val logger by injectLogger() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _cacheState = MutableStateFlow(LocalMediaCache()) + val cacheState: StateFlow = _cacheState.asStateFlow() + + private var observation: LocalMediaObservation? = null + private var periodicJob: Job? = null + private var configuredRoots: List = emptyList() + private val refreshMutex = Mutex() + + init { + scope.launch { + cacheRepository.cacheFlow.collect { cache -> + _cacheState.value = cache + } + } + + scope.launch { + discoveryService.ensureInitialized() + foldersConfig.configuredRootsFlow.collect { roots -> + configuredRoots = roots + observation?.stop() + observation = discoveryService.observeChanges(roots) { + scheduleRefresh(reason = "filesystem_change") + } + scheduleRefresh(reason = "roots_changed") + } + } + + periodicJob = scope.launch { + while (true) { + delay(PERIODIC_REFRESH_MS) + refreshIfStale() + } + } + } + + fun scheduleRefresh(reason: String) { + scope.launch { + runCatching { refreshNow(reason) } + .onFailure { throwable -> + logger.w(throwable) { "Local media refresh failed. reason=$reason" } + } + } + } + + suspend fun refreshIfStale() { + val lastIndexedAt = _cacheState.value.indexedAtEpochMs + val now = Clock.System.now().toEpochMilliseconds() + if (now - lastIndexedAt >= STALE_AFTER_MS) { + refreshNow("stale") + } + } + + suspend fun refreshNow(reason: String) { + refreshMutex.withLock { + logger.i { "Refreshing local media cache. reason=$reason" } + runCatching { + discoveryService.discoverFolders(configuredRoots) + }.onSuccess { folders -> + val cache = LocalMediaCache( + folders = folders, + indexedAtEpochMs = Clock.System.now().toEpochMilliseconds()) + cacheRepository.save(cache) + _cacheState.value = cache + }.onFailure { throwable -> + logger.w(throwable) { "Failed to discover local media. reason=$reason" } + } + } + } + + companion object { + private const val PERIODIC_REFRESH_MS = 15 * 60 * 1000L + private const val STALE_AFTER_MS = 30 * 60 * 1000L + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaModels.kt new file mode 100644 index 00000000..e6ad9ee6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaModels.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.local_tracks.media + +import kotlinx.serialization.Serializable + +@Serializable +data class LocalMediaTrack( + val path: String, + val name: String, + val artists: List, + val durationMs: Long, + val album: String? = null, + val coverBytes: ByteArray? = null) + +@Serializable +data class LocalMediaFolder( + val path: String, + val name: String, + val tracks: List) { + val trackCount: Int + get() = tracks.size +} + +@Serializable +data class LocalMediaCache( + val folders: List = emptyList(), + val indexedAtEpochMs: Long = 0) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt new file mode 100644 index 00000000..1f22bca0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.local_tracks.media + +import androidx.compose.runtime.Composable + +data class LocalMediaPermissionState( + val isGranted: Boolean, + val requestPermission: () -> Unit) + +@Composable +expect fun rememberLocalMediaPermissionState(): LocalMediaPermissionState diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt new file mode 100644 index 00000000..c2c7cd5e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.local_tracks.media + +class NoopLocalMediaDiscoveryService : LocalMediaDiscoveryService { + override suspend fun discoverFolders(roots: List): List { + return emptyList() + } + + override fun observeChanges( + roots: List, + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { + return null + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/AddToPlaylistPicker.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/AddToPlaylistPicker.kt new file mode 100644 index 00000000..09e3a729 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/AddToPlaylistPicker.kt @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2026 Kingmor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.playlist + + +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumTextField +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.SheetDetent +import com.composeunstyled.Text +import com.composeunstyled.rememberModalBottomSheetState +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_add_to_playlist_empty +import spotube.composeapp.generated.resources.library_add_to_playlist_loading +import spotube.composeapp.generated.resources.library_add_to_playlist_search_content_description +import spotube.composeapp.generated.resources.library_add_to_playlist_search_placeholder +import spotube.composeapp.generated.resources.library_add_to_playlist_title +import spotube.composeapp.generated.resources.library_local_tracks_count +import spotube.composeapp.generated.resources.common_cancel + +@Composable +fun AddToPlaylistPicker( + visible: Boolean, + currentUserId: String?, + onDismiss: () -> Unit, + onPlaylistSelected: (String) -> Unit, + modifier: Modifier = Modifier, + libraryRepository: LibraryRepository = koinInject()) { + if (!visible) return + + val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium + + var playlists by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var searchQuery by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + val searchState = rememberTextFieldState(searchQuery) + + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .drop(1) + .collect { searchQuery = it } + } + + LaunchedEffect(visible, currentUserId) { + if (visible) { + isLoading = true + scope.launch { + val result = libraryRepository.savedPlaylists() + playlists = result?.items?.filter { + currentUserId != null && it.owner?.id == currentUserId + } ?: emptyList() + isLoading = false + } + } + } + + val filteredPlaylists = remember(playlists, searchQuery) { + if (searchQuery.isBlank()) { + playlists + } else { + playlists.filter { + it.title.contains(searchQuery, ignoreCase = true) || + it.description?.contains(searchQuery, ignoreCase = true) == true + } + } + } + + val content: @Composable () -> Unit = { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + imageVector = Iconsax.IconsaxFilterSearch, + contentDescription = stringResource(Res.string.library_add_to_playlist_search_content_description)) + MillenniumTextField( + state = searchState, + + modifier = Modifier.weight(1f), + placeholder = stringResource(Res.string.library_add_to_playlist_search_placeholder)) + } + + PlaylistPickerContent( + playlists = filteredPlaylists, + isLoading = isLoading, + onPlaylistSelected = { playlistId -> + onPlaylistSelected(playlistId) + onDismiss() + }) + } + } + + val pickerTitle = stringResource(Res.string.library_add_to_playlist_title) + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + modifier = modifier, + dialogTitle = pickerTitle, + title = { Text(pickerTitle, style = AppTheme.typography.headlineSmall) }, + actions = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.common_cancel)) + } + } + }) { + Box(modifier = Modifier.fillMaxWidth().height(400.dp)) { + content() + } + } +} + +@Composable +private fun PlaylistPickerContent( + playlists: List, + isLoading: Boolean, + onPlaylistSelected: (String) -> Unit) { + if (isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(stringResource(Res.string.library_add_to_playlist_loading)) + } + } else if (playlists.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(stringResource(Res.string.library_add_to_playlist_empty)) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp)) { + items(playlists, key = { it.id }) { playlist -> + PlaylistPickerRow( + playlist = playlist, + onClick = { onPlaylistSelected(playlist.id) }) + } + } + } +} + +@Composable +private fun PlaylistPickerRow( + playlist: MetadataPlaylist, + onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val imageUrl = playlist.thumbnails.firstOrNull()?.url + if (imageUrl != null) { + AsyncImage( + model = imageUrl, + contentDescription = playlist.title, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop) + } else { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center) { + Text( + text = playlist.title.take(1).uppercase(), + style = AppTheme.typography.titleMedium) + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = playlist.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Text( + text = pluralStringResource(Res.plurals.library_local_tracks_count, playlist.trackCount, playlist.trackCount), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt new file mode 100644 index 00000000..85a7f52a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -0,0 +1,96 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.playlist + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.spotube.core.ui.component.ErrorDisplay +import org.spotube.spotube.core.ui.component.LikedTracksCard +import org.spotube.spotube.core.ui.component.PlaylistCard +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.modules.library.LibraryMediaCollection +import org.spotube.spotube.modules.library.LibraryViewMode +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.* + +@Composable +fun LibraryPlaylistsScreen( + modifier: Modifier = Modifier, + showCreatePlaylist: Boolean = false, + viewMode: LibraryViewMode = LibraryViewMode.Grid, + viewModel: LibraryPlaylistsViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val playlistFormState by viewModel.playlistFormState.collectAsStateWithLifecycle() + Column(modifier) { + when (val current = state) { + is LibraryPlaylistsState.Error -> Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh) + } + + else -> { + val data = current as? LibraryPlaylistsState.Data + if (data != null && data.items.isEmpty() && current !is LibraryPlaylistsState.Data.LoadingMore) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (data.query.isNotBlank()) stringResource( + Res.string.library_playlists_no_results, + data.query + ) + else stringResource(Res.string.library_playlists_empty) + ) + } + } else { + LibraryMediaCollection( + items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode, + loading = current is LibraryPlaylistsState.Loading, + loadingMore = current is LibraryPlaylistsState.Data.LoadingMore, + onViewportChanged = viewModel::onItemsVisible, + header = { list -> + LikedTracksCard( + Modifier.fillMaxWidth(), + listMode = list + ) + }, + itemContent = { item, list -> + PlaylistCard( + item, + Modifier.fillMaxWidth(), + listMode = list + ) + }, + skeleton = { list -> + PlayableCard( + stringResource(Res.string.media_preview_title), + stringResource(Res.string.media_preview_subtitle), + modifier = Modifier.fillMaxWidth(), + listMode = list + ) + } + ) + } + } + } + PlaylistFormSheet( + visible = showCreatePlaylist, + formState = playlistFormState, + onDismiss = viewModel::dismissCreatePlaylist, + onSubmit = viewModel::createPlaylist, + onImagePicked = viewModel::onPlaylistFormImagePicked + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt new file mode 100644 index 00000000..2503abf9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.playlist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import io.github.vinceglb.filekit.PlatformFile +import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error + +sealed interface LibraryPlaylistsState { + data object Loading : LibraryPlaylistsState + + sealed interface Data : LibraryPlaylistsState { + val query: String + val allItems: List + val items: List + val nextPagination: PaginationStrategy? + + data class Loaded( + override val query: String = "", + override val allItems: List = emptyList(), + override val nextPagination: PaginationStrategy? = null + ) : Data { + override val items: List = if (query.isNotBlank()) { + allItems.filter { + it.title.contains(query, ignoreCase = true) || + (it.description?.contains(query, ignoreCase = true) == true) + } + } else { + allItems + } + + fun toLoadingMore(): LoadingMore = LoadingMore( + query = query, + allItems = allItems, + nextPagination = nextPagination + ) + } + + data class LoadingMore( + override val query: String = "", + override val allItems: List = emptyList(), + override val nextPagination: PaginationStrategy? = null + ) : Data { + override val items: List = if (query.isNotBlank()) { + allItems.filter { + it.title.contains(query, ignoreCase = true) || + (it.description?.contains(query, ignoreCase = true) == true) + } + } else { + allItems + } + } + } + + data class Error(val message: String) : LibraryPlaylistsState +} + +@OptIn(ExperimentalCoroutinesApi::class) +class LibraryPlaylistsViewModel( + private val repository: LibraryRepository, + private val libraryState: LibraryState +) : ViewModel(), KoinComponent { + private val logger by injectLogger() + + private val _state = MutableStateFlow(LibraryPlaylistsState.Loading) + val uiState: StateFlow = combine( + _state, libraryState.queryFlow(LibraryTab.Playlists) + ) { state, query -> + when (state) { + is LibraryPlaylistsState.Data.Loaded -> state.copy(query = query) + is LibraryPlaylistsState.Data.LoadingMore -> state.copy(query = query) + else -> state + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryPlaylistsState.Loading) + + init { + viewModelScope.launch { + combine( + repository.pluginManager.selectedMetadataPlugin + .filterNotNull() + .flatMapLatest { it.loggedInFlow } + .distinctUntilChanged(), + repository.savedPlaylistIdsFlow + ) { _, _ -> }.collect { + loadInitialData() + } + } + } + + private suspend fun loadInitialData() = runCatching { + _state.value = LibraryPlaylistsState.Loading + val result = repository.savedPlaylists() + _state.value = LibraryPlaylistsState.Data.Loaded( + allItems = result?.items ?: emptyList(), + nextPagination = result?.nextPagination + ) + }.onFailure { e -> + logger.e(e) { "Failed to load playlists" } + _state.value = LibraryPlaylistsState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + + suspend fun loadMoreData() = runCatching { + val currentState = _state.value + if (currentState is LibraryPlaylistsState.Data.Loaded && currentState.nextPagination != null) { + _state.value = currentState.toLoadingMore() + val result = repository.savedPlaylists(currentState.nextPagination) + _state.value = LibraryPlaylistsState.Data.Loaded( + query = currentState.query, + allItems = currentState.allItems + (result?.items ?: emptyList()), + nextPagination = result?.nextPagination + ) + } + }.onFailure { e -> + logger.e(e) { "Failed to load more playlists" } + _state.value = LibraryPlaylistsState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + + fun onQueryChange(query: String) { + libraryState.setQuery(LibraryTab.Playlists, query) + } + + fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) { + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) { + viewModelScope.launch { loadMoreData() } + } + } + + private val playlistForm = PlaylistFormController(viewModelScope, logger) + val playlistFormState: StateFlow = playlistForm.uiState + + fun onPlaylistFormImagePicked(file: PlatformFile) = playlistForm.onImagePicked(file) + + fun dismissCreatePlaylist() { + if (playlistFormState.value.isSubmitting) return + playlistForm.dismiss() + libraryState.dismissCreatePlaylist() + } + + fun createPlaylist(data: PlaylistFormData) { + playlistForm.submit(onSuccess = libraryState::dismissCreatePlaylist) { + repository.createPlaylist( + name = data.name, + description = data.description.ifBlank { null }, + isPublic = data.isPublic, + isCollaborating = data.isCollaborating, + imageBase64 = data.imageBase64, + trackIds = emptyList() + ) + repository.invalidateCaches() + loadInitialData() + } + } + + fun refresh() { + viewModelScope.launch { + repository.invalidateCaches() + loadInitialData() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormController.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormController.kt new file mode 100644 index 00000000..ed60c201 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormController.kt @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.playlist + +import co.touchlab.kermit.Logger +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.readBytes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okio.ByteString.Companion.toByteString + +data class PlaylistFormUiState( + val isVisible: Boolean = false, + val isSubmitting: Boolean = false, + val isProcessingImage: Boolean = false, + /** Base64 of an image picked in this session; `null` keeps the playlist's current cover. */ + val pickedImageBase64: String? = null +) + +/** + * Async state of [PlaylistFormSheet] (visibility, image encoding, submission), owned by the + * ViewModel that opens the sheet and driven on its [scope]. + */ +class PlaylistFormController( + private val scope: CoroutineScope, + private val logger: Logger +) { + private val _uiState = MutableStateFlow(PlaylistFormUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun show() { + _uiState.value = PlaylistFormUiState(isVisible = true) + } + + fun dismiss() { + if (_uiState.value.isSubmitting) return + _uiState.value = PlaylistFormUiState() + } + + fun onImagePicked(file: PlatformFile) { + if (_uiState.value.isProcessingImage) return + _uiState.update { it.copy(isProcessingImage = true) } + scope.launch { + runCatching { + withContext(Dispatchers.Default) { file.readBytes().toByteString().base64() } + }.onSuccess { base64 -> + _uiState.update { it.copy(pickedImageBase64 = base64) } + }.onFailure { e -> + logger.e(e) { "Failed to read playlist cover image" } + } + _uiState.update { it.copy(isProcessingImage = false) } + } + } + + /** + * Runs [action]; the sheet closes (and [onSuccess] runs) only if it succeeds, and stays open + * (re-enabled) on failure. + */ + fun submit(onSuccess: () -> Unit = {}, action: suspend () -> Unit) { + val current = _uiState.value + if (current.isSubmitting || current.isProcessingImage) return + _uiState.update { it.copy(isSubmitting = true) } + scope.launch { + runCatching { action() } + .onSuccess { + _uiState.value = PlaylistFormUiState() + onSuccess() + } + .onFailure { e -> + logger.e(e) { "Failed to submit playlist form" } + _uiState.update { it.copy(isSubmitting = false) } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt new file mode 100644 index 00000000..7ef9e130 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt @@ -0,0 +1,312 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.playlist + +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import androidx.compose.foundation.clickable +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumSwitch +import org.spotube.millennium.composables.MillenniumTextField +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.SheetDetent +import com.composeunstyled.Text +import com.composeunstyled.rememberModalBottomSheetState +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher +import io.github.vinceglb.filekit.PlatformFile +import kotlinx.coroutines.flow.drop +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_playlist_form_change_image +import spotube.composeapp.generated.resources.library_playlist_form_collaborative +import spotube.composeapp.generated.resources.library_playlist_form_collaborative_description +import spotube.composeapp.generated.resources.library_playlist_form_cover_content_description +import spotube.composeapp.generated.resources.library_playlist_form_cover_image +import spotube.composeapp.generated.resources.library_playlist_form_create +import spotube.composeapp.generated.resources.library_playlist_form_create_title +import spotube.composeapp.generated.resources.library_playlist_form_description_label +import spotube.composeapp.generated.resources.library_playlist_form_description_placeholder +import spotube.composeapp.generated.resources.library_playlist_form_edit_title +import spotube.composeapp.generated.resources.library_playlist_form_name_label +import spotube.composeapp.generated.resources.library_playlist_form_name_placeholder +import spotube.composeapp.generated.resources.library_playlist_form_no_image +import spotube.composeapp.generated.resources.library_playlist_form_pick_image +import spotube.composeapp.generated.resources.library_playlist_form_public +import spotube.composeapp.generated.resources.library_playlist_form_public_description +import spotube.composeapp.generated.resources.common_cancel +import spotube.composeapp.generated.resources.common_save + +data class PlaylistFormData( + val name: String = "", + val description: String = "", + val isPublic: Boolean = true, + val isCollaborating: Boolean = false, + val imageBase64: String = "", + val imagePreviewUrl: String? = null +) + +@Composable +fun PlaylistFormSheet( + visible: Boolean, + formState: PlaylistFormUiState, + onDismiss: () -> Unit, + onSubmit: (PlaylistFormData) -> Unit, + onImagePicked: (PlatformFile) -> Unit, + modifier: Modifier = Modifier, + initialData: PlaylistFormData? = null, + isEditing: Boolean = false +) { + if (!visible) return + + val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium + + var name by remember(initialData) { mutableStateOf(initialData?.name ?: "") } + var description by remember(initialData) { mutableStateOf(initialData?.description ?: "") } + var isPublic by remember(initialData) { mutableStateOf(initialData?.isPublic ?: true) } + var isCollaborating by remember(initialData) { + mutableStateOf( + initialData?.isCollaborating ?: false + ) + } + val imageBase64 = formState.pickedImageBase64 ?: initialData?.imageBase64.orEmpty() + val imagePreviewUrl = if (formState.pickedImageBase64 != null) null else initialData?.imagePreviewUrl + + val nameState = rememberTextFieldState(initialData?.name ?: "") + val descriptionState = rememberTextFieldState(initialData?.description ?: "") + + LaunchedEffect(nameState) { + snapshotFlow { nameState.text.toString() } + .drop(1) + .collect { name = it } + } + LaunchedEffect(descriptionState) { + snapshotFlow { descriptionState.text.toString() } + .drop(1) + .collect { description = it } + } + + val imagePicker = rememberFilePickerLauncher( + type = FileKitType.Image + ) { file -> + if (file != null) onImagePicked(file) + } + + val currentData = PlaylistFormData( + name = name, + description = description, + isPublic = isPublic, + isCollaborating = isCollaborating, + imageBase64 = imageBase64, + imagePreviewUrl = imagePreviewUrl + ) + + val handleImagePick = { imagePicker.launch() } + + val formTitle = stringResource( + if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title) + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + modifier = modifier, + dialogTitle = formTitle, + title = { Text(formTitle, style = AppTheme.typography.headlineSmall) }, + actions = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { + MillenniumButton( + onClick = onDismiss, + enabled = !formState.isSubmitting, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.common_cancel)) + } + MillenniumButton( + onClick = { onSubmit(currentData) }, + enabled = name.isNotBlank() && !formState.isProcessingImage, + loading = formState.isSubmitting) { + Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create)) + } + } + }) { + PlaylistFormFields( + nameState = nameState, + descriptionState = descriptionState, + isPublic = isPublic, + onIsPublicChange = { isPublic = it }, + isCollaborating = isCollaborating, + onIsCollaboratingChange = { isCollaborating = it }, + imageBase64 = imageBase64, + imagePreviewUrl = imagePreviewUrl, + isProcessingImage = formState.isProcessingImage, + isImagePickEnabled = !formState.isSubmitting, + onPickImage = handleImagePick) + } +} + +@Composable +private fun PlaylistFormFields( + nameState: TextFieldState, + descriptionState: TextFieldState, + isPublic: Boolean, + onIsPublicChange: (Boolean) -> Unit, + isCollaborating: Boolean, + onIsCollaboratingChange: (Boolean) -> Unit, + imageBase64: String, + imagePreviewUrl: String?, + isProcessingImage: Boolean, + isImagePickEnabled: Boolean, + onPickImage: () -> Unit +) { + // AdaptiveDialogBottomSheet already scrolls its content; a second scroll here nests + // scrollables and crashes with infinite height constraints. + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + MillenniumTextField( + state = nameState, + label = stringResource(Res.string.library_playlist_form_name_label), + modifier = Modifier.fillMaxWidth(), + placeholder = stringResource(Res.string.library_playlist_form_name_placeholder) + ) + + MillenniumTextField( + state = descriptionState, + label = stringResource(Res.string.library_playlist_form_description_label), + modifier = Modifier.fillMaxWidth(), + placeholder = stringResource(Res.string.library_playlist_form_description_placeholder) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { onIsPublicChange(!isPublic) } + .padding(horizontal = 4.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(Res.string.library_playlist_form_public), style = AppTheme.typography.bodyLarge) + Text( + stringResource(Res.string.library_playlist_form_public_description), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + MillenniumSwitch(checked = isPublic, onCheckedChange = onIsPublicChange) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { onIsCollaboratingChange(!isCollaborating) } + .padding(horizontal = 4.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(Res.string.library_playlist_form_collaborative), style = AppTheme.typography.bodyLarge) + Text( + stringResource(Res.string.library_playlist_form_collaborative_description), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + MillenniumSwitch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange) + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(stringResource(Res.string.library_playlist_form_cover_image), style = AppTheme.typography.bodyLarge) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + when { + imagePreviewUrl != null -> AsyncImage( + model = imagePreviewUrl, + contentDescription = stringResource(Res.string.library_playlist_form_cover_content_description), + modifier = Modifier.size(64.dp), + contentScale = ContentScale.Crop + ) + + imageBase64.isNotBlank() -> { + Text( + "✓", + style = AppTheme.typography.titleMedium, + color = AppTheme.colors.primary + ) + } + + else -> { + Text( + stringResource(Res.string.library_playlist_form_no_image), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } + } + MillenniumButton( + onClick = onPickImage, + enabled = isImagePickEnabled, + loading = isProcessingImage) { + Text(stringResource(if (imageBase64.isNotBlank() || imagePreviewUrl != null) Res.string.library_playlist_form_change_image else Res.string.library_playlist_form_pick_image)) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsScreen.kt new file mode 100644 index 00000000..e9df67d5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsScreen.kt @@ -0,0 +1,348 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.lyrics + + +import org.spotube.millennium.composables.MillenniumBarsLoader +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSegmentedButton +import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults +import org.spotube.millennium.composables.MillenniumSegmentedButtonRow +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import coil3.compose.AsyncImage +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine +import org.spotube.spotube.core.ui.component.ApplicationBackButton +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error +import spotube.composeapp.generated.resources.lyrics_mode_plain +import spotube.composeapp.generated.resources.lyrics_mode_synced +import spotube.composeapp.generated.resources.lyrics_no_plain +import spotube.composeapp.generated.resources.lyrics_no_synced +import spotube.composeapp.generated.resources.common_close + +@Composable +fun LyricsScreen( + viewModel: LyricsViewModel = koinViewModel(), + modifier: Modifier = Modifier, + onClose: (() -> Unit)? = null) { + val uiState by viewModel.uiState.collectAsState() + val colors = MillenniumTheme.colors + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to colors.accent.copy(alpha = 0.14f), + 0.55f to colors.background, + 1f to colors.background + ) + ) + ) { + val isWide = maxWidth >= 840.dp + + Column(Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.width(48.dp), contentAlignment = Alignment.CenterStart) { + if (onClose != null) { + MillenniumIconButton( + onClick = onClose, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = stringResource(Res.string.common_close) + ) + } + } else { + ApplicationBackButton() + } + } + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + MillenniumSegmentedButtonRow(Modifier.widthIn(max = 280.dp)) { + MillenniumSegmentedButton( + selected = uiState.mode == LyricType.SYNCED, + onClick = { viewModel.setMode(LyricType.SYNCED) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2), + modifier = Modifier.weight(1f) + ) { Text(stringResource(Res.string.lyrics_mode_synced)) } + MillenniumSegmentedButton( + selected = uiState.mode == LyricType.STATIC, + onClick = { viewModel.setMode(LyricType.STATIC) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2), + modifier = Modifier.weight(1f) + ) { Text(stringResource(Res.string.lyrics_mode_plain)) } + } + } + Spacer(Modifier.width(48.dp)) + } + + if (isWide) { + Row( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 40.dp), + horizontalArrangement = Arrangement.spacedBy(40.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TrackPane(uiState, Modifier.weight(0.38f)) + Column(Modifier.weight(0.62f).fillMaxHeight()) { + LyricsBody(uiState, viewModel) + } + } + } else { + CompactTrackHeader( + uiState, + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 4.dp) + ) + Column( + Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + LyricsBody(uiState, viewModel) + } + } + } + } +} + +@Composable +private fun ColumnScope.LyricsBody(uiState: LyricsUiState, viewModel: LyricsViewModel) { + when { + uiState.isLoading -> CenteredMessage { + MillenniumBarsLoader(size = 48.dp) + } + + uiState.error != null -> CenteredMessage { + Text( + text = uiState.error ?: stringResource(Res.string.common_unknown_error), + style = AppTheme.typography.bodyLarge, + color = MillenniumTheme.colors.danger, + textAlign = TextAlign.Center + ) + } + + uiState.mode == LyricType.SYNCED -> SyncedLyricsContent( + syncedLyrics = uiState.syncedLyrics ?: emptyList(), + currentIndex = uiState.currentIndex, + onLineClick = viewModel::seekToLine + ) + + uiState.mode == LyricType.STATIC -> PlainLyricsContent( + plainLyrics = uiState.plainLyrics ?: "" + ) + } +} + +@Composable +private fun CoverArt(url: String, size: Dp, radius: Dp, elevation: Dp, modifier: Modifier = Modifier) { + val colors = MillenniumTheme.colors + Box( + modifier = modifier + .size(size) + .millenniumSurface( + background = millenniumPanelBrush(colors), + shape = RoundedCornerShape(radius), + borderColor = colors.border, + borderWidth = 1.dp, + shadowElevation = elevation + ) + ) { + AsyncImage( + model = url.takeIf { it.isNotBlank() }, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } +} + +@Composable +private fun TrackPane(uiState: LyricsUiState, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CoverArt( + url = uiState.coverUrl, + size = 280.dp, + radius = 24.dp, + elevation = 14.dp + ) + Spacer(Modifier.height(24.dp)) + Text( + text = uiState.trackTitle, + style = AppTheme.typography.headlineSmall, + color = MillenniumTheme.colors.text, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(4.dp)) + Text( + text = uiState.trackArtists, + style = AppTheme.typography.bodyLarge, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun CompactTrackHeader(uiState: LyricsUiState, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CoverArt(url = uiState.coverUrl, size = 48.dp, radius = 12.dp, elevation = 4.dp) + Column(Modifier.weight(1f)) { + Text( + text = uiState.trackTitle, + style = AppTheme.typography.titleSmall, + color = MillenniumTheme.colors.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = uiState.trackArtists, + style = AppTheme.typography.bodySmall, + color = MillenniumTheme.colors.muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun ColumnScope.CenteredMessage(content: @Composable () -> Unit) { + Box( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentAlignment = Alignment.Center + ) { content() } +} + +@Composable +private fun ColumnScope.SyncedLyricsContent( + syncedLyrics: List, + currentIndex: Int, + onLineClick: (LyricsLine) -> Unit) { + if (syncedLyrics.isEmpty()) { + CenteredMessage { + Text( + text = stringResource(Res.string.lyrics_no_synced), + style = AppTheme.typography.bodyLarge, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center + ) + } + return + } + + SyncedLyricsList( + lines = syncedLyrics, + currentIndex = currentIndex, + onLineClick = onLineClick, + modifier = Modifier.weight(1f).fillMaxWidth() + ) +} + +@Composable +private fun ColumnScope.PlainLyricsContent( + plainLyrics: String) { + if (plainLyrics.isBlank()) { + CenteredMessage { + Text( + text = stringResource(Res.string.lyrics_no_plain), + style = AppTheme.typography.bodyLarge, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center + ) + } + return + } + + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 20.dp) + ) { + item { + Text( + text = plainLyrics, + modifier = Modifier.fillMaxWidth(), + style = AppTheme.typography.titleMedium.copy(lineHeight = 34.sp), + color = MillenniumTheme.colors.text, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsViewModel.kt new file mode 100644 index 00000000..41c7654a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsViewModel.kt @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.lyrics + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.plugin.PluginManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlin.time.Duration.Companion.milliseconds + +@Serializable +enum class LyricType { + STATIC, + SYNCED +} + +data class LyricsUiState( + val mode: LyricType = LyricType.SYNCED, + val syncedLyrics: List? = null, + val plainLyrics: String? = null, + val isLoading: Boolean = false, + val error: String? = null, + val currentIndex: Int = -1, + val trackTitle: String = "", + val trackArtists: String = "", + val coverUrl: String = "") + +class LyricsViewModel( + private val pluginManager: PluginManager, + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() { + + private val _uiState = MutableStateFlow(LyricsUiState()) + + // Only re-emits when the active line changes, not on every position tick. + private val currentIndexFlow = combine( + _uiState.map { it.syncedLyrics }.distinctUntilChanged(), + audioPlayer.positionFlow + ) { lyrics, position -> activeLineIndex(lyrics, position.inWholeMilliseconds) } + .distinctUntilChanged() + + val uiState: StateFlow = combine(_uiState, currentIndexFlow) { state, index -> + state.copy(currentIndex = index) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LyricsUiState()) + + private var currentTrackId: String? = null + + fun setMode(mode: LyricType) { + _uiState.update { it.copy(mode = mode) } + } + + fun loadLyrics(forceReload: Boolean = false) { + viewModelScope.launch { + val entry = audioPlayerQueue.currentQueueEntryFlow.value ?: return@launch + val track = when (entry) { + is QueueEntry.StreamingTrack -> entry.track + is QueueEntry.LocalTrack -> return@launch + } + + if (!forceReload && currentTrackId == track.id && _uiState.value.syncedLyrics != null) { + return@launch + } + + currentTrackId = track.id + _uiState.update { it.copy(isLoading = true, error = null) } + try { + pluginManager.selectedLyricsPlugin.value?.let { pluginService -> + pluginService.use { + val lyrics = lyricsAPI.getLyrics(track) + + _uiState.update { + it.copy( + syncedLyrics = lyrics?.syncedLyrics, + plainLyrics = lyrics?.plainLyrics, + isLoading = false) + } + } + } ?: run { + _uiState.update { it.copy(isLoading = false) } + } + } catch (e: Exception) { + _uiState.update { it.copy(isLoading = false, error = e.message) } + } + } + } + + init { + viewModelScope.launch { + audioPlayerQueue.currentQueueEntryFlow.collect { entry -> + val trackId = when (entry) { + is QueueEntry.StreamingTrack -> entry.track.id + else -> null + } + if (entry is QueueEntry.StreamingTrack) { + val track = entry.track + _uiState.update { + it.copy( + trackTitle = track.title, + trackArtists = track.artists.joinToString(", ") { artist -> artist.name }, + coverUrl = (track.album?.thumbnails ?: track.thumbnails) + ?.firstOrNull()?.url.orEmpty() + ) + } + } + if (trackId != null && trackId != currentTrackId) { + loadLyrics() + } + } + } + } + + fun seekToLine(line: LyricsLine) { + viewModelScope.launch { + audioPlayer.seekTo(line.time.milliseconds) + } + } + + private fun activeLineIndex(lyrics: List?, positionMillis: Long): Int { + if (lyrics.isNullOrEmpty()) return -1 + val index = lyrics.indexOfLast { it.time <= positionMillis } + return index.coerceAtLeast(0) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/SyncedLyricsList.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/SyncedLyricsList.kt new file mode 100644 index 00000000..7d5133ea --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/SyncedLyricsList.kt @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.lyrics + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.theme.MillenniumTheme + +private const val LYRICS_BLANK_LINE = "\u266A" + +/** + * Time-synced lyrics list that keeps the active line vertically centered. + * + * With [onLineClick] it renders Millennium list tiles (the active one is the selected tile); + * without it, it renders a passive compact preview. + */ +@Composable +fun SyncedLyricsList( + lines: List, + currentIndex: Int, + modifier: Modifier = Modifier, + onLineClick: ((LyricsLine) -> Unit)? = null +) { + val listState = rememberLazyListState() + val colors = MillenniumTheme.colors + + BoxWithConstraints(modifier = modifier) { + // Half-viewport padding lets the first/last lines reach the center too. + val edgePadding = maxHeight / 2 + + LaunchedEffect(currentIndex, lines.size) { + if (currentIndex !in lines.indices) return@LaunchedEffect + val visible = listState.layoutInfo.visibleItemsInfo + val itemHeight = visible.firstOrNull { it.index == currentIndex }?.size + ?: visible.firstOrNull()?.size + ?: 0 + listState.animateScrollToItem(index = currentIndex, scrollOffset = itemHeight / 2) + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth().then( + if (onLineClick != null) Modifier.fadingEdges(maxHeight * 0.18f) else Modifier + ), + contentPadding = PaddingValues(vertical = edgePadding), + verticalArrangement = Arrangement.spacedBy(if (onLineClick != null) 4.dp else 2.dp) + ) { + itemsIndexed(lines, key = { index, line -> "$index-${line.time}" }) { index, line -> + val isCurrent = index == currentIndex + val text = line.text.ifBlank { LYRICS_BLANK_LINE } + val color by animateColorAsState( + when { + isCurrent -> colors.accent + index < currentIndex -> colors.muted.copy(alpha = 0.55f) + else -> colors.muted + } + ) + if (onLineClick != null) { + ImmersiveLyricLine( + text = text, + distance = kotlin.math.abs(index - currentIndex), + isCurrent = isCurrent, + onClick = { onLineClick(line) } + ) + } else { + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + style = AppTheme.typography.bodyMedium.copy( + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal, + color = color + ), + textAlign = TextAlign.Center, + maxLines = 2 + ) + } + } + } + } +} + +@Composable +private fun ImmersiveLyricLine( + text: String, + distance: Int, + isCurrent: Boolean, + onClick: () -> Unit +) { + val colors = MillenniumTheme.colors + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val targetAlpha = when (distance) { + 0 -> 1f + 1 -> 0.7f + 2 -> 0.5f + else -> 0.32f + } + val alpha by animateFloatAsState(targetAlpha, tween(300)) + val scale by animateFloatAsState(if (isCurrent) 1f else 0.9f, tween(300)) + val color by animateColorAsState(if (isCurrent) colors.accent else colors.text, tween(300)) + val hoverBackground by animateColorAsState( + if (hovered && !isCurrent) colors.accent.copy(alpha = 0.10f) else Color.Transparent + ) + + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = if (hovered) maxOf(alpha, 0.85f) else alpha + } + .clip(RoundedCornerShape(14.dp)) + .background(hoverBackground) + .hoverable(interaction) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(interactionSource = interaction, indication = null, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + style = AppTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + color = color + ), + textAlign = TextAlign.Center + ) +} + +/** Fades the top and bottom [fade] of the content to transparent. */ +private fun Modifier.fadingEdges(fade: Dp): Modifier = this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val fraction = (fade.toPx() / size.height).coerceIn(0f, 0.5f) + drawRect( + brush = Brush.verticalGradient( + 0f to Color.Transparent, + fraction to Color.Black, + 1f - fraction to Color.Black, + 1f to Color.Transparent + ), + blendMode = BlendMode.DstIn + ) + } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt new file mode 100644 index 00000000..2f785cd8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt @@ -0,0 +1,250 @@ +/* + * Copyright (C) 2026 Kingmor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.playlist + + +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.millennium.composables.MillenniumCircularProgress +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumTextField +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.SheetDetent +import com.composeunstyled.Text +import com.composeunstyled.rememberModalBottomSheetState +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch +import org.spotube.spotube.resources.iconsax.IconsaxSearch +import org.jetbrains.compose.resources.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.playlist_add_tracks_add +import spotube.composeapp.generated.resources.playlist_add_tracks_added_content_description +import spotube.composeapp.generated.resources.playlist_add_tracks_done +import spotube.composeapp.generated.resources.playlist_add_tracks_hint +import spotube.composeapp.generated.resources.playlist_add_tracks_no_results +import spotube.composeapp.generated.resources.playlist_add_tracks_search_content_description +import spotube.composeapp.generated.resources.playlist_add_tracks_search_placeholder +import spotube.composeapp.generated.resources.playlist_add_tracks_title + +@Composable +fun AddTracksToPlaylistDialog( + visible: Boolean, + playlistId: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier) { + if (!visible) return + + val viewModel: AddTracksToPlaylistViewModel = koinViewModel( + key = "add-tracks-$playlistId", + parameters = { parametersOf(playlistId) } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + val dismiss = { + viewModel.reset() + onDismiss() + } + + val content: @Composable () -> Unit = { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically) { + MillenniumTextField( + state = state.queryState, + modifier = Modifier.weight(1f), + placeholder = stringResource(Res.string.playlist_add_tracks_search_placeholder)) + MillenniumIconButton( + icon = Iconsax.IconsaxSearch, + contentDescription = stringResource(Res.string.playlist_add_tracks_search_content_description), + onClick = viewModel::search, + loading = state.isSearching, + style = MillenniumIconButtonStyle.Secondary) + } + + when { + state.isSearching -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center) { + MillenniumCircularProgress(size = 40.dp) + } + } + state.hasSearched && state.results.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center) { + Text(stringResource(Res.string.playlist_add_tracks_no_results)) + } + } + state.results.isNotEmpty() -> { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp)) { + items(state.results, key = { it.track.id }) { result -> + TrackSearchResultRow( + track = result.track, + isAdding = result.isAdding, + isAdded = result.isAdded, + onAdd = { viewModel.addTrack(result.track.id) }) + } + } + } + else -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center) { + Text(stringResource(Res.string.playlist_add_tracks_hint)) + } + } + } + } + } + + val dialogTitle = stringResource(Res.string.playlist_add_tracks_title) + AdaptiveDialogBottomSheet( + onDismiss = dismiss, + modifier = modifier, + dialogTitle = dialogTitle, + title = { Text(dialogTitle, style = AppTheme.typography.headlineSmall) }, + actions = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End) { + MillenniumButton(onClick = dismiss) { + Text(stringResource(Res.string.playlist_add_tracks_done)) + } + } + }) { + Box(modifier = Modifier.fillMaxWidth().height(500.dp)) { + content() + } + } +} + +@Composable +private fun TrackSearchResultRow( + track: MetadataTrack, + isAdding: Boolean, + isAdded: Boolean, + onAdd: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val imageUrl = track.album?.thumbnails?.firstOrNull()?.url + if (imageUrl != null) { + AsyncImage( + model = imageUrl, + contentDescription = track.title, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(6.dp)), + contentScale = ContentScale.Crop) + } else { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(6.dp)), + contentAlignment = Alignment.Center) { + Text( + text = track.title.take(1).uppercase(), + style = AppTheme.typography.titleMedium) + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = track.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Text( + text = track.artists.joinToString(", ") { it.name }, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + } + + when { + isAdded -> { + MillenniumIcon( + imageVector = Iconsax.IconsaxCheckCircle, + contentDescription = stringResource(Res.string.playlist_add_tracks_added_content_description), + tint = AppTheme.colors.primary, + modifier = Modifier.size(24.dp)) + } + else -> { + MillenniumButton( + onClick = onAdd, + loading = isAdding, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.playlist_add_tracks_add)) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistViewModel.kt new file mode 100644 index 00000000..c120b64b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistViewModel.kt @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.playlist + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.search.SearchRepository +import kotlin.coroutines.cancellation.CancellationException + +data class AddTrackResultUi( + val track: MetadataTrack, + val isAdding: Boolean, + val isAdded: Boolean +) + +data class AddTracksToPlaylistUiState( + val queryState: TextFieldState = TextFieldState(), + val results: List = emptyList(), + val isSearching: Boolean = false, + val hasSearched: Boolean = false +) + +private data class AddTracksState( + val results: List = emptyList(), + val isSearching: Boolean = false, + val hasSearched: Boolean = false, + val addingTrackIds: Set = emptySet(), + val addedTrackIds: Set = emptySet() +) + +class AddTracksToPlaylistViewModel( + private val playlistId: String, + private val searchRepository: SearchRepository, + private val libraryRepository: LibraryRepository +) : ViewModel() { + private val logger = Logger.withTag("AddTracksToPlaylistViewModel") + private val queryState = TextFieldState() + private val state = MutableStateFlow(AddTracksState()) + private var searchJob: Job? = null + + val uiState: StateFlow = state.map { current -> + AddTracksToPlaylistUiState( + queryState = queryState, + results = current.results.map { track -> + AddTrackResultUi( + track = track, + isAdding = track.id in current.addingTrackIds, + isAdded = track.id in current.addedTrackIds + ) + }, + isSearching = current.isSearching, + hasSearched = current.hasSearched + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + AddTracksToPlaylistUiState(queryState = queryState) + ) + + fun search() { + val query = queryState.text.toString() + if (query.isBlank() || state.value.isSearching) return + state.update { it.copy(isSearching = true, hasSearched = true) } + searchJob = viewModelScope.launch { + try { + val results = searchRepository.searchTracks(query).items.map { it.data } + state.update { it.copy(results = results) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + logger.e(e) { "Failed to search tracks for \"$query\"" } + state.update { it.copy(results = emptyList()) } + } finally { + state.update { it.copy(isSearching = false) } + } + } + } + + fun addTrack(trackId: String) { + val current = state.value + if (trackId in current.addingTrackIds || trackId in current.addedTrackIds) return + state.update { it.copy(addingTrackIds = it.addingTrackIds + trackId) } + viewModelScope.launch { + try { + libraryRepository.addTracksToPlaylist(playlistId, listOf(trackId)) + state.update { it.copy(addedTrackIds = it.addedTrackIds + trackId) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + logger.e(e) { "Failed to add track $trackId to playlist $playlistId" } + } finally { + state.update { it.copy(addingTrackIds = it.addingTrackIds - trackId) } + } + } + } + + /** Clears the search so the dialog opens fresh next time; in-flight adds still complete. */ + fun reset() { + searchJob?.cancel() + queryState.clearText() + state.update { AddTracksState() } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistRepository.kt new file mode 100644 index 00000000..5aa1977d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistRepository.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.playlist + +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.plugin.PluginManager +import io.github.reactivecircus.cache4k.Cache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +class PlaylistRepository( + val pluginManager: PluginManager, + private val libraryRepository: LibraryRepository) { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + val plugin + get() = pluginManager.selectedMetadataPlugin.value + + private val playlistInfoCache = Cache.Builder>().build() + private val playlistTracksCache = Cache.Builder, PaginationResult>().build() + + init { + scope.launch { + pluginManager.selectedMetadataPlugin + .filterNotNull() + .flatMapLatest { it.loggedInFlow } + .distinctUntilChanged() + .collect { loggedIn -> + isLoggedIn = loggedIn + invalidateCaches() + } + } + } + + private var isLoggedIn: Boolean = false + + fun invalidateCaches() { + playlistInfoCache.invalidateAll() + playlistTracksCache.invalidateAll() + } + + suspend fun getPlaylistInfo(playlistId: String) = plugin?.let { plugin -> + playlistInfoCache.get(playlistId) { + pluginManager.withScope { + plugin.use { + val playlist = metadataPlaylistAPI.getPlaylist(playlistId) + val isSaved = libraryRepository.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false + playlist to isSaved + } + } + } + } + + suspend fun getPlaylistTracks(playlistId: String, paginationStrategy: PaginationStrategy? = null) = + plugin?.let { plugin -> + playlistTracksCache.get(playlistId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))) { + pluginManager.withScope { + plugin.use { + metadataPlaylistAPI.getPlaylistTracks( + id = playlistId, + pagination = paginationStrategy + ) + } + } + } + } + + suspend fun toggleSavedPlaylist(playlistId: String, currentIsSaved: Boolean) = plugin?.let { plugin -> + if (currentIsSaved) { + libraryRepository.removeSavedPlaylists(listOf(playlistId)) + } else { + libraryRepository.savePlaylists(listOf(playlistId)) + } + !currentIsSaved + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt new file mode 100644 index 00000000..370c8ca6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.playlist + + +import androidx.compose.ui.Alignment +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.map +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.CollectionView +import org.spotube.spotube.modules.library.playlist.AddToPlaylistPicker +import org.spotube.spotube.modules.library.playlist.PlaylistFormData +import org.spotube.spotube.modules.library.playlist.PlaylistFormSheet +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.playlist_add_tracks_button +import spotube.composeapp.generated.resources.playlist_loading_title +import spotube.composeapp.generated.resources.playlist_owner_unknown + +@Composable +fun PlaylistScreen( + playlistId: String, + viewModel: PlaylistViewModel, + audioPlayerQueue: AudioPlayerQueue, + audioPlayer: AudioPlayerInterface, + navigationCommands: NavigationCommands +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) + val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() + val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle() + val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() + val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() + val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() + val editPlaylistFormState by viewModel.editPlaylistFormState.collectAsStateWithLifecycle() + var showAddTracksDialog by remember { mutableStateOf(false) } + + val dataState = state as? PlaylistScreenState.Data + val playlist = dataState?.playlist + val owner = playlist?.owner + val ownerName = owner?.displayName ?: owner?.username ?: stringResource(Res.string.playlist_owner_unknown) + val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url + val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId + val artworkUrl = playlist?.thumbnails?.firstOrNull()?.url.orEmpty() + val isPlaying = currentCollectionEntry?.id == playlistId && + playerState == PlayerState.PLAYING + + val errorMessage = (state as? PlaylistScreenState.Error)?.message + val isLoading = state is PlaylistScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) + + val footerContent: (@Composable () -> Unit)? = if (isOwner && dataState != null) { + { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.Center) { + MillenniumButton( + onClick = { showAddTracksDialog = true }, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = null) + Text(stringResource(Res.string.playlist_add_tracks_button)) + } + } + } + } + } else null + + CollectionView( + isPlayLoading = playbackUiState.isPlayLoading, + isShuffleLoading = playbackUiState.isShuffleLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading, + title = playlist?.title ?: stringResource(Res.string.playlist_loading_title), + description = playlist?.description.orEmpty(), + imageURL = artworkUrl, + ownerName = ownerName, + ownerImageURL = ownerImageURL, + onOwnerClick = {}, + onPlay = viewModel::playPlaylist, + onShufflePlay = viewModel::shufflePlayPlaylist, + onAddToQueue = viewModel::addPlaylistToQueue, + isPlaying = isPlaying, + isFollowing = savedPlaylistIds.contains(playlistId), + isFollowLoading = dataState?.isSaving == true, + onFollowClick = viewModel::toggleSavedPlaylist, + showFollowButton = playlistId != "saved_tracks", + onEdit = if (isOwner) viewModel::showEditPlaylist else null, + isLoading = isLoading, + error = errorMessage, + onRetry = { viewModel.refresh() }, + tracks = dataState?.tracks ?: emptyList(), + hasMore = dataState?.nextPagination != null, + isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore, + currentTrackId = trackOptionsContext.currentTrackId, + isCurrentTrackPlaying = playerState == PlayerState.PLAYING, + onTrackClick = viewModel::playPlaylistFromTrack, + onLoadNextPage = viewModel::loadNextTracksPage, + onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) }, + onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) }, + onTrackOptionsAction = viewModel::handleTrackOptionsAction, + trackOptionsState = trackOptionsContext::stateFor, + onBulkDownload = viewModel::downloadTracks, + onBulkAddToQueue = viewModel::addTracksToQueue, + onBulkPlayNext = viewModel::playTracksNext, + onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, + onAddToJam = viewModel::addTracksToJam, + footerContent = footerContent, + trailingContent = { + val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist + PlaylistFormSheet( + visible = editPlaylistFormState.isVisible, + formState = editPlaylistFormState, + onDismiss = viewModel::dismissEditPlaylist, + onImagePicked = viewModel::onEditPlaylistImagePicked, + isEditing = true, + initialData = loadedPlaylist?.let { + PlaylistFormData( + name = it.title, + description = it.description.orEmpty(), + isPublic = true, + isCollaborating = false, + imagePreviewUrl = it.thumbnails.firstOrNull()?.url) + }, + onSubmit = viewModel::updatePlaylist) + + AddToPlaylistPicker( + visible = showAddToPlaylistPicker, + currentUserId = currentUserId, + onDismiss = viewModel::dismissAddToPlaylistPicker, + onPlaylistSelected = viewModel::addTracksToPlaylist) + + AddTracksToPlaylistDialog( + visible = showAddTracksDialog, + playlistId = playlistId, + onDismiss = { + showAddTracksDialog = false + viewModel.refresh() + }) + }) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt new file mode 100644 index 00000000..241605c6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt @@ -0,0 +1,427 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.playlist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackUiState +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.ui.component.TrackOptionsAction +import org.spotube.spotube.core.ui.component.TrackOptionsContext +import org.spotube.spotube.core.ui.component.TrackOptionsState +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.downloads.DownloadManager +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import io.github.vinceglb.filekit.PlatformFile +import org.spotube.spotube.modules.library.playlist.PlaylistFormController +import org.spotube.spotube.modules.library.playlist.PlaylistFormData +import org.spotube.spotube.modules.library.playlist.PlaylistFormUiState +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error + +sealed interface PlaylistScreenState { + data object Loading : PlaylistScreenState + + sealed interface Data : PlaylistScreenState { + val playlist: MetadataPlaylist? + val isSaved: Boolean + val tracks: List + val nextPagination: PaginationStrategy? + val isSaving: Boolean + + data class Loaded( + override val playlist: MetadataPlaylist? = null, + override val isSaved: Boolean = false, + override val tracks: List = emptyList(), + override val nextPagination: PaginationStrategy? = null, + override val isSaving: Boolean = false) : Data { + fun toLoadingMore(): LoadingMore = LoadingMore( + playlist = playlist, + isSaved = isSaved, + tracks = tracks, + nextPagination = nextPagination, + isSaving = isSaving) + } + + data class LoadingMore( + override val playlist: MetadataPlaylist? = null, + override val isSaved: Boolean = false, + override val tracks: List = emptyList(), + override val nextPagination: PaginationStrategy? = null, + override val isSaving: Boolean = false) : Data + } + + data class Error(val message: String) : PlaylistScreenState +} + +@OptIn(ExperimentalCoroutinesApi::class) +class PlaylistViewModel( + private val playlistId: String, + private val repository: PlaylistRepository, + private val libraryRepository: LibraryRepository, + private val savedTracksRepository: SavedTracksRepository, + private val playbackHelper: CollectionPlaybackHelper, + private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository, + private val shareService: ShareService, + private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { + private val logger by injectLogger() + + private val _state = MutableStateFlow(PlaylistScreenState.Loading) + val uiState: StateFlow = _state.asStateFlow() + + /** Loading flags for this screen's play / shuffle / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.Playlist, playlistId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) + val savedPlaylistIds + get() = libraryRepository.savedPlaylistIdsFlow + + private val _currentUserId = MutableStateFlow(null) + val currentUserId: StateFlow = _currentUserId.asStateFlow() + + private val _blacklistedTrackIds = MutableStateFlow>(emptySet()) + val blacklistedTrackIds: StateFlow> = _blacklistedTrackIds.asStateFlow() + + private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) + val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() + + private val _tracksToAddToPlaylist = MutableStateFlow>(emptyList()) + private val _showAddToPlaylistPicker = MutableStateFlow(false) + val showAddToPlaylistPicker: StateFlow = _showAddToPlaylistPicker.asStateFlow() + + val trackOptionsContext: StateFlow = combine( + audioPlayerQueue.queueFlow, + audioPlayerQueue.currentQueueEntryFlow, + savedTracksRepository.savedTracksIdsFlow, + _blacklistedTrackIds, + _blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> + TrackOptionsContext( + currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, + queueTrackIds = queue.mapNotNull { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.id + }.toSet(), + savedTrackIds = savedIds, + blacklistedTrackIds = blacklistedTracks, + blacklistedArtistIds = blacklistedArtists) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext()) + + init { + viewModelScope.launch { + repository.pluginManager.selectedMetadataPlugin + .filterNotNull() + .flatMapLatest { it.loggedInFlow } + .distinctUntilChanged() + .collect { + loadInitialData() + loadCurrentUser() + } + } + blacklistRepository.blacklistedTracks + .onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() } + .launchIn(viewModelScope) + blacklistRepository.blacklistedArtists + .onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() } + .launchIn(viewModelScope) + } + + private suspend fun loadCurrentUser() { + runCatching { + _currentUserId.value = libraryRepository.currentUser()?.id + }.onFailure { e -> + logger.e(e) { "Failed to load current user" } + } + } + + private suspend fun loadInitialData() = runCatching { + _state.value = PlaylistScreenState.Loading + val playlistInfo = repository.getPlaylistInfo(playlistId) + val tracksResult = repository.getPlaylistTracks(playlistId) + + tracksResult?.items?.let { savedTracksRepository.isSavedTracks(it.map { item -> item.id }) } + _state.value = PlaylistScreenState.Data.Loaded( + playlist = playlistInfo?.first, + isSaved = playlistInfo?.second ?: false, + tracks = tracksResult?.items ?: emptyList(), + nextPagination = tracksResult?.nextPagination) + }.onFailure { e -> + logger.e(e) { "Failed to load playlist" } + _state.value = PlaylistScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + + suspend fun loadMoreTracks() = runCatching { + val currentState = _state.value + if (currentState is PlaylistScreenState.Data.Loaded && currentState.nextPagination != null) { + _state.value = currentState.toLoadingMore() + val result = repository.getPlaylistTracks(playlistId, currentState.nextPagination) + + result?.items?.let { savedTracksRepository.isSavedTracks(it.map { item -> item.id }) } + _state.value = PlaylistScreenState.Data.Loaded( + playlist = currentState.playlist, + isSaved = currentState.isSaved, + tracks = currentState.tracks + (result?.items ?: emptyList()), + nextPagination = result?.nextPagination, + isSaving = currentState.isSaving) + } + }.onFailure { e -> + logger.e(e) { "Failed to load more tracks" } + _state.value = PlaylistScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + + fun loadNextTracksPage() { + viewModelScope.launch { + loadMoreTracks() + } + } + + fun toggleSavedPlaylist() { + if ((_state.value as? PlaylistScreenState.Data)?.isSaving == true) return + setSaving(true) + viewModelScope.launch { + runCatching { + val isLiked = libraryRepository.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false + if (isLiked) { + libraryRepository.removeSavedPlaylists(listOf(playlistId)) + } else { + libraryRepository.savePlaylists(listOf(playlistId)) + } + }.onFailure { e -> + logger.e(e) { "Failed to toggle saved playlist" } + } + setSaving(false) + } + } + + private fun setSaving(isSaving: Boolean) { + _state.update { state -> + when (state) { + is PlaylistScreenState.Data.Loaded -> state.copy(isSaving = isSaving) + is PlaylistScreenState.Data.LoadingMore -> state.copy(isSaving = isSaving) + else -> state + } + } + } + + fun playPlaylist() { + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title) + } + + fun shufflePlayPlaylist() { + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title, shuffle = true) + } + + fun addPlaylistToQueue() { + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title) + } + + fun playPlaylistNext() { + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Playlist, playlistId, title) + } + + fun playPlaylistFromTrack(track: MetadataTrack) { + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.Playlist, + id = playlistId, + title = title, + startTrack = track) + } + + fun refresh() { + viewModelScope.launch { + repository.invalidateCaches() + loadInitialData() + } + } + + private val editPlaylistForm = PlaylistFormController(viewModelScope, logger) + val editPlaylistFormState: StateFlow = editPlaylistForm.uiState + + fun showEditPlaylist() = editPlaylistForm.show() + + fun dismissEditPlaylist() = editPlaylistForm.dismiss() + + fun onEditPlaylistImagePicked(file: PlatformFile) = editPlaylistForm.onImagePicked(file) + + fun updatePlaylist(data: PlaylistFormData) { + editPlaylistForm.submit { + libraryRepository.updatePlaylist( + id = playlistId, + name = data.name, + description = data.description.ifBlank { null }, + isPublic = data.isPublic, + isCollaborating = data.isCollaborating, + imageBase64 = data.imageBase64.ifBlank { null }, + trackIds = null) + repository.invalidateCaches() + loadInitialData() + } + } + + fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { + viewModelScope.launch { + when (action) { + is TrackOptionsAction.StartRadio -> {} + is TrackOptionsAction.PlayNext -> { + val queue = audioPlayerQueue.getQueue() + val existing = queue.find { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } + } + + is TrackOptionsAction.AddToQueue -> { + remotePlaybackController.requestTrackAddToQueue(track) + } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) + } + + is TrackOptionsAction.RemoveFromQueue -> { + val queue = audioPlayerQueue.getQueue() + queue.find { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true + }?.let { audioPlayerQueue.removeFromQueue(it) } + } + + is TrackOptionsAction.ToggleFavorite -> { + val savedIds = savedTrackIds.value + if (savedIds.contains(track.id)) { + savedTracksRepository.removeSavedTracks(listOf(track.id)) + } else { + savedTracksRepository.saveTracks(listOf(track.id)) + } + } + + is TrackOptionsAction.Download -> downloadManager.enqueue(track) + is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) + is TrackOptionsAction.Share -> { + val uri = track.externalUri?.takeIf { it.isNotBlank() } + if (uri != null) { + shareService.share(uri, track.title) + } + } + is TrackOptionsAction.AddToPlaylist -> { + _tracksToAddToPlaylist.value = listOf(track) + _showAddToPlaylistPicker.value = true + } + } + } + } + + fun addTracksToPlaylist(playlistId: String) { + viewModelScope.launch { + runCatching { + libraryRepository.addTracksToPlaylist( + playlistId, + _tracksToAddToPlaylist.value.map { it.id }) + }.onFailure { e -> + logger.e(e) { "Failed to add tracks to playlist" } + }.onSuccess { + _showAddToPlaylistPicker.value = false + } + } + } + + fun showAddToPlaylistPicker(tracks: List) { + _tracksToAddToPlaylist.value = tracks + _showAddToPlaylistPicker.value = true + } + + fun dismissAddToPlaylistPicker() { + _showAddToPlaylistPicker.value = false + } + + fun downloadTracks(tracks: List) { + tracks.forEach { track -> downloadManager.enqueue(track) } + } + + fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = _blacklistedTrackIds.value + val artistIds = _blacklistedArtistIds.value + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + fun toggleTrackBlacklist(track: MetadataTrack) { + viewModelScope.launch { + blacklistRepository.toggleTrack(track) + } + } + + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + + fun addTracksToQueue(tracks: List) { + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestTracksAddToQueue(tracks, title) + } + + fun playTracksNext(tracks: List) { + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestTracksPlayNext(tracks, title) + } + + val savedTrackIds + get() = savedTracksRepository.savedTracksIdsFlow + private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { + if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id + return title == other.title && + durationMs == other.durationMs && + album?.id == other.album?.id && + artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/BuiltInPlugins.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/BuiltInPlugins.kt new file mode 100644 index 00000000..b933e110 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/BuiltInPlugins.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.plugin + +import org.jetbrains.compose.resources.DrawableResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_logo_lrclib +import spotube.composeapp.generated.resources.plugin_logo_newpipe + +val NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN = PluginEntry( + name = "NewPipe YouTube", + version = "0.1.0", + apiVersion = PLUGIN_API_VERSION, + description = "NewPipe's YouTube plugin for fetching audio streams.", + author = "Spotube Team", + capabilities = listOf( + PluginCapability.NETWORK_REQUESTS, + PluginCapability.PERSISTENT_STORAGE + ), + abilities = listOf(PluginAbility.AUDIO), + contact = "", + repository = "", + bugs = "", + license = "") +val LRCLIB_BUILT_IN_PLUGIN = PluginEntry( + name = "LRCLib Lyrics", + version = "0.1.0", + apiVersion = PLUGIN_API_VERSION, + description = "LRCLib Lyrics plugin for fetching lyrics.", + author = "Spotube Team", + capabilities = listOf( + PluginCapability.NETWORK_REQUESTS), + abilities = listOf(PluginAbility.LYRICS), + contact = "", + repository = "", + bugs = "", + license = "") +val BUILT_IN_PLUGINS = listOf( + NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN, + LRCLIB_BUILT_IN_PLUGIN) + +/** + * The bundled placeholder logo for a built-in plugin, or `null` if it has none. + * + * Built-in plugins are not installed to disk, so they have no `logo.png`; these + * resources (in `composeResources/drawable`) stand in as their logos. + */ +fun builtInPluginLogo(plugin: PluginEntry): DrawableResource? = when (plugin.id) { + NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN.id -> Res.drawable.plugin_logo_newpipe + LRCLIB_BUILT_IN_PLUGIN.id -> Res.drawable.plugin_logo_lrclib + else -> null +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt new file mode 100644 index 00000000..86a8f30b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.plugin + +import org.spotube.spotube.core.network.HttpClientFactory +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.request.parameter +import io.ktor.http.append +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GitHubRepoSearchResponse( + @SerialName("total_count") val totalCount: Int, + @SerialName("incomplete_results") val incompleteResults: Boolean, + val items: List) + +@Serializable +data class GitHubRepo( + val id: Long, + @SerialName("full_name") val fullName: String, + val description: String? = null, + @SerialName("stargazers_count") val stargazersCount: Int = 0, + @SerialName("html_url") val htmlUrl: String, + val owner: GitHubOwner) + +@Serializable +data class GitHubOwner( + val login: String, + @SerialName("avatar_url") val avatarUrl: String) + +@Serializable +data class GitHubRelease( + @SerialName("tag_name") val tagName: String, + val name: String? = null, + val body: String? = null, + val assets: List, + @SerialName("html_url") val htmlUrl: String, + val prerelease: Boolean = false, + val draft: Boolean = false) + +@Serializable +data class GitHubAsset( + val name: String, + @SerialName("browser_download_url") val browserDownloadUrl: String) + +/** [httpClient] must have JSON content negotiation installed (see [HttpClientFactory.json]). */ +class GitHubPluginRepository(private val httpClient: HttpClient) { + + suspend fun searchSpotubePlugins(page: Int = 1, perPage: Int = 30): GitHubRepoSearchResponse { + return httpClient.get("https://api.github.com/search/repositories") { + headers { + append("Accept", "application/vnd.github+json") + append("X-GitHub-Api-Version", "2022-11-28") + } + parameter("q", "topic:spotube-zipline-plugin") + parameter("sort", "stars") + parameter("order", "desc") + parameter("page", page) + parameter("per_page", perPage) + }.body() + } + + suspend fun getLatestReleaseSmplugUrl(owner: String, repo: String): String? { + return try { + val release: GitHubRelease = + httpClient.get("https://api.github.com/repos/$owner/$repo/releases/latest") { + headers { + append("Accept", "application/vnd.github+json") + append("X-GitHub-Api-Version", "2022-11-28") + } + }.body() + release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl + } catch (_: Exception) { + null + } + } + + suspend fun getReleases(owner: String, repo: String, perPage: Int = 30): List { + return try { + httpClient.get("https://api.github.com/repos/$owner/$repo/releases") { + headers { + append("Accept", "application/vnd.github+json") + append("X-GitHub-Api-Version", "2022-11-28") + } + parameter("per_page", perPage) + }.body() + } catch (_: Exception) { + emptyList() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt new file mode 100644 index 00000000..051ec896 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.plugin + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.core.di.injectLogger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent + +data class MetadataAuthUiState( + /** True when the selected metadata plugin needs an account and the user isn't signed in. */ + val loginRequired: Boolean = false, + val pluginName: String? = null, + /** True while a login started from the prompt is still running. */ + val isLoggingIn: Boolean = false) + +/** + * Tells screens whether the selected metadata plugin is waiting for a login, so they can show a + * friendly sign-in prompt instead of surfacing the plugin's raw authorization exception. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MetadataAuthViewModel( + private val pluginManager: PluginManager) : ViewModel(), KoinComponent { + private val logger by injectLogger() + private val isLoggingIn = MutableStateFlow(false) + + val uiState: StateFlow = combine( + pluginManager.selectedMetadataPlugin.flatMapLatest { service -> + if (service == null) flowOf(false) else loginRequiredFlow(service) + }, + pluginManager.state, + isLoggingIn) { loginRequired, managerState, loggingIn -> + val name = (managerState as? PluginManagerStates.Data) + ?.selectedPlugins?.get(PluginAbility.METADATA)?.name + MetadataAuthUiState(loginRequired = loginRequired, pluginName = name, isLoggingIn = loggingIn) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MetadataAuthUiState()) + + private fun loginRequiredFlow(service: org.spotube.spotube.core.zipline.PluginService): Flow = flow { + val requiresAuth = try { + service.use { coreAPI.requiresAuthentication } + } catch (e: Exception) { + if (e is CancellationException) throw e + false + } + if (!requiresAuth) { + emit(false) + } else { + service.loggedInFlow.collect { emit(!it) } + } + } + + fun login() { + if (isLoggingIn.value) return + val service = pluginManager.selectedMetadataPlugin.value ?: return + // The login runs in the plugin manager's scope so it survives this screen; we only wait on it. + val task = pluginManager.launchTask { + runCatching { service.use { coreAPI.login() } } + .onFailure { logger.e(it) { "Metadata plugin login failed" } } + } + isLoggingIn.value = true + viewModelScope.launch { + try { + task.join() + } finally { + isLoggingIn.update { false } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt new file mode 100644 index 00000000..13d1f380 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -0,0 +1,769 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.plugin + +import org.spotube.spotube.core.network.HttpClientFactory +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.db.DatabaseKeys +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.zipline.BuiltInPluginService +import org.spotube.spotube.core.zipline.PluginService +import org.spotube.spotube.core.zipline.ZiplinePluginService +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsChannel +import io.ktor.utils.io.readRemaining +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.io.readByteArray +import kotlinx.serialization.json.Json +import net.swiftzer.semver.SemVer +import no.synth.kmpzip.okio.ZipInputStream +import okio.ByteString.Companion.toByteString +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath +import okio.SYSTEM +import okio.buffer +import okio.use +import org.jetbrains.compose.resources.getString +import org.koin.core.component.KoinComponent +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_action_install +import spotube.composeapp.generated.resources.plugin_action_replace +import spotube.composeapp.generated.resources.plugin_error_api_incompatible +import spotube.composeapp.generated.resources.plugin_error_api_version_invalid +import spotube.composeapp.generated.resources.plugin_error_app_api_version_invalid +import spotube.composeapp.generated.resources.plugin_error_builtin_add +import spotube.composeapp.generated.resources.plugin_error_builtin_remove +import spotube.composeapp.generated.resources.plugin_error_install_failed +import spotube.composeapp.generated.resources.plugin_error_invalid_plugin_json +import spotube.composeapp.generated.resources.plugin_error_plugin_json_missing +import spotube.composeapp.generated.resources.plugin_error_read_failed +import spotube.composeapp.generated.resources.plugin_error_replace_needs_confirmation +import spotube.composeapp.generated.resources.plugin_error_update_needs_confirmation +import spotube.composeapp.generated.resources.plugin_error_zipline_manifest_missing +import spotube.composeapp.generated.resources.plugin_prompt_incompatible_title +import spotube.composeapp.generated.resources.plugin_prompt_install_message +import spotube.composeapp.generated.resources.plugin_prompt_install_title +import spotube.composeapp.generated.resources.plugin_prompt_replace_downgrade +import spotube.composeapp.generated.resources.plugin_prompt_replace_generic +import spotube.composeapp.generated.resources.plugin_prompt_replace_invalid_incoming +import spotube.composeapp.generated.resources.plugin_prompt_replace_invalid_installed +import spotube.composeapp.generated.resources.plugin_prompt_replace_same_version +import spotube.composeapp.generated.resources.plugin_prompt_replace_title +import spotube.composeapp.generated.resources.plugin_prompt_update_message +import spotube.composeapp.generated.resources.plugin_prompt_update_title +import spotube.composeapp.generated.resources.update_action_update + +const val PLUGIN_API_VERSION = "0.0.1" + +interface PluginProvider { + val selectedMetadataPlugin: StateFlow +} + +/** Narrow read-only view of the active audio plugin, used by consumers that only need to + * key caches or make calls against the currently selected audio plugin without depending + * on the full [PluginManager] (which also owns plugin installation/lifecycle machinery). */ +interface AudioPluginSource { + val selectedAudioPlugin: StateFlow +} + +/** Narrow read-only view of the active scrobble plugin, used by consumers that only need + * to submit scrobbles against the currently selected plugin without depending on the full + * [PluginManager] (which also owns plugin installation/lifecycle machinery). */ +interface ScrobblePluginSource { + val selectedScrobblePlugin: StateFlow +} + +class PluginManager( + val database: Database, + val paths: Paths, + private val httpClientFactory: HttpClientFactory) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { + private val logger by injectLogger() + + // plugin.json may carry fields newer than this app knows about (e.g. `entryPoint`). + private val pluginJsonParser = Json { ignoreUnknownKeys = true } + private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> + logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } + } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler) + private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath() + val pluginsDirPath: Path get() = pluginsDir + private val httpClient get() = httpClientFactory.default + + enum class InstallPromptKind { + INSTALL, + UPDATE, + REPLACE, + INFO, + } + + // Holds a parsed plugin awaiting user confirmation or acknowledgement. + class PendingPlugin( + val entry: PluginEntry, + val kind: InstallPromptKind, + val title: String, + val message: String, + val bytes: ByteArray? = null, + val existingEntry: PluginEntry? = null, + val confirmLabel: String? = null) + + val pendingPlugin = MutableStateFlow(null) + + val state: StateFlow = database.settingsDataStore.data + .map { p -> + val json = p[DatabaseKeys.PLUGINS_STATE_KEY] + val defaultSelectedPlugins = mapOf( + PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN) + if (json == null) { + PluginManagerStates.Data( + plugins = BUILT_IN_PLUGINS, + selectedPlugins = defaultSelectedPlugins + ) + } else { + try { + val res = Json.decodeFromString(json) + val plugins = res.plugins.map { + // Replace plugins that match built-in plugin IDs with the built-in + // plugin entries. This ensures that any updates to built-in plugins + // are reflected in the UI, while still allowing user-installed plugins + // to be loaded from disk. + if (it in BUILT_IN_PLUGINS) { + BUILT_IN_PLUGINS.first { builtIn -> builtIn.id == it.id } + } else it + }.toSet() + + val resolvedPlugins = (plugins union BUILT_IN_PLUGINS).toList() + val resolvedPluginsById = resolvedPlugins.associateBy { it.id } + // Drop selections for plugins that don't actually declare the ability + // (e.g. a scrobble selection left over from a removed built-in plugin) + // and always point selections at the resolved plugin entry. + val selectedPlugins = res.selectedPlugins.ifEmpty { defaultSelectedPlugins } + .mapNotNull { (ability, selected) -> + val plugin = resolvedPluginsById[selected.id] ?: return@mapNotNull null + if (ability in plugin.abilities) ability to plugin else null + } + .toMap() + + res.copy( + plugins = resolvedPlugins, + selectedPlugins = selectedPlugins) + } catch (_: Exception) { + PluginManagerStates.Data( + BUILT_IN_PLUGINS, + selectedPlugins = defaultSelectedPlugins + ) // Fallback on error + } + } + } + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = PluginManagerStates.Loading + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val ziplineServices = state + .filterIsInstance() + .map { it.selectedPlugins to it.generation } + // Paired with generation so same-version replaces (structurally equal + // PluginEntry) still trigger a restart. Without it the DataStore skips + // the emission (identical JSON) and the flow stays alive with stale code. + .distinctUntilChanged() + .flatMapLatest { (plugins, gen) -> + logger.d { "ziplineServices: flow restarting with generation=$gen, selectedPlugins=${plugins.keys}" } + flow { + val ziplineServices: MutableMap = + mutableMapOf() + val ziplineServicesByPluginID = mutableMapOf() + try { + for (ability in PluginAbility.entries) { + val plugin = plugins[ability] ?: continue + + if (ziplineServicesByPluginID.containsKey(plugin.id)) { + ziplineServices[ability] = ziplineServicesByPluginID[plugin.id] + } else if (plugin in BUILT_IN_PLUGINS) { + val service = BuiltInPluginService(plugin) + ziplineServices[ability] = service + ziplineServicesByPluginID[plugin.id] = service + service.start() + } else { + logger.d { "ziplineServices: creating new ZiplinePluginService for ${plugin.name} (${plugin.id})" } + val service = ZiplinePluginService( + applicationName = plugin.name, + manifestUrl = "http://localhost?path=${(pluginsDir / plugin.id.toPath() / "manifest.zipline.json")}", + pluginInfo = plugin) + ziplineServices[ability] = service + ziplineServicesByPluginID[plugin.id] = service + service.start() + } + } + emit(ziplineServices) + // Keep the flow alive until the next plugin is selected. + awaitCancellation() + } finally { + withContext(NonCancellable) { + logger.d { "ziplineServices: finally block stopping ${ziplineServicesByPluginID.size} services" } + ziplineServicesByPluginID.forEach { (_, service) -> + try { + service.stop() + } catch (_: Exception) { + logger.e { "ziplineServices: error stopping service" } + } + } + logger.d { "ziplineServices: all services stopped" } + } + } + } + } + .stateIn( + scope = scope, + // Keep plugin runtime alive across screen/navigation transitions. + started = SharingStarted.Eagerly, + initialValue = null + ) + + private fun filterPluginByType(ability: PluginAbility): StateFlow> { + return state + .filterIsInstance() + .map { state -> + state.plugins.filter { + it.abilities.contains(ability) + } + } + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = emptyList() + ) + } + + private fun filterSelectedPluginByType(ability: PluginAbility): StateFlow { + return ziplineServices + .mapNotNull { state -> + state?.get(ability) + } + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = null + ) + } + + val metadataPlugins: StateFlow> = filterPluginByType(PluginAbility.METADATA) + val audioPlugins = filterPluginByType(PluginAbility.AUDIO) + val lyricsPlugins = filterPluginByType(PluginAbility.LYRICS) + val scrobblePlugins = filterPluginByType(PluginAbility.SCROBBLE) + + override val selectedMetadataPlugin = + filterSelectedPluginByType(PluginAbility.METADATA) + override val selectedAudioPlugin = + filterSelectedPluginByType(PluginAbility.AUDIO) + val selectedLyricsPlugin = + filterSelectedPluginByType(PluginAbility.LYRICS) + override val selectedScrobblePlugin = + filterSelectedPluginByType(PluginAbility.SCROBBLE) + + fun launchTask(block: suspend CoroutineScope.() -> Unit): Job { + return scope.launch(block = block) + } + + fun asyncTask(block: suspend CoroutineScope.() -> T) = + scope.async(context = Dispatchers.IO, block = block) + suspend fun withScope(block: suspend CoroutineScope.() -> T) = withContext( + scope.coroutineContext.minusKey(Job) + Dispatchers.IO + ) { + block() + } + + private suspend fun updatePluginsState(newState: PluginManagerStates.Data) { + database.settingsDataStore.updateData { preferences -> + preferences.toMutablePreferences().apply { + this[DatabaseKeys.PLUGINS_STATE_KEY] = Json.encodeToString(newState) + } + } + } + + suspend fun addPluginFromURL(url: String) { + withContext(Dispatchers.IO) { + val response = httpClient.get(url) + val bytes = response.bodyAsChannel().readRemaining().readByteArray() + preparePlugin(bytes) + } + } + + /** Downloads and installs a newer plugin build over the installed one, without prompting. */ + suspend fun updatePluginFromURL(url: String) { + withContext(Dispatchers.IO) { + val response = httpClient.get(url) + val bytes = response.bodyAsChannel().readRemaining().readByteArray() + addPluginFromByteArray(bytes = bytes, allowReplacingInstalled = true) + } + } + + /** Parses the zip, reads plugin.json, then surfaces a PendingPlugin for the UI to confirm. */ + suspend fun preparePlugin(bytes: ByteArray) { + withContext(Dispatchers.IO) { + val tempDir = "${paths.getApplicationCacheDirPath()}/temp-plugin-preview".toPath() + try { + if (FileSystem.SYSTEM.exists(tempDir)) FileSystem.SYSTEM.deleteRecursively(tempDir) + FileSystem.SYSTEM.createDirectories(tempDir) + + val okioBuffer = okio.Buffer().apply { write(bytes) } + okioBuffer.use { bufferedSource -> + val zipIn = ZipInputStream(bufferedSource) + var entry = zipIn.nextEntry + while (entry != null) { + val entryPath = tempDir / entry.name.toPath() + if (entry.isDirectory) { + FileSystem.SYSTEM.createDirectories(entryPath) + } else { + entryPath.parent?.let { + if (!FileSystem.SYSTEM.exists(it)) FileSystem.SYSTEM.createDirectories( + it + ) + } + FileSystem.SYSTEM.sink(entryPath).buffer().use { sink -> + sink.write(zipIn.readBytes()) + } + } + entry = zipIn.nextEntry + } + } + + val pluginJsonPath = tempDir / "plugin.json".toPath() + if (!FileSystem.SYSTEM.exists(pluginJsonPath)) { + throw IllegalArgumentException(getString(Res.string.plugin_error_plugin_json_missing)) + } + + val pluginJson = + FileSystem.SYSTEM.source(pluginJsonPath).buffer().use { it.readUtf8() } + val pluginEntry = try { + pluginJsonParser.decodeFromString(pluginJson) + } catch (e: Exception) { + throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) + } + + // Preserve logo.png before deleting temp dir so the permission dialog can show it + val logoPngPath = tempDir / "logo.png".toPath() + if (FileSystem.SYSTEM.exists(logoPngPath)) { + val logoDir = pluginsDirPath / pluginEntry.id.toPath() + if (!FileSystem.SYSTEM.exists(logoDir)) FileSystem.SYSTEM.createDirectories(logoDir) + val destLogo = logoDir / "logo.png".toPath() + FileSystem.SYSTEM.copy(logoPngPath, destLogo) + } + + pendingPlugin.value = buildPendingPlugin(pluginEntry, bytes) + } catch (e: Exception) { + throw Exception(getString(Res.string.plugin_error_read_failed, e.message.orEmpty()), e) + } finally { + if (FileSystem.SYSTEM.exists(tempDir)) { + try { + FileSystem.SYSTEM.deleteRecursively(tempDir) + } catch (_: Exception) { + } + } + } + } + } + + /** + * Called when the user confirms installation/update/replacement in the dialog. The prompt stays + * up until the install finishes, so the dialog can show progress. + * + * @return The install job, or `null` when there was nothing to install. + */ + fun confirmInstall(): Job? { + val pending = pendingPlugin.value ?: return null + val bytes = pending.bytes ?: run { dismissInstall(); return null } + val allowReplacingInstalled = when (pending.kind) { + InstallPromptKind.INSTALL -> false + InstallPromptKind.UPDATE, InstallPromptKind.REPLACE -> true + InstallPromptKind.INFO -> run { dismissInstall(); return null } + } + return scope.launch { + try { + addPluginFromByteArray( + bytes = bytes, + allowReplacingInstalled = allowReplacingInstalled + ) + } finally { + // Only close our own prompt; another plugin may have been queued meanwhile. + pendingPlugin.compareAndSet(pending, null) + } + } + } + + /** Called when the user closes or denies the dialog. */ + fun dismissInstall() { + pendingPlugin.value = null + } + + suspend fun addPluginFromByteArray( + bytes: ByteArray, + allowReplacingInstalled: Boolean = false) { + withContext(Dispatchers.IO) { + if (!FileSystem.SYSTEM.exists(pluginsDir)) { + FileSystem.SYSTEM.createDirectories(pluginsDir) + } + + val tempDir = "${paths.getApplicationCacheDirPath()}/temp-plugin".toPath() + + try { + if (FileSystem.SYSTEM.exists(tempDir)) { + FileSystem.SYSTEM.deleteRecursively(tempDir) + } + FileSystem.SYSTEM.createDirectories(tempDir) + + val okioBuffer = okio.Buffer() + okioBuffer.write(bytes) + + okioBuffer.use { bufferedSource -> + val zipIn = ZipInputStream(bufferedSource) + var entry = zipIn.nextEntry + + while (entry != null) { + val entryPath = tempDir / entry.name.toPath() + + if (entry.isDirectory) { + FileSystem.SYSTEM.createDirectories(entryPath) + } else { + entryPath.parent?.let { parent -> + if (!FileSystem.SYSTEM.exists(parent)) { + FileSystem.SYSTEM.createDirectories(parent) + } + } + + FileSystem.SYSTEM.sink(entryPath).buffer().use { sink -> + val entryBytes = zipIn.readBytes() + val hash = entryBytes.toByteString().sha256().hex() + logger.d { "Extracting ${entry.name} (${entryBytes.size / 1024.0} KB, SHA-256: $hash)" } + sink.write(entryBytes) + sink.flush() + } + } + + entry = zipIn.nextEntry + } + } + + val pluginJsonPath = tempDir / "plugin.json".toPath() + if (!FileSystem.SYSTEM.exists(pluginJsonPath)) { + throw IllegalArgumentException(getString(Res.string.plugin_error_plugin_json_missing)) + } + val pluginManifestPath = tempDir / "manifest.zipline.json".toPath() + if (!FileSystem.SYSTEM.exists(pluginManifestPath)) { + throw IllegalArgumentException(getString(Res.string.plugin_error_zipline_manifest_missing)) + } + + val pluginJson = FileSystem.SYSTEM.source(pluginJsonPath).buffer().use { source -> + source.readUtf8() + } + + val pluginEntry = try { + pluginJsonParser.decodeFromString(pluginJson) + } catch (e: Exception) { + throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) + } + + ensurePluginApiCompatible(pluginEntry) + + val installedPlugin = findInstalledPlugin(pluginEntry.id) + if (installedPlugin != null && !allowReplacingInstalled) { + throw IllegalArgumentException( + when (compareVersions(installedPlugin, pluginEntry)) { + VersionRelation.Update -> getString(Res.string.plugin_error_update_needs_confirmation, pluginEntry.name) + VersionRelation.Replace -> getString(Res.string.plugin_error_replace_needs_confirmation, pluginEntry.name) + } + ) + } + + val finalPluginDir = pluginsDir / pluginEntry.id.toPath() + if (FileSystem.SYSTEM.exists(finalPluginDir)) { + FileSystem.SYSTEM.deleteRecursively(finalPluginDir) + } + FileSystem.SYSTEM.createDirectories(finalPluginDir) + + FileSystem.SYSTEM.list(tempDir).forEach { sourcePath -> + val relativePath = + sourcePath.toString().removePrefix(tempDir.toString()).trimStart('/', '\\') + val targetPath = finalPluginDir / relativePath.toPath() + + if (FileSystem.SYSTEM.metadata(sourcePath).isDirectory) { + copyDirectory(sourcePath, targetPath) + } else { + targetPath.parent?.let { parent -> + if (!FileSystem.SYSTEM.exists(parent)) { + FileSystem.SYSTEM.createDirectories(parent) + } + } + FileSystem.SYSTEM.copy(sourcePath, targetPath) + } + } + + addPlugin(pluginEntry) + } catch (e: Exception) { + throw Exception(getString(Res.string.plugin_error_install_failed, e.message.orEmpty()), e) + } finally { + if (FileSystem.SYSTEM.exists(tempDir)) { + try { + FileSystem.SYSTEM.deleteRecursively(tempDir) + } catch (_: Exception) { + } + } + } + } + } + + private fun copyDirectory(source: Path, target: Path) { + if (!FileSystem.SYSTEM.exists(target)) { + FileSystem.SYSTEM.createDirectories(target) + } + + FileSystem.SYSTEM.list(source).forEach { sourcePath -> + val fileName = sourcePath.name + val targetPath = target / fileName.toPath() + + if (FileSystem.SYSTEM.metadata(sourcePath).isDirectory) { + copyDirectory(sourcePath, targetPath) + } else { + FileSystem.SYSTEM.copy(sourcePath, targetPath) + } + } + } + + private suspend fun addPlugin(plugin: PluginEntry) { + if (plugin in BUILT_IN_PLUGINS) { + throw IllegalArgumentException(getString(Res.string.plugin_error_builtin_add)) + } + + val currentState = state.value + if (currentState is PluginManagerStates.Data) { + val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id } + plugin + val updatedSelectedPlugins = + currentState.selectedPlugins.mapValues { (_, selectedPlugin) -> + if (selectedPlugin.id == plugin.id) plugin else selectedPlugin + } + val newState = PluginManagerStates.Data( + plugins = updatedPlugins, + selectedPlugins = updatedSelectedPlugins, + generation = currentState.generation + 1 + ) + logger.d { "addPlugin: bumped generation to ${newState.generation} for plugin ${plugin.id}" } + updatePluginsState(newState) + } + } + + suspend fun removePlugin(plugin: PluginEntry) { + if (plugin in BUILT_IN_PLUGINS) { + throw IllegalArgumentException(getString(Res.string.plugin_error_builtin_remove)) + } + + val currentState = state.value + if (currentState is PluginManagerStates.Data) { + val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id } + val updatedSelectedPlugins = + currentState.selectedPlugins.filterValues { it.id != plugin.id } + val newState = PluginManagerStates.Data( + plugins = updatedPlugins, + selectedPlugins = updatedSelectedPlugins, + generation = currentState.generation + ) + + withContext(Dispatchers.IO) { + val pluginDir = pluginsDir / plugin.id.toPath() + if (FileSystem.SYSTEM.exists(pluginDir)) { + FileSystem.SYSTEM.deleteRecursively(pluginDir) + } + } + + updatePluginsState(newState) + } + } + + fun setSelectedPlugin(ability: PluginAbility, plugin: PluginEntry?) { + val currentState = state.value + if (currentState is PluginManagerStates.Data) { + val newState = currentState.copy( + selectedPlugins = if (plugin == null) { + currentState.selectedPlugins - ability + } else { + currentState.selectedPlugins + (ability to plugin) + } + ) + scope.launch { + updatePluginsState(newState) + } + } + } + + private enum class VersionRelation { + Update, + Replace, + } + + private suspend fun buildPendingPlugin(pluginEntry: PluginEntry, bytes: ByteArray): PendingPlugin { + val apiError = getPluginApiCompatibilityError(pluginEntry) + if (apiError != null) { + return PendingPlugin( + entry = pluginEntry, + kind = InstallPromptKind.INFO, + title = getString(Res.string.plugin_prompt_incompatible_title), + message = apiError) + } + + val installedPlugin = findInstalledPlugin(pluginEntry.id) ?: return PendingPlugin( + entry = pluginEntry, + kind = InstallPromptKind.INSTALL, + title = getString(Res.string.plugin_prompt_install_title), + message = getString(Res.string.plugin_prompt_install_message, pluginEntry.name), + bytes = bytes, + confirmLabel = getString(Res.string.plugin_action_install)) + + return when (compareVersions(installedPlugin, pluginEntry)) { + VersionRelation.Update -> PendingPlugin( + entry = pluginEntry, + kind = InstallPromptKind.UPDATE, + title = getString(Res.string.plugin_prompt_update_title), + message = getString( + Res.string.plugin_prompt_update_message, + installedPlugin.version, + pluginEntry.version), + bytes = bytes, + existingEntry = installedPlugin, + confirmLabel = getString(Res.string.update_action_update)) + + VersionRelation.Replace -> PendingPlugin( + entry = pluginEntry, + kind = InstallPromptKind.REPLACE, + title = getString(Res.string.plugin_prompt_replace_title), + message = buildReplaceMessage(installedPlugin, pluginEntry), + bytes = bytes, + existingEntry = installedPlugin, + confirmLabel = getString(Res.string.plugin_action_replace)) + } + } + + private suspend fun buildReplaceMessage( + installedPlugin: PluginEntry, + incomingPlugin: PluginEntry + ): String { + val incomingVersion = parseSemVerOrNull(incomingPlugin.version) + val installedVersion = parseSemVerOrNull(installedPlugin.version) + + return when { + incomingVersion == null -> getString( + Res.string.plugin_prompt_replace_invalid_incoming, + incomingPlugin.name, + incomingPlugin.version) + installedVersion == null -> getString( + Res.string.plugin_prompt_replace_invalid_installed, + installedPlugin.version, + incomingPlugin.version) + incomingVersion == installedVersion -> getString( + Res.string.plugin_prompt_replace_same_version, + incomingPlugin.version) + incomingVersion < installedVersion -> getString( + Res.string.plugin_prompt_replace_downgrade, + installedPlugin.version, + incomingPlugin.version) + else -> getString(Res.string.plugin_prompt_replace_generic) + } + } + + private fun compareVersions( + installedPlugin: PluginEntry, + incomingPlugin: PluginEntry + ): VersionRelation { + val installedVersion = parseSemVerOrNull(installedPlugin.version) + val incomingVersion = parseSemVerOrNull(incomingPlugin.version) + + return if (installedVersion != null && incomingVersion != null && incomingVersion > installedVersion) { + VersionRelation.Update + } else { + VersionRelation.Replace + } + } + + private fun findInstalledPlugin(id: String): PluginEntry? { + val currentState = state.value as? PluginManagerStates.Data + return currentState?.plugins?.find { it.id == id } + } + + private fun parseSemVerOrNull(version: String): SemVer? { + return try { + SemVer.parse(version) + } catch (_: Exception) { + null + } + } + + private suspend fun ensurePluginApiCompatible(pluginEntry: PluginEntry) { + getPluginApiCompatibilityError(pluginEntry)?.let { error -> + throw IllegalArgumentException(error) + } + } + + private suspend fun getPluginApiCompatibilityError(pluginEntry: PluginEntry): String? { + val pluginApiVersion = parseSemVerOrNull(pluginEntry.apiVersion) + ?: return getString( + Res.string.plugin_error_api_version_invalid, + pluginEntry.name, + pluginEntry.apiVersion, + PLUGIN_API_VERSION) + val appApiVersion = parseSemVerOrNull(PLUGIN_API_VERSION) + ?: return getString(Res.string.plugin_error_app_api_version_invalid, PLUGIN_API_VERSION) + + val compatible = if (pluginApiVersion.major == 0 || appApiVersion.major == 0) { + pluginApiVersion.major == appApiVersion.major && pluginApiVersion.minor == appApiVersion.minor + } else { + pluginApiVersion.major == appApiVersion.major + } + + return if (compatible) { + null + } else { + getString( + Res.string.plugin_error_api_incompatible, + pluginEntry.name, + pluginEntry.apiVersion, + PLUGIN_API_VERSION) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt new file mode 100644 index 00000000..050dd859 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.plugin + +import com.goncalossilva.murmurhash.MurmurHash3 +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder + +@Serializable +enum class PluginCapability { + @SerialName("persistent_storage") + PERSISTENT_STORAGE, + @SerialName("network_requests") + NETWORK_REQUESTS, + @SerialName("webview") + WEBVIEW +} + +//Set naming strategy to snake_case for better interoperability with JavaScript plugins +@Serializable +enum class PluginAbility { + @SerialName("metadata") + METADATA, + @SerialName("audio") + AUDIO, + @SerialName("lyrics") + LYRICS, + @SerialName("scrobble") + SCROBBLE, +} + +/** + * Decodes a list of enums while dropping values this app version doesn't know about, so a + * plugin.json written for another plugin API revision still parses. + */ +abstract class LenientEnumListSerializer>( + private val elementSerializer: KSerializer) : KSerializer> { + private val listSerializer = ListSerializer(elementSerializer) + override val descriptor: SerialDescriptor = listSerializer.descriptor + + override fun serialize(encoder: Encoder, value: List) = + listSerializer.serialize(encoder, value) + + override fun deserialize(decoder: Decoder): List { + val jsonDecoder = decoder as? JsonDecoder ?: return listSerializer.deserialize(decoder) + val array = jsonDecoder.decodeJsonElement() as? JsonArray + ?: throw SerializationException("Expected a JSON array") + return array.mapNotNull { element -> + try { + jsonDecoder.json.decodeFromJsonElement(elementSerializer, element) + } catch (_: SerializationException) { + null + } + } + } +} + +object LenientPluginCapabilityListSerializer : + LenientEnumListSerializer(PluginCapability.serializer()) + +object LenientPluginAbilityListSerializer : + LenientEnumListSerializer(PluginAbility.serializer()) + +@Serializable +data class PluginEntry( + val name: String, + val version: String, + val apiVersion: String, + val description: String, + val author: String, + @Serializable(with = LenientPluginCapabilityListSerializer::class) + val capabilities: List, + @Serializable(with = LenientPluginAbilityListSerializer::class) + val abilities: List, + val contact: String, + val repository: String, + val bugs: String, + val license: String) { + @Suppress("REDUNDANT_CALL_OF_CONVERSION_METHOD") + val id: String = MurmurHash3().hash32x86("$name:$author".encodeToByteArray()) + .toUInt() + .toString(16) +} + +sealed class PluginManagerStates { + @Serializable + data class Data( + val plugins: List, + val selectedPlugins: Map = emptyMap(), + // Bumped on every addPlugin so the DataStore always detects a structural + // change — without it, same-version replaces produce identical JSON and + // the DataStore's internal distinctUntilChanged blocks the state emission, + // leaving the ziplineServices flow stuck with the old code. + val generation: Long = 0L + ) : PluginManagerStates() + + data object Loading : PluginManagerStates() +// data class Error(val message: String) : PluginViewModelStates() +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt new file mode 100644 index 00000000..0d52c279 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt @@ -0,0 +1,1256 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.plugin + + +import org.spotube.millennium.composables.MillenniumCircularProgress +import org.spotube.millennium.composables.MillenniumDotsLoader +import org.spotube.millennium.composables.MillenniumSpokeSpinner +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSegmentedButton +import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults +import org.spotube.millennium.composables.MillenniumSegmentedButtonRow +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.rememberTextFieldState + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.crossfade +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.extras.kebabToTitleCase +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.spotube.core.ui.component.AdaptiveDropdownBottomSheet +import org.spotube.spotube.core.ui.component.AdaptiveMenuItem +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.component.HeaderDisplayMode +import org.spotube.spotube.getPlatform +import org.spotube.spotube.modules.plugin.components.DefaultAbilityPluginGrid +import org.spotube.spotube.modules.plugin.components.PluginCard +import org.spotube.spotube.modules.plugin.components.PluginDialogActions +import org.spotube.spotube.modules.plugin.components.PluginDialogBody +import org.spotube.spotube.modules.plugin.components.PluginDialogTitle +import org.spotube.spotube.modules.plugin.components.PluginIconTile +import org.spotube.spotube.modules.plugin.components.PluginLogoTile +import org.spotube.spotube.modules.plugin.components.PluginPanel +import org.spotube.spotube.modules.plugin.components.PluginPill +import org.spotube.spotube.resources.iconsax.IconsaxBoxAdd +import org.spotube.spotube.resources.iconsax.User +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import spotube.composeapp.generated.resources.plugin_install_sheet_subtitle +import spotube.composeapp.generated.resources.plugin_install_file_hint +import spotube.composeapp.generated.resources.plugin_install_or +import spotube.composeapp.generated.resources.plugin_info_dialog_title +import spotube.composeapp.generated.resources.plugin_version_label +import org.spotube.spotube.modules.plugin.components.PluginInstallDialog +import org.spotube.spotube.modules.plugin.components.PluginLogo +import org.spotube.spotube.modules.plugin.components.PluginPermissionDialog +import org.spotube.spotube.modules.plugin.components.abilityIcon +import org.spotube.spotube.modules.plugin.components.accentColor +import org.spotube.spotube.modules.plugin.components.displayLabel +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.openUrlInBrowser +import org.spotube.spotube.resources.iconsax.CarbonGithubLogo +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAdd +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 +import org.spotube.spotube.resources.iconsax.IconsaxBox +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxEdit +import org.spotube.spotube.resources.iconsax.IconsaxExportArrowBulk +import org.spotube.spotube.resources.iconsax.IconsaxGlobe +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxImportArrow2Bulk +import org.spotube.spotube.resources.iconsax.IconsaxLink +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher +import okio.Path +import kotlinx.coroutines.flow.collect +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_discover_badge_official +import spotube.composeapp.generated.resources.plugin_discover_badge_verified +import spotube.composeapp.generated.resources.plugin_discover_repository_content_description +import spotube.composeapp.generated.resources.plugin_discover_repository_host +import spotube.composeapp.generated.resources.plugin_discover_stars_content_description +import spotube.composeapp.generated.resources.plugin_info_abilities +import spotube.composeapp.generated.resources.plugin_info_api_version +import spotube.composeapp.generated.resources.plugin_info_author +import spotube.composeapp.generated.resources.plugin_info_capabilities +import spotube.composeapp.generated.resources.plugin_info_contact +import spotube.composeapp.generated.resources.plugin_info_label_format +import spotube.composeapp.generated.resources.plugin_info_license +import spotube.composeapp.generated.resources.plugin_info_report_bugs +import spotube.composeapp.generated.resources.plugin_info_repository +import spotube.composeapp.generated.resources.plugin_info_version +import spotube.composeapp.generated.resources.plugin_support_dialog_title +import spotube.composeapp.generated.resources.common_close +import spotube.composeapp.generated.resources.welcome_plugins_title +import spotube.composeapp.generated.resources.welcome_plugins_setup_guide +import spotube.composeapp.generated.resources.welcome_plugins_discover +import spotube.composeapp.generated.resources.welcome_plugins_retry +import spotube.composeapp.generated.resources.welcome_plugins_more +import spotube.composeapp.generated.resources.welcome_plugins_discovery_empty +import spotube.composeapp.generated.resources.welcome_plugins_discovery_error +import spotube.composeapp.generated.resources.welcome_plugins_sources +import spotube.composeapp.generated.resources.plugin_action_download +import spotube.composeapp.generated.resources.plugin_action_install_from_file +import spotube.composeapp.generated.resources.plugin_configure_title +import spotube.composeapp.generated.resources.plugin_empty_subtitle +import spotube.composeapp.generated.resources.plugin_empty_title +import spotube.composeapp.generated.resources.plugin_error_download_failed +import spotube.composeapp.generated.resources.plugin_error_enter_url +import spotube.composeapp.generated.resources.plugin_error_url_scheme +import spotube.composeapp.generated.resources.plugin_install_section_title +import spotube.composeapp.generated.resources.plugin_installed_plugins_count +import spotube.composeapp.generated.resources.plugin_screen_title +import spotube.composeapp.generated.resources.plugin_section_file_title +import spotube.composeapp.generated.resources.plugin_section_install +import spotube.composeapp.generated.resources.plugin_section_url_title +import spotube.composeapp.generated.resources.plugin_url_placeholder +import spotube.composeapp.generated.resources.settings_plugins_action_change +import spotube.composeapp.generated.resources.settings_plugins_action_select +import spotube.composeapp.generated.resources.settings_plugins_default_ability_title +import spotube.composeapp.generated.resources.settings_plugins_no_plugins +import spotube.composeapp.generated.resources.settings_plugins_no_selection +import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description + +private val OFFICIAL_PLUGIN_OWNERS = setOf("KRTirtho", "team-spotube") + +private val VERIFIED_PLUGIN_OWNERS = setOf() + +@Composable +fun PluginScreen( + viewModel: PluginViewModel = koinViewModel(), + onboarding: Boolean = false) { + Column(modifier = Modifier.fillMaxSize()) { + if (!onboarding) { + ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) + } + PluginScreenContent( + viewModel = viewModel, + onboarding = onboarding, + modifier = Modifier.weight(1f)) + } +} + +/** + * The plugin management UI without its own scaffold / app bar, so it can be embedded + * as a tab in the large-screen settings layout as well as hosted by [PluginScreen]. + */ +@Composable +internal fun PluginScreenContent( + viewModel: PluginViewModel, + onboarding: Boolean = false, + modifier: Modifier = Modifier) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + + val platform = remember { getPlatform() } + val launcher = rememberFilePickerLauncher( + type = FileKitType.File( + extensions = if (platform.type == PlatformType.Android) listOf() else listOf("smplug") + ) + ) { file -> + viewModel.onFileSelected(file) + } + + val state = uiState + if (state is PluginUiState.Data) { + state.pendingPlugin?.let { pending -> + PluginPermissionDialog( + pluginInfo = pending.entry, + title = pending.title, + message = pending.message, + confirmLabel = pending.confirmLabel, + existingPlugin = pending.existingEntry, + logoPath = state.pendingPluginLogoPath, + onConfirm = if (pending.kind != PluginManager.InstallPromptKind.INFO && pending.confirmLabel != null) { + { viewModel.confirmInstall() } + } else { + null + }, + isConfirming = state.isConfirmingInstall, + onDismiss = { viewModel.dismissInstall() } + ) + } + + state.showPluginInfo?.let { plugin -> + PluginInfoDialog( + plugin = plugin, + logoPath = state.pluginInfoLogoPath, + onDismiss = { viewModel.dismissPluginInfo() } + ) + } + + state.showPluginSupport?.let { plugin -> + SupportDialog( + plugin = plugin, + supportText = state.supportText, + isLoading = state.isLoadingSupport, + onDismiss = { viewModel.dismissSupport() } + ) + } + + state.installDialogRepo?.let { repo -> + PluginInstallDialog( + repo = repo, + releases = state.releases, + isLoadingReleases = state.isLoadingReleases, + onDismiss = { viewModel.dismissInstallDialog() }, + onInstall = { release -> + viewModel.dismissInstallDialog() + viewModel.installPlugin(release, repo.id) + } + ) + } + + if (state.showInstallSheet) { + InstallSheet( + urlInput = state.urlInput, + urlError = state.urlError, + isLoadingUrl = state.isLoadingUrl, + isPreparingFile = state.isPreparingFile, + onUrlChange = { viewModel.onUrlInputChange(it) }, + onSubmitUrl = { viewModel.submitUrl() }, + onPickFile = { launcher.launch() }, + onDismiss = { viewModel.dismissInstallSheet() } + ) + } + } + + Box(modifier = modifier.fillMaxSize()) { + when (val data = uiState) { + is PluginUiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { MillenniumCircularProgress(size = 40.dp) } + } + + is PluginUiState.Data -> { + Box(modifier = Modifier.fillMaxSize()) { + val discoverListState = rememberLazyListState() + LazyColumn( + state = discoverListState, + modifier = Modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), + contentPadding = PaddingValues( + start = 12.dp, + end = 12.dp, + top = 8.dp, + bottom = 24.dp + shellBottomInset + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (onboarding) { + item { + Column( + modifier = Modifier.padding( + horizontal = 4.dp, + vertical = 16.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(Res.string.welcome_plugins_title), + style = AppTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold) + Text( + stringResource(Res.string.welcome_plugins_setup_guide), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) + } + } + item { + MillenniumSegmentedButtonRow( + modifier = Modifier.padding(bottom = 12.dp)) { + MillenniumSegmentedButton( + selected = data.onboardingDiscover, + onClick = { viewModel.showOnboardingDiscovery(true) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)) { + Text(stringResource(Res.string.welcome_plugins_discover)) + } + MillenniumSegmentedButton( + selected = !data.onboardingDiscover, + onClick = { viewModel.showOnboardingDiscovery(false) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2)) { + Text(stringResource(Res.string.welcome_plugins_sources)) + } + } + } + } + if (!onboarding || !data.onboardingDiscover) { + + item { + DefaultAbilityPluginGrid( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + selections = data.abilitySelections, + onSelected = { ability, plugin -> + viewModel.selectPlugin(ability, plugin) + }) + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(Res.string.plugin_configure_title), + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + MillenniumButton(onClick = { viewModel.showInstallSheet() }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxAdd, + contentDescription = stringResource(Res.string.plugin_install_section_title)) + Text(stringResource(Res.string.plugin_install_section_title)) + } + } + } + } + + if (data.plugins.isEmpty()) { + item { + Box( + modifier = Modifier.fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier.size(72.dp) + .clip(RoundedCornerShape(18.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.1f)) + ) { + Box(contentAlignment = Alignment.Center) { + MillenniumIcon( + Iconsax.IconsaxBox, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = AppTheme.colors.primary + ) + } + } + Text( + stringResource(Res.string.plugin_empty_title), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + stringResource(Res.string.plugin_empty_subtitle), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } + } + } else { + item { + Text( + pluralStringResource( + Res.plurals.plugin_installed_plugins_count, + data.plugins.size, + data.plugins.size + ), + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding( + horizontal = 4.dp, + vertical = 4.dp + ) + ) + } + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(12.dp), + borderColor = AppTheme.colors.outlineVariant, + borderWidth = 1.dp, + shadowElevation = 6.dp)) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + data.plugins.forEachIndexed { index, item -> + if (index > 0) { + MillenniumSeparator() + } + PluginCard( + plugin = item.plugin, + isSelected = item.isSelected, + onRemove = { viewModel.removePlugin(item.plugin) }, + isLoggedIn = item.authState.isLoggedIn, + logoPath = item.logoPath, + isAuthInProgress = item.isAuthInProgress, + isRemoving = item.isRemoving, + onInfo = { viewModel.showPluginInfo(item.plugin) }, + onSupport = if (item.isSelected) { + { viewModel.loadSupport(item.plugin) } + } else null, + onLogin = if (item.isSelected && item.authState.requiresAuth) { + { viewModel.login(item.plugin) } + } else null, + onLogout = if (item.isSelected && item.authState.requiresAuth) { + { viewModel.logout(item.plugin) } + } else null) + } + } + } + } + } + + } + if ((!onboarding || data.onboardingDiscover) && (onboarding || data.discover.isLoading || data.discover.repos.isNotEmpty() || data.discover.error != null)) { + if (onboarding) { + item { + MillenniumButton( + onClick = viewModel::showInstallSheet, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.plugin_install_section_title)) + } + } + } + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxGlobe, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = AppTheme.colors.primary + ) + Text( + stringResource(Res.string.welcome_plugins_discover), + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + } + } + if (data.discover.isLoading) { + item { + Box( + Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + MillenniumDotsLoader() + } + } + } + if (onboarding && data.discover.isInitialLoaded && !data.discover.isLoading && data.discover.repos.isEmpty() && data.discover.error == null) { + item { + Text( + stringResource(Res.string.welcome_plugins_discovery_empty), + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(12.dp) + ) + } + } + items( + data.discover.repos, + key = { it.id } + ) { repo -> + DiscoverRepoCard( + repo = repo, + isInstalling = data.discover.installingRepoId == repo.id, + onInstall = { viewModel.showInstallDialog(repo) } + ) + } + + if (data.discover.isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + MillenniumDotsLoader() + } + } + } + + data.discover.error?.let { error -> + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(Res.string.welcome_plugins_discovery_error), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.error, + modifier = Modifier.padding( + horizontal = 4.dp, + vertical = 8.dp + ) + ) + MillenniumButton( + onClick = viewModel::retryDiscovery, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.welcome_plugins_retry)) + } + } + } + } + if (onboarding && data.discover.hasMore && !data.discover.isLoading && !data.discover.isLoadingMore && data.discover.error == null) { + item { + MillenniumButton( + onClick = viewModel::loadNextDiscoverPage, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.welcome_plugins_more)) + } + } + } + } + } + + val density = LocalDensity.current + val shouldLoadMore = remember(density) { + derivedStateOf { + val totalItems = discoverListState.layoutInfo.totalItemsCount + val lastVisibleIndex = + discoverListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index + ?: 0 + totalItems > 0 && lastVisibleIndex >= totalItems - 3 + } + } + + // Pagination trigger stays in the composable because it is driven by + // LazyList layout information, which only exists in UI scope. + // The actual loading call is forwarded to the ViewModel. + LaunchedEffect(shouldLoadMore.value, onboarding) { + if (!onboarding && shouldLoadMore.value) { + viewModel.loadNextDiscoverPage() + } + } + } + } + } + } +} + +@Composable +private fun UrlError.text(): String = when (this) { + is UrlError.Empty -> stringResource(Res.string.plugin_error_enter_url) + is UrlError.InvalidScheme -> stringResource(Res.string.plugin_error_url_scheme) + is UrlError.DownloadFailed -> stringResource(Res.string.plugin_error_download_failed) + is UrlError.Message -> message +} + +@Composable +private fun InstallSheet( + urlInput: String, + urlError: UrlError?, + isLoadingUrl: Boolean, + isPreparingFile: Boolean, + onUrlChange: (String) -> Unit, + onSubmitUrl: () -> Unit, + onPickFile: () -> Unit, + onDismiss: () -> Unit) { + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { + PluginDialogTitle( + title = stringResource(Res.string.plugin_install_section_title), + icon = Iconsax.IconsaxBoxAdd) + }, + dialogTitle = stringResource(Res.string.plugin_install_section_title)) { + PluginDialogBody { + Text( + stringResource(Res.string.plugin_install_sheet_subtitle), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + + val urlState = rememberTextFieldState(urlInput) + LaunchedEffect(urlInput) { + if (urlState.text.toString() != urlInput) { + urlState.edit { replace(0, length, urlInput) } + } + } + LaunchedEffect(urlState) { + snapshotFlow { urlState.text.toString() }.collect { value -> + if (value != urlInput) onUrlChange(value) + } + } + + PluginPanel { + Column( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PluginIconTile(Iconsax.IconsaxLink) + Text( + stringResource(Res.string.plugin_section_url_title), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) + } + MillenniumTextField( + state = urlState, + label = "", + modifier = Modifier.fillMaxWidth(), + placeholder = stringResource(Res.string.plugin_url_placeholder), + error = urlError?.text()) + MillenniumButton( + onClick = onSubmitUrl, + loading = isLoadingUrl, + modifier = Modifier.fillMaxWidth(), + style = MillenniumButtonStyle.Primary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + Iconsax.IconsaxImportArrow2Bulk, + contentDescription = null, + modifier = Modifier.size(18.dp)) + Text(stringResource(Res.string.plugin_action_download)) + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumSeparator(modifier = Modifier.weight(1f)) + Text( + stringResource(Res.string.plugin_install_or), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + MillenniumSeparator(modifier = Modifier.weight(1f)) + } + + PluginPanel(modifier = Modifier.clickable(enabled = !isPreparingFile, onClick = onPickFile)) { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PluginIconTile(Iconsax.IconsaxExportArrowBulk) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + stringResource(Res.string.plugin_action_install_from_file), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) + Text( + stringResource(Res.string.plugin_install_file_hint), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + } + if (isPreparingFile) { + MillenniumSpokeSpinner(size = 18.dp) + } else { + MillenniumIcon( + FeatherIcons.ChevronRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = AppTheme.colors.onSurfaceVariant) + } + } + } + } + } +} + +@Composable +private fun PluginInfoDialog( + plugin: PluginEntry, + logoPath: Path?, + onDismiss: () -> Unit) { + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { PluginDialogTitle(stringResource(Res.string.plugin_info_dialog_title)) }, + dialogTitle = stringResource(Res.string.plugin_info_dialog_title), + actions = { + PluginDialogActions( + secondaryLabel = stringResource(Res.string.common_close), + onSecondary = onDismiss) + }) { + PluginDialogBody { + PluginPanel { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp)) { + PluginLogoTile(plugin = plugin, logoPath = logoPath) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + plugin.name, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + PluginPill(plugin.author, icon = Iconsax.User) + PluginPill( + stringResource(Res.string.plugin_version_label, plugin.version), + tint = AppTheme.colors.primary) + } + } + } + if (plugin.description.isNotBlank()) { + Text( + plugin.description, + modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + } + + PluginPanel { + Column( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + DetailRow(stringResource(Res.string.plugin_info_api_version), plugin.apiVersion) + if (plugin.license.isNotBlank()) { + DetailRow(stringResource(Res.string.plugin_info_license), plugin.license) + } + if (plugin.capabilities.isNotEmpty()) { + DetailChipsRow( + stringResource(Res.string.plugin_info_capabilities), + plugin.capabilities.map { it.name }) + } + if (plugin.abilities.isNotEmpty()) { + DetailChipsRow( + stringResource(Res.string.plugin_info_abilities), + plugin.abilities.map { ability -> ability.displayLabel() }) + } + if (plugin.repository.isNotBlank()) { + ClickableDetailRow(label = stringResource(Res.string.plugin_info_repository), value = plugin.repository) { + openUrlInBrowser(plugin.repository) + } + } + if (plugin.contact.isNotBlank()) { + DetailRow(stringResource(Res.string.plugin_info_contact), plugin.contact) + } + if (plugin.bugs.isNotBlank()) { + ClickableDetailRow(label = stringResource(Res.string.plugin_info_report_bugs), value = plugin.bugs) { + openUrlInBrowser(plugin.bugs) + } + } + } + } + } + } +} + +@Composable +private fun SupportDialog( + plugin: PluginEntry, + supportText: String?, + isLoading: Boolean, + onDismiss: () -> Unit) { + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { PluginDialogTitle(stringResource(Res.string.plugin_support_dialog_title, plugin.name)) }, + dialogTitle = stringResource(Res.string.plugin_support_dialog_title, plugin.name), + actions = { + PluginDialogActions( + secondaryLabel = stringResource(Res.string.common_close), + onSecondary = onDismiss) + }) { + PluginDialogBody { + if (isLoading) { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + contentAlignment = Alignment.Center) { + MillenniumCircularProgress(size = 36.dp) + } + } else { + PluginPanel { + Text( + supportText ?: "", + modifier = Modifier.padding(14.dp), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun DiscoverRepoCard( + repo: GitHubRepo, + isInstalling: Boolean, + onInstall: () -> Unit) { + val isOfficial = repo.owner.login in OFFICIAL_PLUGIN_OWNERS + val isVerified = repo.owner.login in VERIFIED_PLUGIN_OWNERS + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(12.dp), + borderColor = AppTheme.colors.outlineVariant, + borderWidth = 1.dp, + shadowElevation = 6.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + val platformContext = LocalPlatformContext.current + AsyncImage( + model = ImageRequest.Builder(platformContext) + .data(repo.owner.avatarUrl) + .crossfade(true) + .build(), + contentDescription = repo.owner.login, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(8.dp)) + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + repo.fullName.split("/") + .last() + .replace("spotube-plugin-", "") + .kebabToTitleCase(), + style = AppTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + if (isOfficial) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.15f)) + ) { + Text( + stringResource(Res.string.plugin_discover_badge_official), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp) + ) + } + } else if (isVerified) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFF4CAF50).copy(alpha = 0.15f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxCheckCircle, + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = Color(0xFF4CAF50) + ) + Text( + stringResource(Res.string.plugin_discover_badge_verified), + style = AppTheme.typography.labelSmall, + color = Color(0xFF4CAF50) + ) + } + } + } + } + if (!repo.description.isNullOrBlank()) { + Text( + repo.description, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + itemVerticalAlignment = Alignment.CenterVertically + ) { + Text( + repo.owner.login, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxHeart, + contentDescription = stringResource(Res.string.plugin_discover_stars_content_description), + modifier = Modifier.size(11.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + repo.stargazersCount.toString(), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.surfaceVariant) + .clickable { + openUrlInBrowser(repo.htmlUrl) + } + ) { + Row( + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + MillenniumIcon( + Iconsax.CarbonGithubLogo, + contentDescription = stringResource(Res.string.plugin_discover_repository_content_description), + modifier = Modifier.size(10.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + stringResource(Res.string.plugin_discover_repository_host), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant, + singleLine = true + ) + } + } + } + } + MillenniumButton( + onClick = onInstall, + loading = isInstalling, + style = MillenniumButtonStyle.Secondary + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxAdd, + contentDescription = null) + Text(stringResource(Res.string.plugin_section_install)) + } + } + } + } +} + +@Composable +private fun DetailRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(Res.string.plugin_info_label_format, label), + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.widthIn(min = 90.dp) + ) + Text( + value, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface + ) + } +} + +@Composable +private fun ClickableDetailRow(label: String, value: String, onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(Res.string.plugin_info_label_format, label), + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.widthIn(min = 90.dp) + ) + Text( + value, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun DetailChipsRow(label: String, chips: List) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(Res.string.plugin_info_label_format, label), + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.widthIn(min = 90.dp) + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + chips.forEach { chip -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.secondaryContainer.copy(alpha = 0.6f)) + ) { + Text( + chip, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + } +} + +@Composable +fun DefaultAbilityPluginSelector( + selection: AbilitySelection, + onSelected: (PluginEntry?) -> Unit = { }) { + val plugins = selection.plugins + val selectedPlugin = selection.selectedPlugin + val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins) + + val menuItems = buildList { + if (plugins.isNotEmpty()) { + plugins.forEach { plugin -> + val isSelected = selectedPlugin?.name == plugin.name + add( + AdaptiveMenuItem( + label = plugin.name, + onClick = { onSelected(plugin) }, + selected = isSelected) + ) + } + } else { + add( + AdaptiveMenuItem( + label = noPluginsText, + onClick = { }, + enabled = false) + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(selection.ability.accentColor().copy(alpha = 0.1f)) + ) { + MillenniumIcon( + imageVector = selection.ability.abilityIcon(), + contentDescription = stringResource( + Res.string.settings_plugins_plugin_content_description, + selection.ability.displayLabel() + ), + modifier = Modifier.padding(8.dp), + tint = selection.ability.accentColor() + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource( + Res.string.settings_plugins_default_ability_title, + selection.ability.displayLabel() + ), + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.onSurface + ) + if (selectedPlugin != null) { + Text( + selectedPlugin.name, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.primary, + modifier = Modifier.padding(top = 4.dp) + ) + } else { + Text( + stringResource(Res.string.settings_plugins_no_selection), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } + + AdaptiveDropdownBottomSheet( + items = menuItems, + headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, + header = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(selection.ability.accentColor().copy(alpha = 0.1f)) + ) { + MillenniumIcon( + imageVector = selection.ability.abilityIcon(), + contentDescription = null, + modifier = Modifier.padding(8.dp), + tint = selection.ability.accentColor() + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource( + Res.string.settings_plugins_default_ability_title, + selection.ability.displayLabel() + ), + style = AppTheme.typography.titleMedium) + selectedPlugin?.let { + Text( + it.name, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary) + } + } + } + }, + trigger = { onClick -> + MillenniumButton( + onClick = onClick, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + if (selectedPlugin != null) { + stringResource(Res.string.settings_plugins_action_change) + } else { + stringResource(Res.string.settings_plugins_action_select) + }) + MillenniumIcon( + imageVector = if (selectedPlugin != null) Iconsax.IconsaxEdit else Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(14.dp)) + } + } + }) + } +} + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt new file mode 100644 index 00000000..7d685cfd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt @@ -0,0 +1,663 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.plugin + +import org.spotube.spotube.core.network.HttpClientFactory +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.webview.WebViewController +import org.spotube.spotube.core.zipline.PluginService +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.readBytes +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.swiftzer.semver.SemVer +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath +import okio.SYSTEM +import org.koin.core.component.KoinComponent + +data class PluginAuthState( + val requiresAuth: Boolean = false, + val isLoggedIn: Boolean = false) + +data class PluginListItem( + val plugin: PluginEntry, + val isSelected: Boolean, + val authState: PluginAuthState, + val logoPath: Path?, + val isAuthInProgress: Boolean = false, + val isRemoving: Boolean = false) + +data class AbilitySelection( + val ability: PluginAbility, + val plugins: List, + val selectedPlugin: PluginEntry?, + val pluginLogoPaths: Map = emptyMap()) + +data class PluginDiscoverState( + val repos: List = emptyList(), + val currentPage: Int = 1, + val hasMore: Boolean = true, + val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, + val error: String? = null, + val installingRepoId: Long? = null, + val isInitialLoaded: Boolean = false) + +sealed interface UrlError { + data object Empty : UrlError + data object InvalidScheme : UrlError + data object DownloadFailed : UrlError + data class Message(val message: String) : UrlError +} + +sealed interface PluginUiState { + data object Loading : PluginUiState + + data class Data( + val onboardingDiscover: Boolean = true, + val plugins: List, + val abilitySelections: List, + val pendingPlugin: PluginManager.PendingPlugin? = null, + val pendingPluginLogoPath: Path? = null, + val urlInput: String = "", + val urlError: UrlError? = null, + val isLoadingUrl: Boolean = false, + val isPreparingFile: Boolean = false, + val isConfirmingInstall: Boolean = false, + val showInstallSheet: Boolean = false, + val showPluginInfo: PluginEntry? = null, + val pluginInfoLogoPath: Path? = null, + val showPluginSupport: PluginEntry? = null, + val supportText: String? = null, + val isLoadingSupport: Boolean = false, + val installDialogRepo: GitHubRepo? = null, + val releases: List = emptyList(), + val isLoadingReleases: Boolean = false, + val discover: PluginDiscoverState = PluginDiscoverState()) : PluginUiState +} + +private data class PluginManagerSnapshot( + val state: PluginManagerStates, + val pendingPlugin: PluginManager.PendingPlugin?, + val metadataPlugins: List, + val audioPlugins: List, + val lyricsPlugins: List, + val scrobblePlugins: List, + val activeServices: Map?) + +private data class PluginScreenState( + val onboardingDiscover: Boolean = true, + val urlInput: String = "", + val urlError: UrlError? = null, + val isLoadingUrl: Boolean = false, + val isPreparingFile: Boolean = false, + val isConfirmingInstall: Boolean = false, + val authInProgressIds: Set = emptySet(), + val removingIds: Set = emptySet(), + val showInstallSheet: Boolean = false, + val showPluginInfo: PluginEntry? = null, + val pluginInfoLogoPath: Path? = null, + val showPluginSupport: PluginEntry? = null, + val supportText: String? = null, + val isLoadingSupport: Boolean = false, + val installDialogRepo: GitHubRepo? = null, + val releases: List = emptyList(), + val isLoadingReleases: Boolean = false, + val discover: PluginDiscoverState = PluginDiscoverState()) + +@OptIn(ExperimentalCoroutinesApi::class) +class PluginViewModel( + private val pluginManager: PluginManager, + private val webViewController: WebViewController, + httpClientFactory: HttpClientFactory) : ViewModel(), KoinComponent { + + private val logger by injectLogger() + private val gitHubRepo = GitHubPluginRepository(httpClientFactory.json) + + private val _screenState = MutableStateFlow(PluginScreenState()) + private val _allRepos = mutableListOf() + private val _paginationInfo = PaginationInfo() + + init { + viewModelScope.launch { + pluginManager.state.collect { pluginState -> + if (pluginState is PluginManagerStates.Data) { + val installedUrls = pluginState.plugins + .mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } } + .toSet() + _screenState.update { + it.copy( + discover = it.discover.copy( + repos = _allRepos.filter { repo -> repo.htmlUrl !in installedUrls } + ) + ) + } + } + } + } + loadFirstDiscoverPage() + } + + private class PaginationInfo( + var currentPage: Int = 1, + var hasMore: Boolean = true, + var totalCount: Int = 0) + + @Suppress("UNCHECKED_CAST") + private val pluginManagerSnapshot: StateFlow = combine( + pluginManager.state, + pluginManager.pendingPlugin, + pluginManager.metadataPlugins, + pluginManager.audioPlugins, + pluginManager.lyricsPlugins, + pluginManager.scrobblePlugins, + pluginManager.ziplineServices) { values -> + PluginManagerSnapshot( + state = values[0] as PluginManagerStates, + pendingPlugin = values[1] as PluginManager.PendingPlugin?, + metadataPlugins = values[2] as List, + audioPlugins = values[3] as List, + lyricsPlugins = values[4] as List, + scrobblePlugins = values[5] as List, + activeServices = values[6] as Map?) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + PluginManagerSnapshot( + state = PluginManagerStates.Loading, + pendingPlugin = null, + metadataPlugins = emptyList(), + audioPlugins = emptyList(), + lyricsPlugins = emptyList(), + scrobblePlugins = emptyList(), + activeServices = null) + ) + + private val pluginLogoPaths: StateFlow> = pluginManager.state + .filterIsInstance() + .map { state -> + state.plugins.associate { plugin -> + plugin.id to getLogoPath(plugin.id) + } + } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + + private val pluginAuthStates: StateFlow> = pluginManager.state + .filterIsInstance() + .map { it.plugins } + .distinctUntilChanged() + .flatMapLatest { plugins -> + if (plugins.isEmpty()) return@flatMapLatest flowOf(emptyMap()) + val entries = plugins.associate { plugin -> + plugin.id to pluginAuthFlow(plugin) + } + combine(entries.values.toList()) { states -> + entries.keys.zip(states).toMap() + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + + val uiState: StateFlow = combine( + pluginManagerSnapshot, + _screenState, + pluginAuthStates, + pluginLogoPaths) { snapshot, screen, authStates, logoPaths -> + when (val state = snapshot.state) { + is PluginManagerStates.Loading -> PluginUiState.Loading + is PluginManagerStates.Data -> PluginUiState.Data( + onboardingDiscover = screen.onboardingDiscover, + plugins = state.plugins.map { plugin -> + PluginListItem( + plugin = plugin, + isSelected = state.selectedPlugins.containsValue(plugin), + authState = authStates[plugin.id] ?: PluginAuthState(), + logoPath = logoPaths[plugin.id], + isAuthInProgress = plugin.id in screen.authInProgressIds, + isRemoving = plugin.id in screen.removingIds) + }, + abilitySelections = listOf( + AbilitySelection( + PluginAbility.METADATA, + snapshot.metadataPlugins, + state.selectedPlugins[PluginAbility.METADATA], + logoPaths), + AbilitySelection( + PluginAbility.AUDIO, + snapshot.audioPlugins, + state.selectedPlugins[PluginAbility.AUDIO], + logoPaths), + AbilitySelection( + PluginAbility.LYRICS, + snapshot.lyricsPlugins, + state.selectedPlugins[PluginAbility.LYRICS], + logoPaths), + AbilitySelection( + PluginAbility.SCROBBLE, + snapshot.scrobblePlugins, + state.selectedPlugins[PluginAbility.SCROBBLE], + logoPaths)), + pendingPlugin = snapshot.pendingPlugin, + pendingPluginLogoPath = snapshot.pendingPlugin?.entry?.id?.let { getLogoPath(it) }, + urlInput = screen.urlInput, + urlError = screen.urlError, + isLoadingUrl = screen.isLoadingUrl, + isPreparingFile = screen.isPreparingFile, + isConfirmingInstall = screen.isConfirmingInstall, + showInstallSheet = screen.showInstallSheet, + showPluginInfo = screen.showPluginInfo, + pluginInfoLogoPath = screen.pluginInfoLogoPath, + showPluginSupport = screen.showPluginSupport, + supportText = screen.supportText, + isLoadingSupport = screen.isLoadingSupport, + installDialogRepo = screen.installDialogRepo, + releases = screen.releases, + isLoadingReleases = screen.isLoadingReleases, + discover = screen.discover) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PluginUiState.Loading) + + private fun pluginAuthFlow(plugin: PluginEntry): Flow { + return combine(pluginManager.state, pluginManager.ziplineServices) { state, services -> + val data = state as? PluginManagerStates.Data + val ability = data?.selectedPlugins?.entries + ?.firstOrNull { (_, selected) -> selected.id == plugin.id } + ?.key + ability?.let { services?.get(it) } + }.distinctUntilChanged() + .flatMapLatest { service -> + if (service == null) return@flatMapLatest flowOf(PluginAuthState()) + flow { + val requiresAuth = try { + service.use { coreAPI.requiresAuthentication } + } catch (e: Exception) { + if (e is CancellationException) throw e + // Service may have been stopped/closed concurrently when the + // plugin selection changed. Fall back to a default state and wait + // for the next service emission. + false + } + emit(PluginAuthState(requiresAuth = requiresAuth, isLoggedIn = false)) + if (requiresAuth) { + service.loggedInFlow.collect { loggedIn -> + emit(PluginAuthState(requiresAuth = true, isLoggedIn = loggedIn)) + } + } + } + } + } + + private fun getLogoPath(pluginId: String): Path? { + val path = pluginManager.pluginsDirPath / pluginId.toPath() / "logo.png".toPath() + return if (FileSystem.SYSTEM.exists(path)) path else null + } + + private fun findAbilityForPlugin(pluginId: String): PluginAbility? { + val state = pluginManagerSnapshot.value.state as? PluginManagerStates.Data + return state?.selectedPlugins?.entries + ?.firstOrNull { (_, plugin) -> plugin.id == pluginId } + ?.key + } + + private fun findServiceForPlugin(pluginId: String): PluginService? { + val ability = findAbilityForPlugin(pluginId) ?: return null + return pluginManagerSnapshot.value.activeServices?.get(ability) + } + + fun onUrlInputChange(input: String) { + _screenState.update { it.copy(urlInput = input, urlError = null) } + } + + fun submitUrl() { + val url = _screenState.value.urlInput.trim() + when { + url.isBlank() -> { + _screenState.update { it.copy(urlError = UrlError.Empty) } + return + } + + !url.startsWith("http://") && !url.startsWith("https://") -> { + _screenState.update { it.copy(urlError = UrlError.InvalidScheme) } + return + } + } + + _screenState.update { it.copy(urlError = null, isLoadingUrl = true) } + viewModelScope.launch { + runCatching { + pluginManager.addPluginFromURL(url) + }.onSuccess { + _screenState.update { it.copy(urlInput = "", isLoadingUrl = false) } + }.onFailure { e -> + logger.e(e) { "Failed to add plugin from URL" } + _screenState.update { + it.copy( + urlError = e.message?.let { message -> UrlError.Message(message) } + ?: UrlError.DownloadFailed, + isLoadingUrl = false) + } + } + } + } + + fun onFileSelected(file: PlatformFile?) { + if (file == null || _screenState.value.isPreparingFile) return + _screenState.update { it.copy(isPreparingFile = true) } + viewModelScope.launch { + try { + runCatching { + pluginManager.preparePlugin(file.readBytes()) + }.onFailure { e -> + if (e is CancellationException) throw e + logger.e(e) { "Failed to prepare plugin from file" } + } + } finally { + _screenState.update { it.copy(isPreparingFile = false) } + } + } + } + + fun showInstallSheet() { + _screenState.update { it.copy(showInstallSheet = true) } + } + + fun dismissInstallSheet() { + _screenState.update { it.copy(showInstallSheet = false) } + } + + fun showPluginInfo(plugin: PluginEntry) { + _screenState.update { + it.copy( + showPluginInfo = plugin, + pluginInfoLogoPath = getLogoPath(plugin.id)) + } + } + + fun dismissPluginInfo() { + _screenState.update { it.copy(showPluginInfo = null, pluginInfoLogoPath = null) } + } + + fun loadSupport(plugin: PluginEntry) { + _screenState.update { + it.copy( + showPluginSupport = plugin, + isLoadingSupport = true, + supportText = null) + } + viewModelScope.launch { + val service = findServiceForPlugin(plugin.id) + if (service == null) { + _screenState.update { it.copy(isLoadingSupport = false) } + return@launch + } + runCatching { + service.use { coreAPI.supportMarkdownText(SemVer.parse(plugin.version)) } + }.onSuccess { text -> + _screenState.update { it.copy(supportText = text, isLoadingSupport = false) } + }.onFailure { e -> + logger.e(e) { "Failed to load support text" } + _screenState.update { it.copy(isLoadingSupport = false) } + } + } + } + + fun dismissSupport() { + _screenState.update { + it.copy( + showPluginSupport = null, + supportText = null, + isLoadingSupport = false) + } + } + + fun showInstallDialog(repo: GitHubRepo) { + _screenState.update { + it.copy( + installDialogRepo = repo, + isLoadingReleases = true, + releases = emptyList()) + } + viewModelScope.launch { + runCatching { + val parts = repo.fullName.split("/") + gitHubRepo.getReleases(parts[0], parts[1]) + }.onSuccess { releases -> + _screenState.update { it.copy(releases = releases, isLoadingReleases = false) } + }.onFailure { e -> + logger.e(e) { "Failed to load releases" } + _screenState.update { it.copy(isLoadingReleases = false) } + } + } + } + + fun dismissInstallDialog() { + _screenState.update { it.copy(installDialogRepo = null, releases = emptyList()) } + } + + fun selectPlugin(ability: PluginAbility, plugin: PluginEntry?) { + pluginManager.setSelectedPlugin(ability, plugin) + } + + fun showOnboardingDiscovery(show: Boolean) { + _screenState.update { it.copy(onboardingDiscover = show) } + } + + fun removePlugin(plugin: PluginEntry) { + if (plugin.id in _screenState.value.removingIds) return + _screenState.update { it.copy(removingIds = it.removingIds + plugin.id) } + viewModelScope.launch { + try { + runCatching { + pluginManager.removePlugin(plugin) + }.onFailure { e -> + if (e is CancellationException) throw e + logger.e(e) { "Failed to remove plugin" } + } + } finally { + _screenState.update { it.copy(removingIds = it.removingIds - plugin.id) } + } + } + } + + fun login(plugin: PluginEntry) { + if (plugin.id in _screenState.value.authInProgressIds) return + val service = findServiceForPlugin(plugin.id) ?: return + trackAuthTask(plugin.id, pluginManager.launchTask { + service.use { coreAPI.login() } + }) + } + + fun logout(plugin: PluginEntry) { + if (plugin.id in _screenState.value.authInProgressIds) return + val service = findServiceForPlugin(plugin.id) ?: return + val task = pluginManager.launchTask { + service.use { coreAPI.logout() } + } + trackAuthTask(plugin.id, task) { + webViewController.clearData(plugin.id) + } + } + + /** + * Marks [pluginId] busy until [task] (running in the plugin manager's scope, so it outlives this + * screen) and the optional [alongside] work finish, whatever their outcome. + */ + private fun trackAuthTask(pluginId: String, task: Job, alongside: suspend () -> Unit = {}) { + _screenState.update { it.copy(authInProgressIds = it.authInProgressIds + pluginId) } + viewModelScope.launch { + try { + alongside() + task.join() + } finally { + _screenState.update { it.copy(authInProgressIds = it.authInProgressIds - pluginId) } + } + } + } + + fun confirmInstall() { + if (_screenState.value.isConfirmingInstall) return + val task = pluginManager.confirmInstall() ?: return + _screenState.update { it.copy(isConfirmingInstall = true) } + viewModelScope.launch { + try { + task.join() + } finally { + _screenState.update { it.copy(isConfirmingInstall = false) } + } + } + } + + fun dismissInstall() { + pluginManager.dismissInstall() + } + + private fun filterInstalledRepos(repos: List): List { + val pluginState = pluginManager.state.value + if (pluginState !is PluginManagerStates.Data) return repos + val installedUrls = pluginState.plugins + .mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } } + .toSet() + return repos.filter { it.htmlUrl !in installedUrls } + } + + fun retryDiscovery() { + if (_screenState.value.discover.isLoading) return + loadFirstDiscoverPage() + } + + private fun loadFirstDiscoverPage() { + viewModelScope.launch { + _screenState.update { + it.copy(discover = it.discover.copy(isLoading = true, error = null)) + } + runCatching { + gitHubRepo.searchSpotubePlugins(page = 1) + }.onSuccess { response -> + _allRepos.clear() + _allRepos.addAll(response.items) + _paginationInfo.currentPage = 1 + _paginationInfo.totalCount = response.totalCount + _paginationInfo.hasMore = _allRepos.size < response.totalCount + _screenState.update { + it.copy( + discover = it.discover.copy( + repos = filterInstalledRepos(response.items), + currentPage = 1, + hasMore = _paginationInfo.hasMore, + isLoading = false, + isInitialLoaded = true) + ) + } + }.onFailure { e -> + logger.e(e) { "Failed to load plugins" } + _screenState.update { + it.copy( + discover = it.discover.copy( + isLoading = false, + error = e.message, + isInitialLoaded = true) + ) + } + } + } + } + + fun loadNextDiscoverPage() { + val current = _screenState.value.discover + if (current.isLoadingMore || !current.hasMore) return + viewModelScope.launch { + val nextPage = _paginationInfo.currentPage + 1 + _screenState.update { + it.copy(discover = it.discover.copy(isLoadingMore = true, error = null)) + } + runCatching { + gitHubRepo.searchSpotubePlugins(page = nextPage) + }.onSuccess { response -> + _allRepos.addAll(response.items) + _paginationInfo.currentPage = nextPage + _paginationInfo.hasMore = _allRepos.size < response.totalCount + _screenState.update { + it.copy( + discover = it.discover.copy( + repos = filterInstalledRepos(_allRepos), + currentPage = nextPage, + hasMore = _paginationInfo.hasMore, + isLoadingMore = false) + ) + } + }.onFailure { e -> + logger.e(e) { "Failed to load more plugins" } + _screenState.update { + it.copy( + discover = it.discover.copy( + isLoadingMore = false, + error = e.message) + ) + } + } + } + } + + fun installPlugin(release: GitHubRelease, repoId: Long) { + val smplugUrl = + release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl + if (smplugUrl != null) { + installPluginFromUrl(smplugUrl, repoId) + } + } + + private fun installPluginFromUrl(url: String, repoId: Long) { + if (_screenState.value.discover.installingRepoId != null) return + _screenState.update { + it.copy(discover = it.discover.copy(installingRepoId = repoId, error = null)) + } + viewModelScope.launch { + runCatching { + pluginManager.addPluginFromURL(url) + }.onFailure { e -> + logger.e(e) { "Failed to install plugin" } + _screenState.update { + it.copy(discover = it.discover.copy(error = e.message)) + } + } + _screenState.update { + it.copy(discover = it.discover.copy(installingRepoId = null)) + } + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt new file mode 100644 index 00000000..17f267d8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt @@ -0,0 +1,387 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.plugin.components + + +import org.spotube.millennium.composables.MillenniumIcon +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.spotube.spotube.core.ui.component.AdaptiveDropdownBottomSheet +import org.spotube.spotube.core.ui.component.AdaptiveMenuItem +import org.spotube.spotube.core.ui.component.HeaderDisplayMode +import org.spotube.spotube.modules.plugin.AbilitySelection +import org.spotube.spotube.modules.plugin.PluginAbility +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 +import org.spotube.spotube.resources.iconsax.IconsaxDocumentText +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.resources.iconsax.IconsaxSound +import org.spotube.spotube.resources.iconsax.IconsaxTextalignLeft +import okio.Path +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_version_label +import spotube.composeapp.generated.resources.settings_plugins_ability_audio +import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics +import spotube.composeapp.generated.resources.settings_plugins_ability_metadata +import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble +import spotube.composeapp.generated.resources.settings_plugins_default_ability_title +import spotube.composeapp.generated.resources.settings_plugins_no_plugins +import spotube.composeapp.generated.resources.settings_plugins_no_selection +import kotlin.random.Random + +/** The accent color used for a plugin ability across the whole plugin UI. */ +internal fun PluginAbility.accentColor(): Color = when (this) { + PluginAbility.METADATA -> Color(0xFF34A853) + PluginAbility.AUDIO -> Color(0xFF1E88E5) + PluginAbility.LYRICS -> Color(0xFFF9A825) + PluginAbility.SCROBBLE -> Color(0xFF8E24AA) +} + +/** The icon used to represent a plugin ability. */ +internal fun PluginAbility.abilityIcon(): ImageVector = when (this) { + PluginAbility.METADATA -> Iconsax.IconsaxDocumentText + PluginAbility.AUDIO -> Iconsax.IconsaxMusic + PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft + PluginAbility.SCROBBLE -> Iconsax.IconsaxSound +} + +/** + * A vibrant, multi-hue gradient per ability, used by the Frutiger-Aero plugin tiles. + */ +internal fun PluginAbility.aeroGradient(): List = when (this) { + PluginAbility.METADATA -> listOf(Color(0xFFA8E063), Color(0xFF56AB2F), Color(0xFF11998E)) + PluginAbility.AUDIO -> listOf(Color(0xFF56CCF2), Color(0xFF2F80ED), Color(0xFF6A11CB)) + PluginAbility.LYRICS -> listOf(Color(0xFFFFE259), Color(0xFFFFA751), Color(0xFFFF5E62)) + PluginAbility.SCROBBLE -> listOf(Color(0xFFFF6FD8), Color(0xFF9D50BB), Color(0xFF3F0D7A)) +} + +@Composable +internal fun PluginAbility.displayLabel(): String = when (this) { + PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata) + PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio) + PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics) + PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble) +} + +/** A short "v1.2.3 · Author" summary shown under a plugin in the picker. */ +@Composable +private fun PluginEntry.metadataLabel(): String = buildString { + append(stringResource(Res.string.plugin_version_label, version)) + if (author.isNotBlank()) { + append(" · ").append(author) + } +} + +/** + * The 2x2 grid of default-plugin selectors shown on large screens. Each tile is a + * Frutiger-Aero style card representing one ability and its currently selected plugin. + */ +@Composable +internal fun DefaultAbilityPluginGrid( + selections: List, + onSelected: (PluginAbility, PluginEntry?) -> Unit, + modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + selections.chunked(2).forEach { rowSelections -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + rowSelections.forEach { selection -> + DefaultAbilityPluginCard( + selection = selection, + onSelected = { onSelected(selection.ability, it) }, + modifier = Modifier.weight(1f)) + } + repeat(2 - rowSelections.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@Composable +private fun DefaultAbilityPluginCard( + selection: AbilitySelection, + onSelected: (PluginEntry?) -> Unit, + modifier: Modifier = Modifier) { + val ability = selection.ability + val selected = selection.selectedPlugin + val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins) + val noSelectionText = stringResource(Res.string.settings_plugins_no_selection) + + val menuItems = buildList { + if (selection.plugins.isNotEmpty()) { + selection.plugins.forEach { plugin -> + add( + AdaptiveMenuItem( + label = plugin.name, + subtitle = plugin.metadataLabel(), + leadingContent = { + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center) { + PluginLogo( + plugin = plugin, + logoPath = selection.pluginLogoPaths[plugin.id], + contentDescription = plugin.name, + modifier = Modifier.fillMaxSize(), + fallback = { + MillenniumIcon( + imageVector = ability.abilityIcon(), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = ability.accentColor()) + }) + } + }, + onClick = { onSelected(plugin) }, + selected = selected?.name == plugin.name) + ) + } + } else { + add( + AdaptiveMenuItem( + label = noPluginsText, + onClick = { }, + enabled = false) + ) + } + } + + AdaptiveDropdownBottomSheet( + items = menuItems, + modifier = modifier, + headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, + header = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + AbilityBadge(ability) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource( + Res.string.settings_plugins_default_ability_title, + ability.displayLabel()), + style = AppTheme.typography.titleMedium) + selected?.let { + Text( + it.name, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary) + } + } + } + }, + trigger = { onClick -> + AbilityPluginTile( + ability = ability, + selectedPlugin = selected, + logoPath = selection.pluginLogoPaths[selected?.id], + pluginName = selected?.name ?: noSelectionText, + version = selected?.version, + onClick = onClick) + }) +} + +@Composable +private fun AbilityPluginTile( + ability: PluginAbility, + selectedPlugin: PluginEntry?, + logoPath: Path?, + pluginName: String, + version: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier) { + val accent = ability.accentColor() + val gradient = ability.aeroGradient() + val shape = RoundedCornerShape(20.dp) + val seed = ability.ordinal + 1 + + Box( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .clickable(onClick = onClick) + .heightIn(min = 148.dp) + .drawWithCache { + val background = Brush.linearGradient( + colors = gradient, + start = Offset.Zero, + end = Offset(size.width, size.height)) + onDrawBehind { + drawRect(background) + drawAeroDecorations(seed) + // Bottom scrim so the white text stays readable on light gradients. + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.32f)), + startY = size.height * 0.3f, + endY = size.height)) + // Glossy top highlight. + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.White.copy(alpha = 0.35f), Color.Transparent), + startY = 0f, + endY = size.height * 0.55f)) + } + }) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top) { + Box( + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.35f)) + .border(1.dp, Color.White.copy(alpha = 0.6f), CircleShape), + contentAlignment = Alignment.Center) { + PluginLogo( + plugin = selectedPlugin, + logoPath = logoPath, + contentDescription = pluginName, + modifier = Modifier.fillMaxSize(), + fallback = { + MillenniumIcon( + imageVector = ability.abilityIcon(), + contentDescription = null, + modifier = Modifier.size(26.dp), + tint = accent) + }) + } + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color.White.copy(alpha = 0.85f)) + } + + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = pluginName, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Text( + text = buildString { + append(ability.displayLabel()) + if (!version.isNullOrBlank()) { + append(" · ").append(stringResource(Res.string.plugin_version_label, version)) + } + }, + style = AppTheme.typography.labelMedium, + color = Color.White.copy(alpha = 0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +private fun AbilityBadge(ability: PluginAbility) { + val accent = ability.accentColor() + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(accent.copy(alpha = 0.12f))) { + MillenniumIcon( + imageVector = ability.abilityIcon(), + contentDescription = null, + modifier = Modifier.padding(8.dp), + tint = accent) + } +} + +/** + * Soft translucent bubbles and highlights behind a tile, giving the glossy + * Frutiger-Aero look. Deterministic per tile so it does not flicker on recomposition. + */ +private fun DrawScope.drawAeroDecorations(seed: Int) { + val random = Random(seed) + repeat(6) { + val center = Offset( + x = random.nextFloat() * size.width, + y = random.nextFloat() * size.height) + val radius = size.minDimension * (0.12f + random.nextFloat() * 0.4f) + drawCircle( + brush = Brush.radialGradient( + colors = listOf(Color.White.copy(alpha = 0.3f), Color.Transparent), + center = center, + radius = radius), + radius = radius, + center = center) + } + repeat(3) { + val center = Offset( + x = random.nextFloat() * size.width, + y = random.nextFloat() * size.height) + val radius = size.minDimension * (0.03f + random.nextFloat() * 0.05f) + drawCircle( + color = Color.White.copy(alpha = 0.28f), + radius = radius, + center = center) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt new file mode 100644 index 00000000..4937e3bb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt @@ -0,0 +1,306 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.plugin.components + + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import okio.Path +import org.spotube.spotube.modules.plugin.BUILT_IN_PLUGINS +import org.spotube.spotube.modules.plugin.PluginAbility +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxBox +import org.spotube.spotube.resources.iconsax.IconsaxCheckSquare +import org.spotube.spotube.resources.iconsax.IconsaxInformation +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxTag +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import org.spotube.spotube.resources.iconsax.User +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_action_remove +import spotube.composeapp.generated.resources.plugin_action_login +import spotube.composeapp.generated.resources.plugin_action_logout +import spotube.composeapp.generated.resources.plugin_state_active +import spotube.composeapp.generated.resources.plugin_state_builtin +import spotube.composeapp.generated.resources.plugin_version_label + +@Composable +internal fun PluginCard( + plugin: PluginEntry, + isSelected: Boolean, + onRemove: () -> Unit, + isLoggedIn: Boolean, + onLogin: (() -> Unit)? = null, + onLogout: (() -> Unit)? = null, + onInfo: (() -> Unit)? = null, + onSupport: (() -> Unit)? = null, + logoPath: Path? = null, + isAuthInProgress: Boolean = false, + isRemoving: Boolean = false) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + // Plugin icon + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(10.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.1f)) + ) { + PluginLogo( + plugin = plugin, + logoPath = logoPath, + contentDescription = plugin.name, + modifier = Modifier.fillMaxSize(), + fallback = { + Box(contentAlignment = Alignment.Center) { + MillenniumIcon( + Iconsax.IconsaxBox, + contentDescription = null, + tint = AppTheme.colors.primary, + modifier = Modifier.size(22.dp) + ) + } + }) + } + + // Text content + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + plugin.name, + style = AppTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + // "Active" chip + if (isSelected) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.15f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxCheckSquare, + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = AppTheme.colors.primary + ) + Text( + stringResource(Res.string.plugin_state_active), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary + ) + } + } + } + } + + if (plugin.description.isNotBlank()) { + Text( + plugin.description, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + // Meta chips row + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + MillenniumIcon( + Iconsax.User, + contentDescription = null, + modifier = Modifier.size(11.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + plugin.author, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + if (plugin !in BUILT_IN_PLUGINS) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxTag, + contentDescription = null, + modifier = Modifier.size(11.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + stringResource(Res.string.plugin_version_label, plugin.version), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } + + // Ability chips + if (plugin.abilities.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + plugin.abilities.forEach { ability -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.secondaryContainer.copy(alpha = 0.6f)) + ) { + Text( + ability.displayLabel(), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSecondaryContainer, + modifier = Modifier.padding( + horizontal = 6.dp, + vertical = 2.dp + ) + ) + } + } + } + } + } + + Column( + modifier = Modifier.align(Alignment.Bottom), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (onInfo != null) { + MillenniumIconButton( + onClick = onInfo, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + Iconsax.IconsaxInformation, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant) + } + } + if (onSupport != null) { + MillenniumIconButton( + onClick = onSupport, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + Iconsax.IconsaxHeart, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant) + } + } + if (plugin in BUILT_IN_PLUGINS) { + Text( + stringResource(Res.string.plugin_state_builtin), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + .border( + BorderStroke(1.dp, AppTheme.colors.outlineVariant), + shape = RoundedCornerShape(6.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + } else { + MillenniumIconButton( + onClick = onRemove, + loading = isRemoving, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + Iconsax.IconsaxTrash, + contentDescription = stringResource(Res.string.plugin_action_remove), + tint = AppTheme.colors.onSurfaceVariant) + } + } + } + + val authAction = when { + isLoggedIn && onLogout != null -> onLogout to Res.string.plugin_action_logout + !isLoggedIn && onLogin != null -> onLogin to Res.string.plugin_action_login + else -> null + } + authAction?.let { (action, label) -> + MillenniumButton( + onClick = action, + loading = isAuthInProgress, + style = MillenniumButtonStyle.Secondary) { + Text( + text = stringResource(label), + style = AppTheme.typography.labelLarge + ) + } + } + } + } +} + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt new file mode 100644 index 00000000..26e390ef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt @@ -0,0 +1,251 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.plugin.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxBoxAdd +import org.spotube.spotube.resources.iconsax.IconsaxInformation +import okio.Path +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface + +// Shared building blocks for the plugin dialogs/sheets so they all read as one family and +// match the panel/tile language used by the plugin cards and the rest of the app. + +/** Body column for plugin dialogs; the adaptive dialog/sheet around it handles scrolling. */ +@Composable +internal fun PluginDialogBody(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(14.dp), + content = content) +} + +/** Title for the phone bottom sheet; the desktop dialog shows the same text in its titlebar. */ +@Composable +internal fun PluginDialogTitle( + title: String, + icon: ImageVector? = null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (icon != null) { + PluginIconTile(icon, size = 40.dp) + } + Text( + title, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface) + } +} + +/** Rounded, softly bordered panel — the same surface the plugin cards use. */ +@Composable +internal fun PluginPanel( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = modifier + .fillMaxWidth() + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(14.dp), + borderColor = AppTheme.colors.outlineVariant, + borderWidth = 1.dp), + content = content) +} + +@Composable +internal fun PluginIconTile( + icon: ImageVector, + modifier: Modifier = Modifier, + size: Dp = 38.dp, + tint: Color = AppTheme.colors.primary) { + Box( + modifier = modifier + .size(size) + .clip(RoundedCornerShape(size / 3.4f)) + .background(tint.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center) { + MillenniumIcon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(size * 0.5f), + tint = tint) + } +} + +@Composable +internal fun PluginLogoTile( + plugin: PluginEntry, + logoPath: Path?, + size: Dp = 56.dp) { + Box( + modifier = Modifier + .size(size) + .clip(RoundedCornerShape(size / 3.4f)) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center) { + PluginLogo( + plugin = plugin, + logoPath = logoPath, + contentDescription = plugin.name, + modifier = Modifier.fillMaxSize(), + fallback = { + MillenniumIcon( + imageVector = Iconsax.IconsaxBoxAdd, + contentDescription = null, + tint = AppTheme.colors.primary, + modifier = Modifier.size(size * 0.5f)) + }) + } +} + +@Composable +internal fun PluginPill( + text: String, + icon: ImageVector? = null, + tint: Color = AppTheme.colors.onSurfaceVariant) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(tint.copy(alpha = 0.12f)) + .padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (icon != null) { + MillenniumIcon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = tint) + } + Text( + text, + style = AppTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = tint, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + } +} + +/** Tinted info strip; [isWarning] switches it to the error tone. */ +@Composable +internal fun PluginCallout(text: String, isWarning: Boolean = false) { + val tint = if (isWarning) AppTheme.colors.error else AppTheme.colors.primary + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(tint.copy(alpha = 0.10f)) + .padding(12.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumIcon( + imageVector = Iconsax.IconsaxInformation, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = tint) + Text( + text, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurface) + } +} + +@Composable +internal fun PluginSectionLabel(text: String, badge: String? = null) { + Row( + modifier = Modifier.padding(horizontal = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text, + style = AppTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface) + if (badge != null) { + PluginPill(badge, tint = AppTheme.colors.primary) + } + } +} + +/** Equal-width secondary + primary action pair. */ +@Composable +internal fun PluginDialogActions( + secondaryLabel: String, + onSecondary: () -> Unit, + primaryLabel: String? = null, + onPrimary: (() -> Unit)? = null, + primaryEnabled: Boolean = true, + primaryLoading: Boolean = false) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( + onClick = onSecondary, + modifier = Modifier.weight(1f), + style = if (primaryLabel != null) MillenniumButtonStyle.Secondary else MillenniumButtonStyle.Primary) { + Text(secondaryLabel) + } + if (primaryLabel != null && onPrimary != null) { + MillenniumButton( + onClick = onPrimary, + enabled = primaryEnabled, + loading = primaryLoading, + modifier = Modifier.weight(1f), + style = MillenniumButtonStyle.Primary) { + Text(primaryLabel) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginInstallDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginInstallDialog.kt new file mode 100644 index 00000000..6a05841d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginInstallDialog.kt @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.plugin.components + +import org.spotube.millennium.composables.MillenniumCircularProgress +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.plugin.GitHubRelease +import org.spotube.spotube.modules.plugin.GitHubRepo +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumDropdownMenuContent +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import org.spotube.millennium.composables.MillenniumIcon +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_cancel +import spotube.composeapp.generated.resources.plugin_action_install +import spotube.composeapp.generated.resources.plugin_install_no_releases +import spotube.composeapp.generated.resources.plugin_install_prerelease +import spotube.composeapp.generated.resources.plugin_install_release_notes +import spotube.composeapp.generated.resources.plugin_install_select_release_label +import spotube.composeapp.generated.resources.plugin_install_select_release_placeholder + +@Composable +fun PluginInstallDialog( + repo: GitHubRepo, + releases: List, + isLoadingReleases: Boolean, + onDismiss: () -> Unit, + onInstall: (GitHubRelease) -> Unit) { + var selectedRelease by remember { mutableStateOf(null) } + var expanded by remember { mutableStateOf(false) } + + LaunchedEffect(releases) { + if (selectedRelease == null && releases.isNotEmpty()) { + selectedRelease = releases.first() + } + } + + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { PluginDialogTitle(stringResource(Res.string.plugin_action_install)) }, + dialogTitle = stringResource(Res.string.plugin_action_install), + actions = { + PluginDialogActions( + secondaryLabel = stringResource(Res.string.common_cancel), + onSecondary = onDismiss, + primaryLabel = stringResource(Res.string.plugin_action_install), + onPrimary = { selectedRelease?.let(onInstall) }, + primaryEnabled = selectedRelease != null && !isLoadingReleases) + }) { + PluginDialogBody { + PluginPanel { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp)) { + val platformContext = LocalPlatformContext.current + AsyncImage( + model = ImageRequest.Builder(platformContext) + .data(repo.owner.avatarUrl) + .crossfade(true) + .build(), + contentDescription = repo.owner.login, + modifier = Modifier.size(56.dp).clip(RoundedCornerShape(16.dp))) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + repo.fullName, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + if (!repo.description.isNullOrBlank()) { + Text( + repo.description, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis) + } + } + } + } + + if (isLoadingReleases) { + Box( + modifier = Modifier.fillMaxWidth().height(96.dp), + contentAlignment = Alignment.Center) { + MillenniumCircularProgress(size = 36.dp) + } + } else if (releases.isEmpty()) { + PluginCallout(stringResource(Res.string.plugin_install_no_releases), isWarning = true) + } else { + PluginSectionLabel(stringResource(Res.string.plugin_install_select_release_label)) + MillenniumDropdownMenu( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.fillMaxWidth(), + anchor = { + MillenniumButton( + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + style = MillenniumButtonStyle.Secondary) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + Text( + selectedRelease?.tagName + ?: stringResource(Res.string.plugin_install_select_release_placeholder), + style = AppTheme.typography.bodyMedium) + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(16.dp)) + } + } + }) { + MillenniumDropdownMenuContent { + releases.forEach { release -> + MillenniumDropdownMenuItem( + label = release.tagName, + selected = release.tagName == selectedRelease?.tagName, + description = if (release.prerelease) { + stringResource(Res.string.plugin_install_prerelease) + } else null, + onClick = { + selectedRelease = release + expanded = false + }) + } + } + } + + selectedRelease?.let { release -> + if (release.prerelease) { + PluginCallout(stringResource(Res.string.plugin_install_prerelease), isWarning = true) + } + if (!release.body.isNullOrBlank()) { + PluginSectionLabel(stringResource(Res.string.plugin_install_release_notes)) + PluginPanel { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .verticalScroll(rememberScrollState()) + .padding(12.dp)) { + Text( + release.body, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginLogo.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginLogo.kt new file mode 100644 index 00000000..531f99cd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginLogo.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.plugin.components + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.crossfade +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.modules.plugin.builtInPluginLogo +import okio.Path +import org.jetbrains.compose.resources.painterResource + +/** + * Renders a plugin's logo, resolving the best available source: + * + * 1. the plugin's own `logo.png` ([logoPath]) when it was installed from disk, + * 2. the bundled placeholder logo for built-in plugins (see [builtInPluginLogo]), + * 3. [fallback] (for example the ability icon) when no logo is available. + */ +@Composable +fun PluginLogo( + plugin: PluginEntry?, + logoPath: Path?, + modifier: Modifier = Modifier, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Crop, + fallback: @Composable () -> Unit) { + val builtInLogo = plugin?.let { builtInPluginLogo(it) } + + when { + logoPath != null -> { + val platformContext = LocalPlatformContext.current + AsyncImage( + model = ImageRequest.Builder(platformContext) + .data(logoPath.toString()) + .crossfade(true) + .build(), + contentDescription = contentDescription, + contentScale = contentScale, + modifier = modifier) + } + + builtInLogo != null -> { + Image( + painter = painterResource(builtInLogo), + contentDescription = contentDescription, + contentScale = contentScale, + modifier = modifier) + } + + else -> fallback() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt new file mode 100644 index 00000000..c919aaa4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.plugin.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.ArrowRight +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.plugin.PluginCapability +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxEye +import org.spotube.spotube.resources.iconsax.IconsaxWifiSquare +import org.spotube.spotube.resources.iconsax.PhosphorDatabaseDuotone +import org.spotube.spotube.resources.iconsax.User +import okio.Path +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumSeparator +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_cancel +import spotube.composeapp.generated.resources.common_close +import spotube.composeapp.generated.resources.plugin_permissions_api_diff +import spotube.composeapp.generated.resources.plugin_permissions_capability_network_desc +import spotube.composeapp.generated.resources.plugin_permissions_capability_network_title +import spotube.composeapp.generated.resources.plugin_permissions_capability_storage_desc +import spotube.composeapp.generated.resources.plugin_permissions_capability_storage_title +import spotube.composeapp.generated.resources.plugin_permissions_capability_webview_desc +import spotube.composeapp.generated.resources.plugin_permissions_capability_webview_title +import spotube.composeapp.generated.resources.plugin_permissions_compare_title +import spotube.composeapp.generated.resources.plugin_permissions_installed_label +import spotube.composeapp.generated.resources.plugin_permissions_none +import spotube.composeapp.generated.resources.plugin_permissions_requested_title +import spotube.composeapp.generated.resources.plugin_permissions_supplied_label +import spotube.composeapp.generated.resources.plugin_version_label + +@Composable +fun PluginPermissionDialog( + pluginInfo: PluginEntry, + title: String, + message: String, + confirmLabel: String?, + existingPlugin: PluginEntry? = null, + logoPath: Path? = null, + onConfirm: (() -> Unit)? = null, + isConfirming: Boolean = false, + onDismiss: () -> Unit) { + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { PluginDialogTitle(title) }, + dialogTitle = title, + actions = { + PluginDialogActions( + secondaryLabel = stringResource( + if (confirmLabel == null) Res.string.common_close else Res.string.common_cancel), + onSecondary = onDismiss, + primaryLabel = confirmLabel, + onPrimary = onConfirm, + primaryLoading = isConfirming) + }) { + PluginDialogBody { + PluginPanel { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp)) { + PluginLogoTile(plugin = pluginInfo, logoPath = logoPath) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + pluginInfo.name, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + PluginPill(pluginInfo.author, icon = Iconsax.User) + PluginPill( + stringResource(Res.string.plugin_version_label, pluginInfo.version), + tint = AppTheme.colors.primary) + } + } + } + if (pluginInfo.description.isNotBlank()) { + Text( + pluginInfo.description, + modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis) + } + } + + PluginCallout(text = message, isWarning = confirmLabel == null) + + existingPlugin?.let { + InstalledComparisonPanel(installedPlugin = it, incomingPlugin = pluginInfo) + } + + PluginSectionLabel( + stringResource(Res.string.plugin_permissions_requested_title), + badge = pluginInfo.capabilities.size.takeIf { it > 0 }?.toString()) + + PluginPanel { + if (pluginInfo.capabilities.isEmpty()) { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PluginIconTile(Iconsax.IconsaxCheckCircle) + Text( + stringResource(Res.string.plugin_permissions_none), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + } else { + pluginInfo.capabilities.forEachIndexed { index, capability -> + if (index > 0) MillenniumSeparator() + CapabilityRow(capability) + } + } + } + } + } +} + +@Composable +private fun InstalledComparisonPanel( + installedPlugin: PluginEntry, + incomingPlugin: PluginEntry) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + PluginSectionLabel(stringResource(Res.string.plugin_permissions_compare_title)) + PluginPanel { + Column( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly) { + VersionStat( + label = stringResource(Res.string.plugin_permissions_installed_label), + value = installedPlugin.version) + MillenniumIcon( + imageVector = FeatherIcons.ArrowRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = AppTheme.colors.onSurfaceVariant) + VersionStat( + label = stringResource(Res.string.plugin_permissions_supplied_label), + value = incomingPlugin.version, + highlighted = true) + } + Text( + stringResource( + Res.string.plugin_permissions_api_diff, + installedPlugin.apiVersion, + incomingPlugin.apiVersion), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + } + } + } +} + +@Composable +private fun VersionStat(label: String, value: String, highlighted: Boolean = false) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + label, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + Text( + stringResource(Res.string.plugin_version_label, value), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = if (highlighted) AppTheme.colors.primary else AppTheme.colors.onSurface) + } +} + +@Composable +private fun CapabilityRow(capability: PluginCapability) { + val (icon, label, description) = when (capability) { + PluginCapability.PERSISTENT_STORAGE -> Triple( + Iconsax.PhosphorDatabaseDuotone, + stringResource(Res.string.plugin_permissions_capability_storage_title), + stringResource(Res.string.plugin_permissions_capability_storage_desc)) + PluginCapability.NETWORK_REQUESTS -> Triple( + Iconsax.IconsaxWifiSquare, + stringResource(Res.string.plugin_permissions_capability_network_title), + stringResource(Res.string.plugin_permissions_capability_network_desc)) + PluginCapability.WEBVIEW -> Triple( + Iconsax.IconsaxEye, + stringResource(Res.string.plugin_permissions_capability_webview_title), + stringResource(Res.string.plugin_permissions_capability_webview_desc)) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PluginIconTile(icon) + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + label, + style = AppTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium) + Text( + description, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt new file mode 100644 index 00000000..a2dfde04 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt @@ -0,0 +1,270 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.plugin.guard + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.component.LoginRequiredDisplay +import org.spotube.spotube.modules.plugin.MetadataAuthViewModel +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAdd +import org.spotube.spotube.resources.iconsax.IconsaxBoxAdd +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxMusicLibrary +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumTheme +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.no_metadata_plugin_action_install +import spotube.composeapp.generated.resources.no_metadata_plugin_action_local +import spotube.composeapp.generated.resources.no_metadata_plugin_local_hint +import spotube.composeapp.generated.resources.no_metadata_plugin_action_select +import spotube.composeapp.generated.resources.no_metadata_plugin_description_install +import spotube.composeapp.generated.resources.no_metadata_plugin_description_select +import spotube.composeapp.generated.resources.no_metadata_plugin_step_discover +import spotube.composeapp.generated.resources.no_metadata_plugin_step_install +import spotube.composeapp.generated.resources.no_metadata_plugin_step_select +import spotube.composeapp.generated.resources.no_metadata_plugin_title + +/** + * Renders [content] only while a metadata plugin is selected. Otherwise shows a guided + * "no plugin" state with a call to action leading to the plugin manager, instead of letting + * the screen sit on an endless loading skeleton. + * + * @param backButton whether the placeholder's top bar shows a back button (for pushed screens). + * @param requireLogin also swap [content] for a friendly sign-in prompt while the plugin needs an + * account and the user isn't signed in (for screens that only make sense when logged in). + * @param showTopBar false when the host screen already draws its own app bar (e.g. Library tabs). + */ +@Composable +fun MetadataPluginGuard( + backButton: Boolean = false, + showTopBar: Boolean = true, + requireLogin: Boolean = false, + viewModel: MetadataPluginGuardViewModel = koinViewModel(), + authViewModel: MetadataAuthViewModel = koinViewModel(), + content: @Composable () -> Unit +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val authState by authViewModel.uiState.collectAsStateWithLifecycle() + + when (val s = state) { + is MetadataPluginGuardState.Available -> + if (requireLogin && authState.loginRequired) { + Column(Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) { + if (showTopBar) ApplicationMainBar(backButton = backButton) + Box( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = LocalAppShellBottomInset.current), + contentAlignment = Alignment.Center) { + LoginRequiredDisplay( + pluginName = authState.pluginName, + onLogin = authViewModel::login, + isLoggingIn = authState.isLoggingIn, + modifier = Modifier.widthIn(max = 420.dp)) + } + } + } else { + content() + } + is MetadataPluginGuardState.Loading -> Box( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) + is MetadataPluginGuardState.Missing -> NoMetadataPluginScreen( + hasInstalledPlugins = s.hasInstalledPlugins, + backButton = backButton, + showTopBar = showTopBar, + onOpenPlugins = viewModel::openPlugins, + onOpenLocalTracks = viewModel::openLocalTracks) + } +} + +@Composable +private fun NoMetadataPluginScreen( + hasInstalledPlugins: Boolean, + backButton: Boolean, + showTopBar: Boolean, + onOpenPlugins: () -> Unit, + onOpenLocalTracks: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background)) { + if (showTopBar) ApplicationMainBar(backButton = backButton) + + Box( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(bottom = LocalAppShellBottomInset.current), + contentAlignment = Alignment.Center) { + Column( + modifier = Modifier + .widthIn(max = 420.dp) + .padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp)) { + HeroBadge() + + Text( + text = stringResource(Res.string.no_metadata_plugin_title), + style = AppTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface, + textAlign = TextAlign.Center) + + Text( + text = stringResource( + if (hasInstalledPlugins) Res.string.no_metadata_plugin_description_select + else Res.string.no_metadata_plugin_description_install), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + + if (!hasInstalledPlugins) { + MillenniumCard(modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Step(1, Res.string.no_metadata_plugin_step_discover) + Step(2, Res.string.no_metadata_plugin_step_install) + Step(3, Res.string.no_metadata_plugin_step_select) + } + } + } + + MillenniumButton( + onClick = onOpenPlugins, + style = MillenniumButtonStyle.Primary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + if (hasInstalledPlugins) Iconsax.IconsaxCheckCircle else Iconsax.IconsaxAdd, + contentDescription = null, + modifier = Modifier.size(18.dp)) + Text( + stringResource( + if (hasInstalledPlugins) Res.string.no_metadata_plugin_action_select + else Res.string.no_metadata_plugin_action_install)) + } + } + + MillenniumButton( + onClick = onOpenLocalTracks, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + Iconsax.IconsaxMusicLibrary, + contentDescription = null, + modifier = Modifier.size(18.dp)) + Text(stringResource(Res.string.no_metadata_plugin_action_local)) + } + } + + Text( + text = stringResource(Res.string.no_metadata_plugin_local_hint), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + } + } + } +} + +@Composable +private fun HeroBadge() { + val primary = AppTheme.colors.primary + Box( + modifier = Modifier + .size(104.dp) + .clip(CircleShape) + .background(Brush.radialGradient(listOf(primary.copy(alpha = 0.22f), primary.copy(alpha = 0f)))), + contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(20.dp)) + .background(primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center) { + MillenniumIcon( + Iconsax.IconsaxBoxAdd, + contentDescription = null, + modifier = Modifier.size(36.dp), + tint = primary) + } + } +} + +@Composable +private fun Step(index: Int, label: StringResource) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box( + modifier = Modifier + .size(26.dp) + .clip(CircleShape) + .background(AppTheme.colors.primary.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center) { + Text( + text = index.toString(), + style = AppTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.primary) + } + Text( + text = stringResource(label), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt new file mode 100644 index 00000000..02cdf6cb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.plugin.guard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab +import org.spotube.spotube.modules.plugin.PluginAbility +import org.spotube.spotube.modules.plugin.PluginManager +import org.spotube.spotube.modules.plugin.PluginManagerStates +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +sealed interface MetadataPluginGuardState { + data object Loading : MetadataPluginGuardState + data object Available : MetadataPluginGuardState + + /** No metadata plugin is selected. [hasInstalledPlugins] tells whether the user only needs + * to pick one (true) or has to install one first (false). */ + data class Missing(val hasInstalledPlugins: Boolean) : MetadataPluginGuardState +} + +class MetadataPluginGuardViewModel( + pluginManager: PluginManager, + private val navigationCommands: NavigationCommands, + private val libraryState: LibraryState +) : ViewModel() { + val uiState: StateFlow = pluginManager.state + .map { state -> + when (state) { + is PluginManagerStates.Loading -> MetadataPluginGuardState.Loading + is PluginManagerStates.Data -> + if (PluginAbility.METADATA in state.selectedPlugins) { + MetadataPluginGuardState.Available + } else { + MetadataPluginGuardState.Missing( + hasInstalledPlugins = state.plugins.any { PluginAbility.METADATA in it.abilities }) + } + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = MetadataPluginGuardState.Loading) + + fun openPlugins() { + navigationCommands.navigateTo(Routes.Plugins) + } + + /** Local tracks are a plugin-free player, so offer them as the way forward. */ + fun openLocalTracks() { + libraryState.onTabSelected(LibraryTab.LocalTracks) + navigationCommands.navigateTo(Routes.Library) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksRepository.kt new file mode 100644 index 00000000..9b931d99 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksRepository.kt @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.saved_tracks + +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.plugin.PluginManager +import io.github.reactivecircus.cache4k.Cache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +class SavedTracksRepository( + val pluginManager: PluginManager +) { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + val plugin + get() = pluginManager.selectedMetadataPlugin.value + + private val savedTracksCache = + Cache.Builder>().build() + private val totalCountCache = Cache.Builder().build() + private val savedTrackIds = MutableStateFlow>(emptySet()) + + val savedTracksIdsFlow : StateFlow> = savedTrackIds.asStateFlow() + + init { + scope.launch { + pluginManager.selectedMetadataPlugin + .filterNotNull() + .flatMapLatest { it.loggedInFlow } + .distinctUntilChanged() + .collect { loggedIn -> + isLoggedIn = loggedIn + invalidateCaches() + } + } + } + + private var isLoggedIn: Boolean = false + + private val authPlugin get() = if (isLoggedIn) plugin else null + + fun invalidateCaches() { + savedTracksCache.invalidateAll() + totalCountCache.invalidateAll() + savedTrackIds.value = emptySet() + } + + suspend fun getSavedTracks(paginationStrategy: PaginationStrategy? = null) = + authPlugin?.let { plugin -> + val strategy = paginationStrategy ?: PaginationStrategy.Offset(0, 50) + savedTracksCache.get(strategy) { + val tracks = pluginManager.withScope { + plugin.use { + val tracks = metadataTrackAPI.savedTracks(paginationStrategy) + tracks + } + } + savedTrackIds.value += tracks.items.map { it.id }.toSet() + tracks + } + } + + suspend fun getSavedTracksCount(): Int? = plugin?.let { plugin -> + totalCountCache.get("count") { + pluginManager.withScope { + plugin.use { + val result = metadataTrackAPI.savedTracks(PaginationStrategy.Offset(0, 1)) + result.totalCount + } + } + } + } + + suspend fun saveTracks(ids: List) { + authPlugin?.let { plugin -> + pluginManager.withScope { + plugin.use { + metadataTrackAPI.saveTracks(ids) + } + } + savedTracksCache.invalidateAll() + totalCountCache.invalidateAll() + savedTrackIds.value += ids.toSet() + } + } + + suspend fun removeSavedTracks(ids: List) { + authPlugin?.let { plugin -> + pluginManager.withScope { + plugin.use { + metadataTrackAPI.removeSavedTracks(ids) + } + } + savedTracksCache.invalidateAll() + totalCountCache.invalidateAll() + savedTrackIds.value -= ids.toSet() + } + } + + suspend fun isSavedTracks(ids: List): List { + // Filter out ids that are already known to be saved + val unknownIds = ids.filterNot { savedTrackIds.value.contains(it) } + + if(unknownIds.isEmpty()) { + return ids.map { true } + } + + val unknownStates = plugin?.let { plugin -> + val savedStates = pluginManager.withScope { + plugin.use { + metadataTrackAPI.isSavedTracks(unknownIds) + } + } + val savedIds = + unknownIds.filterIndexed { index, string -> savedStates.getOrNull(index) == true } + .toSet() + savedTrackIds.value += savedIds + savedStates + } ?: unknownIds.map { false } + + // Combine known saved states with unknown states + return ids.map { id -> + savedTrackIds.value.contains(id) || unknownStates.getOrNull(unknownIds.indexOf(id)) ?: false + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt new file mode 100644 index 00000000..b3da0317 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.saved_tracks + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.map +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.CollectionView +import org.spotube.spotube.modules.library.playlist.AddToPlaylistPicker +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.liked_tracks +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun SavedTracksScreen( + viewModel: SavedTracksViewModel, + audioPlayerQueue: AudioPlayerQueue, + audioPlayer: AudioPlayerInterface, + navigationCommands: NavigationCommands +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) + val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() + val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() + val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() + val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() + + val dataState = state as? SavedTracksScreenState.Data + val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks && + playerState == PlayerState.PLAYING + + val errorMessage = (state as? SavedTracksScreenState.Error)?.message + val isLoading = state is SavedTracksScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) + + CollectionView( + isPlayLoading = playbackUiState.isPlayLoading, + isShuffleLoading = playbackUiState.isShuffleLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading, + title = stringResource(Res.string.saved_tracks_title), + description = dataState?.let { pluralStringResource(Res.plurals.library_local_tracks_count, it.totalCount, it.totalCount) } ?: "", + imageURL = "", + imageResource = Res.drawable.liked_tracks, + ownerName = stringResource(Res.string.saved_tracks_owner_you), + ownerImageURL = null, + onOwnerClick = {}, + onPlay = viewModel::playSavedTracks, + onShufflePlay = viewModel::shufflePlaySavedTracks, + onAddToQueue = viewModel::addSavedTracksToQueue, + isPlaying = isPlaying, + isFollowing = false, + onFollowClick = {}, + showFollowButton = false, + isLoading = isLoading, + error = errorMessage, + onRetry = { viewModel.refresh() }, + tracks = dataState?.tracks ?: emptyList(), + hasMore = dataState?.nextPagination != null, + isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore, + currentTrackId = trackOptionsContext.currentTrackId, + isCurrentTrackPlaying = playerState == PlayerState.PLAYING, + onTrackClick = viewModel::playSavedTracksFromTrack, + onLoadNextPage = viewModel::loadNextTracksPage, + onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) }, + onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) }, + onTrackOptionsAction = viewModel::handleTrackOptionsAction, + trackOptionsState = trackOptionsContext::stateFor, + onBulkDownload = viewModel::downloadTracks, + onBulkAddToQueue = viewModel::addTracksToQueue, + onBulkPlayNext = viewModel::playTracksNext, + onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, + onAddToJam = viewModel::addTracksToJam, + trailingContent = { + AddToPlaylistPicker( + visible = showAddToPlaylistPicker, + currentUserId = currentUserId, + onDismiss = viewModel::dismissAddToPlaylistPicker, + onPlaylistSelected = viewModel::addTracksToPlaylist) + }) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt new file mode 100644 index 00000000..6ee5e398 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -0,0 +1,406 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.saved_tracks + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackUiState +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.ui.component.TrackOptionsAction +import org.spotube.spotube.core.ui.component.TrackOptionsContext +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.downloads.DownloadManager +import org.spotube.spotube.modules.library.LibraryRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.core.component.KoinComponent +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.* + +const val SAVED_TRACKS_COLLECTION_ID = "saved_tracks" + +sealed interface SavedTracksScreenState { + data object Loading : SavedTracksScreenState + + sealed interface Data : SavedTracksScreenState { + val tracks: List + val nextPagination: PaginationStrategy? + val totalCount: Int + + data class Loaded( + override val tracks: List = emptyList(), + override val nextPagination: PaginationStrategy? = null, + override val totalCount: Int = 0) : Data { + fun toLoadingMore(): LoadingMore = LoadingMore( + tracks = tracks, + nextPagination = nextPagination, + totalCount = totalCount) + } + + data class LoadingMore( + override val tracks: List = emptyList(), + override val nextPagination: PaginationStrategy? = null, + override val totalCount: Int = 0) : Data + } + + data class Error(val message: String) : SavedTracksScreenState +} + +@OptIn(ExperimentalCoroutinesApi::class) +class SavedTracksViewModel( + private val repository: SavedTracksRepository, + private val playbackHelper: CollectionPlaybackHelper, + private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository, + private val libraryRepository: LibraryRepository, + private val shareService: ShareService, + private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { + private val logger by injectLogger() + + private val _state = MutableStateFlow(SavedTracksScreenState.Loading) + val uiState: StateFlow = _state.asStateFlow() + + /** Loading flags for this screen's play / shuffle / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) + val savedTrackIdsFlow: StateFlow> + get() = repository.savedTracksIdsFlow + + // Tracks whose save/remove request is still in flight. + private val _pendingSavedTrackIds = MutableStateFlow>(emptySet()) + val pendingSavedTrackIds: StateFlow> = _pendingSavedTrackIds.asStateFlow() + + private val _currentUserId = MutableStateFlow(null) + val currentUserId: StateFlow = _currentUserId.asStateFlow() + + private val _blacklistedTrackIds = MutableStateFlow>(emptySet()) + val blacklistedTrackIds: StateFlow> = _blacklistedTrackIds.asStateFlow() + + private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) + val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() + + private val _tracksToAddToPlaylist = MutableStateFlow>(emptyList()) + private val _showAddToPlaylistPicker = MutableStateFlow(false) + val showAddToPlaylistPicker: StateFlow = _showAddToPlaylistPicker.asStateFlow() + + val trackOptionsContext: StateFlow = combine( + audioPlayerQueue.queueFlow, + audioPlayerQueue.currentQueueEntryFlow, + _blacklistedTrackIds, + _blacklistedArtistIds) { queue, currentEntry, blacklistedTracks, blacklistedArtists -> + TrackOptionsContext( + currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, + queueTrackIds = queue.mapNotNull { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.id + }.toSet(), + blacklistedTrackIds = blacklistedTracks, + blacklistedArtistIds = blacklistedArtists, + forceFavorite = true) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext(forceFavorite = true)) + + init { + viewModelScope.launch { + combine( + repository.pluginManager.selectedMetadataPlugin + .filterNotNull() + .flatMapLatest { it.loggedInFlow } + .distinctUntilChanged(), + repository.savedTracksIdsFlow + ) { _, _ -> }.collect { + loadInitialData() + loadCurrentUser() + } + } + blacklistRepository.blacklistedTracks + .onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() } + .launchIn(viewModelScope) + blacklistRepository.blacklistedArtists + .onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() } + .launchIn(viewModelScope) + } + + private suspend fun loadCurrentUser() { + runCatching { + _currentUserId.value = libraryRepository.currentUser()?.id + }.onFailure { e -> + logger.e(e) { "Failed to load current user" } + } + } + + private suspend fun loadInitialData() { + runCatching { + val tracksResult = repository.getSavedTracks() + val totalCount = repository.getSavedTracksCount() ?: 0 + _state.value = SavedTracksScreenState.Data.Loaded( + tracks = tracksResult?.items ?: emptyList(), + nextPagination = tracksResult?.nextPagination, + totalCount = totalCount) + }.onFailure { e -> + logger.e(e) { "Failed to load saved tracks" } + _state.value = SavedTracksScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + } + + fun loadNextTracksPage() { + viewModelScope.launch { + val currentState = _state.value + if (currentState is SavedTracksScreenState.Data.Loaded && currentState.nextPagination != null) { + _state.value = currentState.toLoadingMore() + runCatching { + val result = repository.getSavedTracks(currentState.nextPagination) + _state.value = SavedTracksScreenState.Data.Loaded( + tracks = currentState.tracks + (result?.items ?: emptyList()), + nextPagination = result?.nextPagination, + totalCount = currentState.totalCount) + }.onFailure { e -> + logger.e(e) { "Failed to load more tracks" } + _state.value = SavedTracksScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + } + } + } + + fun playSavedTracks() { + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") + } + + fun shufflePlaySavedTracks() { + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.SavedTracks, + SAVED_TRACKS_COLLECTION_ID, + "Saved Tracks", + shuffle = true) + } + + fun addSavedTracksToQueue() { + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") + } + + fun playSavedTracksFromTrack(track: MetadataTrack) { + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.SavedTracks, + id = SAVED_TRACKS_COLLECTION_ID, + title = "Saved Tracks", + startTrack = track) + } + + fun refresh() { + viewModelScope.launch { + repository.invalidateCaches() + loadInitialData() + } + } + + fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { + viewModelScope.launch { + when (action) { + is TrackOptionsAction.StartRadio -> {} + is TrackOptionsAction.PlayNext -> { + val queue = audioPlayerQueue.getQueue() + val existing = queue.find { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } + } + + is TrackOptionsAction.AddToQueue -> { + remotePlaybackController.requestTrackAddToQueue(track) + } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) + } + + is TrackOptionsAction.RemoveFromQueue -> { + val queue = audioPlayerQueue.getQueue() + queue.find { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true + }?.let { audioPlayerQueue.removeFromQueue(it) } + } + + is TrackOptionsAction.ToggleFavorite -> {} + is TrackOptionsAction.Download -> downloadManager.enqueue(track) + is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) + is TrackOptionsAction.Share -> { + val uri = track.externalUri?.takeIf { it.isNotBlank() } + if (uri != null) { + shareService.share(uri, track.title) + } + } + is TrackOptionsAction.AddToPlaylist -> { + _tracksToAddToPlaylist.value = listOf(track) + _showAddToPlaylistPicker.value = true + } + } + } + } + + fun addTracksToPlaylist(playlistId: String) { + viewModelScope.launch { + runCatching { + libraryRepository.addTracksToPlaylist( + playlistId, + _tracksToAddToPlaylist.value.map { it.id }) + }.onFailure { e -> + logger.e(e) { "Failed to add tracks to playlist" } + }.onSuccess { + _showAddToPlaylistPicker.value = false + } + } + } + + fun showAddToPlaylistPicker(tracks: List) { + _tracksToAddToPlaylist.value = tracks + _showAddToPlaylistPicker.value = true + } + + fun dismissAddToPlaylistPicker() { + _showAddToPlaylistPicker.value = false + } + + fun downloadTracks(tracks: List) { + tracks.forEach { track -> downloadManager.enqueue(track) } + } + + fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = _blacklistedTrackIds.value + val artistIds = _blacklistedArtistIds.value + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + fun toggleTrackBlacklist(track: MetadataTrack) { + viewModelScope.launch { + blacklistRepository.toggleTrack(track) + } + } + + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + + fun addTracksToQueue(tracks: List) { + remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks") + } + + fun playTracksNext(tracks: List) { + remotePlaybackController.requestTracksPlayNext(tracks, "Saved Tracks") + } + + suspend fun isSavedTracks(trackIds: List): List { + return repository.isSavedTracks(trackIds) + } + + suspend fun saveTracks(trackIds: List) { + repository.saveTracks(trackIds) + } + + suspend fun removeSavedTracks(trackIds: List) { + repository.removeSavedTracks(trackIds) + } + + fun toggleSavedTrack(trackId: String, isSaved: Boolean) { + if (trackId in _pendingSavedTrackIds.value) return + _pendingSavedTrackIds.update { it + trackId } + viewModelScope.launch { + runCatching { + if (isSaved) { + repository.removeSavedTracks(listOf(trackId)) + } else { + repository.saveTracks(listOf(trackId)) + } + }.onFailure { e -> + logger.e(e) { "Failed to toggle saved track" } + } + _pendingSavedTrackIds.update { it - trackId } + } + } + + private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { + if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id + return title == other.title && + durationMs == other.durationMs && + album?.id == other.album?.id && + artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } + } +} + +sealed interface SavedState { + data class Success(val data: T) : SavedState + data object Loading : SavedState + data class Error(val message: String) : SavedState +} + +@Composable +fun rememberIsSavedTracks( + trackIds: List, + repository: SavedTracksRepository = koinInject()): SavedState> { + val scope = rememberCoroutineScope() + var state by remember { mutableStateOf>>(SavedState.Loading) } + + LaunchedEffect(trackIds) { + scope.launch { + state = SavedState.Loading + runCatching { + val result = repository.isSavedTracks(trackIds) + state = SavedState.Success(result) + }.onFailure { e -> + state = SavedState.Error(e.message ?: getString(Res.string.common_unknown_error)) + } + } + } + + return state +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchRepository.kt new file mode 100644 index 00000000..19d61d20 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchRepository.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.search + +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.spotube.modules.plugin.PluginManager +import io.github.reactivecircus.cache4k.Cache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.search_error_no_metadata_plugin + +class SearchRepository( + private val pluginManager: PluginManager +) { + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + private val supportedTypesCache = Cache.Builder>().build() + private val allSearchCache = Cache.Builder>().build() + private val trackSearchCache = Cache.Builder, PaginationResult>().build() + private val albumSearchCache = Cache.Builder, PaginationResult>().build() + private val artistSearchCache = Cache.Builder, PaginationResult>().build() + private val playlistSearchCache = Cache.Builder, PaginationResult>().build() + private val userSearchCache = Cache.Builder, PaginationResult>().build() + + init { + scope.launch { + pluginManager.selectedMetadataPlugin + .filterNotNull() + .distinctUntilChanged() + .collect { invalidateCaches() } + } + } + + fun invalidateCaches() { + supportedTypesCache.invalidateAll() + allSearchCache.invalidateAll() + trackSearchCache.invalidateAll() + albumSearchCache.invalidateAll() + artistSearchCache.invalidateAll() + playlistSearchCache.invalidateAll() + userSearchCache.invalidateAll() + } + + suspend fun loadSupportedSearchTypes(): List { + return supportedTypesCache.get(Unit) { + val plugin = pluginManager.selectedMetadataPlugin.value + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) + pluginManager.withScope { + plugin.use { metadataSearchAPI.supportedSearchTypes } + } + } + } + + suspend fun searchAll(query: String): List { + return allSearchCache.get(query) { + val plugin = pluginManager.selectedMetadataPlugin.value + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) + pluginManager.withScope { + plugin.use { metadataSearchAPI.search(query) } + } + } + } + + suspend fun searchTracks( + query: String, + pagination: PaginationStrategy? = null + ): PaginationResult { + val key = query to pagination + return trackSearchCache.get(key) { + val plugin = pluginManager.selectedMetadataPlugin.value + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) + pluginManager.withScope { + plugin.use { metadataSearchAPI.searchTracks(query, pagination) } + } + } + } + + suspend fun searchAlbums( + query: String, + pagination: PaginationStrategy? = null + ): PaginationResult { + val key = query to pagination + return albumSearchCache.get(key) { + val plugin = pluginManager.selectedMetadataPlugin.value + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) + pluginManager.withScope { + plugin.use { metadataSearchAPI.searchAlbums(query, pagination) } + } + } + } + + suspend fun searchArtists( + query: String, + pagination: PaginationStrategy? = null + ): PaginationResult { + val key = query to pagination + return artistSearchCache.get(key) { + val plugin = pluginManager.selectedMetadataPlugin.value + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) + pluginManager.withScope { + plugin.use { metadataSearchAPI.searchArtists(query, pagination) } + } + } + } + + suspend fun searchPlaylists( + query: String, + pagination: PaginationStrategy? = null + ): PaginationResult { + val key = query to pagination + return playlistSearchCache.get(key) { + val plugin = pluginManager.selectedMetadataPlugin.value + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) + pluginManager.withScope { + plugin.use { metadataSearchAPI.searchPlaylists(query, pagination) } + } + } + } + + suspend fun searchUsers( + query: String, + pagination: PaginationStrategy? = null + ): PaginationResult { + val key = query to pagination + return userSearchCache.get(key) { + val plugin = pluginManager.selectedMetadataPlugin.value + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) + pluginManager.withScope { + plugin.use { metadataSearchAPI.searchUsers(query, pagination) } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt new file mode 100644 index 00000000..03227191 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt @@ -0,0 +1,1223 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.search + + +import org.spotube.millennium.composables.MillenniumMarqueeProgress +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumAutocomplete +import org.spotube.millennium.composables.MillenniumText +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.input.rememberTextFieldState + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.spotube.Platform +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.ui.component.AlbumCard +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.component.ArtistCard +import org.spotube.spotube.core.ui.component.ErrorDisplay +import org.spotube.spotube.core.ui.component.PlaylistCard +import org.spotube.spotube.core.ui.component.TrackList +import org.spotube.spotube.core.ui.component.TrackOptionsAction +import org.spotube.spotube.core.ui.component.TrackOptionsState +import org.spotube.spotube.core.ui.component.UserCard +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.core.ui.component.dragScrollable +import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.getPlatform +import org.spotube.spotube.isDesktop +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.downloads.DownloadsViewModel +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.library.playlist.AddToPlaylistPicker +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxSearchBroken +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import org.spotube.spotube.resources.iconsax.InconsaxClock +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.search_clear_history +import spotube.composeapp.generated.resources.search_placeholder +import spotube.composeapp.generated.resources.library_tab_albums +import spotube.composeapp.generated.resources.library_tab_artists +import spotube.composeapp.generated.resources.library_tab_playlists +import spotube.composeapp.generated.resources.search_results_title +import spotube.composeapp.generated.resources.search_no_search_types +import spotube.composeapp.generated.resources.search_start_typing +import spotube.composeapp.generated.resources.search_no_results +import spotube.composeapp.generated.resources.search_section_tracks +import spotube.composeapp.generated.resources.search_section_users +import spotube.composeapp.generated.resources.search_start_typing_tracks +import spotube.composeapp.generated.resources.search_loading_tracks +import spotube.composeapp.generated.resources.search_no_albums +import spotube.composeapp.generated.resources.search_loading_albums +import spotube.composeapp.generated.resources.search_no_artists +import spotube.composeapp.generated.resources.search_loading_artists +import spotube.composeapp.generated.resources.search_no_playlists +import spotube.composeapp.generated.resources.search_loading_playlists +import spotube.composeapp.generated.resources.search_no_users +import spotube.composeapp.generated.resources.search_loading_users +import spotube.composeapp.generated.resources.search_see_all +import spotube.composeapp.generated.resources.search_searching +import spotube.composeapp.generated.resources.search_warm_crates +import spotube.composeapp.generated.resources.search_warm_cosmos +import spotube.composeapp.generated.resources.search_warm_speakers +import spotube.composeapp.generated.resources.search_warm_vinyl +import spotube.composeapp.generated.resources.search_warm_music_gods +import spotube.composeapp.generated.resources.search_warm_antennas +import spotube.composeapp.generated.resources.search_warm_records +import spotube.composeapp.generated.resources.search_tab_all +import org.jetbrains.compose.resources.StringResource +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.adaptive.WindowBreakpoints + +private val GridMinCellSize = 180.dp + +@Composable +fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { + val audioPlayerQueue: AudioPlayerQueue = koinInject() + val shareService: ShareService = koinInject() + val remotePlaybackController: RemotePlaybackController = koinInject() + val searchResultsTitle = stringResource(Res.string.search_results_title) + val downloadsViewModel: DownloadsViewModel = koinViewModel() + val libraryRepository: LibraryRepository = koinInject() + val blacklistRepository: BlacklistRepository = koinInject() + val navigationCommands: NavigationCommands = koinInject() + val state by viewModel.state.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) + val selectedType = state.selectedSearchType + val scope = rememberCoroutineScope() + val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() + var showAddToPlaylistPicker by remember { mutableStateOf(false) } + var tracksToAddToPlaylist by remember { mutableStateOf>(emptyList()) } + var currentUserId by remember { mutableStateOf(null) } + + val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle( + emptyList() + ) + val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle( + emptyList() + ) + + val isExpanded = + currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded && getPlatform().isDesktop() + + fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = blacklistedTracks.map { it.id }.toSet() + val artistIds = blacklistedArtists.map { it.id }.toSet() + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + fun toggleTrackBlacklist(track: MetadataTrack) { + scope.launch { + blacklistRepository.toggleTrack(track) + } + } + + LaunchedEffect(Unit) { + currentUserId = libraryRepository.currentUser()?.id + } + + val focusManager = LocalFocusManager.current + + fun playSingleTrack(track: MetadataTrack) { + scope.launch { + audioPlayerQueue.load( + entries = listOf(QueueEntry.StreamingTrack(track = track, url = "")), + autoPlay = true, + startPosition = 0, + collectionEntry = null + ) + } + } + + fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { + scope.launch { + when (action) { + is TrackOptionsAction.StartRadio -> {} + is TrackOptionsAction.PlayNext -> { + val queue = audioPlayerQueue.getQueue() + val existing = queue.find { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.id == track.id + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent( + listOf( + QueueEntry.StreamingTrack( + track = track, + url = "" + ) + ) + ) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } + } + + is TrackOptionsAction.AddToQueue -> { + remotePlaybackController.requestTrackAddToQueue(track) + } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) + } + + is TrackOptionsAction.RemoveFromQueue -> { + val queue = audioPlayerQueue.getQueue() + queue.find { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.id == track.id + }?.let { audioPlayerQueue.removeFromQueue(it) } + } + + is TrackOptionsAction.ToggleFavorite -> viewModel.toggleTrackIsFavorite(track.id) + is TrackOptionsAction.Download -> downloadsViewModel.downloadTrack(track) + is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) + is TrackOptionsAction.Share -> { + val uri = track.externalUri?.takeIf { it.isNotBlank() } + if (uri != null) { + shareService.share(uri, track.title) + } + } + + is TrackOptionsAction.AddToPlaylist -> { + tracksToAddToPlaylist = listOf(track) + showAddToPlaylistPicker = true + } + } + } + } + + fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState { + val currentQueueEntry = audioPlayerQueue.currentQueueEntryFlow.value + val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id + val queue = audioPlayerQueue.queueFlow.value + val queueTrackIds = queue.mapNotNull { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.id + }.toSet() + return TrackOptionsState( + isInQueue = queueTrackIds.contains(track.id), + isCurrentlyPlaying = track.id == currentTrackId, + isFavorite = savedTrackIds.contains(track.id), + isBlacklisted = isTrackBlacklisted(track) + ) + } + + val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle(emptyList()) + val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() + + fun getTrackOptionsStateReactive(track: MetadataTrack): TrackOptionsState { + val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id + val queueTrackIds = queue.mapNotNull { entry -> + (entry as? QueueEntry.StreamingTrack)?.track?.id + }.toSet() + return TrackOptionsState( + isInQueue = queueTrackIds.contains(track.id), + isCurrentlyPlaying = track.id == currentTrackId, + isFavorite = savedTrackIds.contains(track.id), + isBlacklisted = isTrackBlacklisted(track) + ) + } + + fun bulkAddToQueue(tracks: List) { + remotePlaybackController.requestTracksAddToQueue(tracks, searchResultsTitle) + } + + fun bulkAddToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + + fun bulkPlayNext(tracks: List) { + remotePlaybackController.requestTracksPlayNext(tracks, searchResultsTitle) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) + .windowInsetsPadding(WindowInsets.statusBars) + ) { + if (!isExpanded) { + SearchBar( + query = state.query, + onQueryChange = viewModel::onQueryChange, + recentSearchSuggestions = state.recentSearchSuggestions, + onRecentSearchClick = { search -> + viewModel.applyRecentSearch(search) + focusManager.clearFocus() + }, + onClearAllRecentSearches = { + viewModel.clearAllRecentSearches() + focusManager.clearFocus() + }, + ) + } else { + ApplicationMainBar(backButton = false) + } + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + if (isExpanded) { + SearchBar( + query = state.query, + onQueryChange = viewModel::onQueryChange, + recentSearchSuggestions = state.recentSearchSuggestions, + onRecentSearchClick = { search -> + viewModel.applyRecentSearch(search) + focusManager.clearFocus() + }, + onClearAllRecentSearches = { + viewModel.clearAllRecentSearches() + focusManager.clearFocus() + } + ) + } + + if (state.supportedSearchTypes.isNotEmpty()) { + SearchTabs( + types = state.supportedSearchTypes, + selectedType = selectedType, + onTabSelected = viewModel::onTabSelected + ) + } + } + + when { + state.isLoadingSearchTypes -> { + SearchLoadingIndicator( + modifier = Modifier + .fillMaxSize() + ) + } + + selectedType == null -> { + SearchMessage( + message = stringResource(Res.string.search_no_search_types), + modifier = Modifier + .fillMaxSize() + ) + } + + selectedType == MetadataSupportedSearchType.ALL -> { + SearchAllTab( + query = state.query, + tracks = state.tracks, + albums = state.albums, + artists = state.artists, + playlists = state.playlists, + users = state.users, + supportedSearchTypes = state.supportedSearchTypes, + onSeeAll = viewModel::onTabSelected, + onTrackClick = ::playSingleTrack, + onTrackOptionsAction = ::handleTrackOptionsAction, + onTrackOptionsState = ::getTrackOptionsStateReactive, + onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) }, + onBulkAddToQueue = ::bulkAddToQueue, + onBulkPlayNext = ::bulkPlayNext, + onBulkAddToPlaylist = { tracks -> + tracksToAddToPlaylist = tracks + showAddToPlaylistPicker = true + }, + onBulkAddToJam = ::bulkAddToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, + onArtistClick = { artist -> + navigationCommands.navigateTo(Routes.Artist(artist.id)) + }, + onAlbumClick = { album -> + navigationCommands.navigateTo(Routes.Album(album.id)) + }, + onArtistsOverflowClick = { track -> + // No-op for now, could show a dialog with all artists + }, + modifier = Modifier + .fillMaxSize() + ) + } + + selectedType == MetadataSupportedSearchType.TRACK -> { + SearchTracksTab( + query = state.query, + tracks = state.tracks, + onLoadNextPage = viewModel::loadNextTracks, + onTrackClick = ::playSingleTrack, + onTrackOptionsAction = ::handleTrackOptionsAction, + onTrackOptionsState = ::getTrackOptionsStateReactive, + onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) }, + onBulkAddToQueue = ::bulkAddToQueue, + onBulkPlayNext = ::bulkPlayNext, + onBulkAddToPlaylist = { tracks -> + tracksToAddToPlaylist = tracks + showAddToPlaylistPicker = true + }, + onBulkAddToJam = ::bulkAddToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, + onArtistClick = { artist -> + navigationCommands.navigateTo(Routes.Artist(artist.id)) + }, + onAlbumClick = { album -> + navigationCommands.navigateTo(Routes.Album(album.id)) + }, + onArtistsOverflowClick = { track -> + // No-op for now, could show a dialog with all artists + }, + modifier = Modifier + .fillMaxSize() + ) + } + + selectedType == MetadataSupportedSearchType.PLAYLIST -> { + SearchPlaylistsTab( + query = state.query, + playlists = state.playlists, + onLoadNextPage = viewModel::loadNextPlaylists, + modifier = Modifier + .fillMaxSize() + ) + } + + selectedType == MetadataSupportedSearchType.ALBUM -> { + SearchAlbumsTab( + query = state.query, + albums = state.albums, + onLoadNextPage = viewModel::loadNextAlbums, + modifier = Modifier + .fillMaxSize() + ) + } + + selectedType == MetadataSupportedSearchType.ARTIST -> { + SearchArtistsTab( + query = state.query, + artists = state.artists, + onLoadNextPage = viewModel::loadNextArtists, + modifier = Modifier + .fillMaxSize() + ) + } + + selectedType == MetadataSupportedSearchType.USER -> { + SearchUsersTab( + query = state.query, + users = state.users, + onLoadNextPage = viewModel::loadNextUsers, + modifier = Modifier + .fillMaxSize() + ) + } + } + } + + AddToPlaylistPicker( + visible = showAddToPlaylistPicker, + currentUserId = currentUserId, + onDismiss = { showAddToPlaylistPicker = false }, + onPlaylistSelected = { playlistId -> + scope.launch { + libraryRepository.addTracksToPlaylist( + playlistId, + tracksToAddToPlaylist.map { it.id } + ) + } + }) + } +} + +@Composable +private fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + recentSearchSuggestions: List, + onRecentSearchClick: (String) -> Unit, + onClearAllRecentSearches: () -> Unit +) { + val hasRecentSearchSuggestions = recentSearchSuggestions.isNotEmpty() + val searchState = rememberTextFieldState(query) + + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() }.collect { onQueryChange(it) } + } + LaunchedEffect(query) { + if (searchState.text.toString() != query) { + searchState.edit { replace(0, length, query) } + } + } + + MillenniumAutocomplete( + state = searchState, + suggestions = recentSearchSuggestions, + onSuggestionSelected = onRecentSearchClick, + suggestionLabel = { it }, + leadingContent = { + MillenniumIcon( + imageVector = Iconsax.InconsaxClock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + }, + placeholder = stringResource(Res.string.search_placeholder), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp), + menuHeader = if (hasRecentSearchSuggestions) { + { + MillenniumListTile( + title = { + MillenniumText( + text = stringResource(Res.string.search_clear_history), + color = AppTheme.colors.error, + style = AppTheme.typography.bodyMedium + ) + }, + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxTrash, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.error + ) + }, + onClick = onClearAllRecentSearches, + minimumHeight = 36.dp, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + leadingSpacing = 8.dp, + shape = AppTheme.shapes.small + ) + } + } else { + null + } + ) +} + +@Composable +private fun SearchTabs( + types: List, + selectedType: MetadataSupportedSearchType?, + onTabSelected: (MetadataSupportedSearchType) -> Unit +) { + val listState = rememberLazyListState() + LazyRow( + state = listState, + modifier = Modifier.fillMaxWidth().dragScrollable(listState), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(types) { type -> + val isSelected = selectedType == type + MillenniumButton( + onClick = { onTabSelected(type) }, + style = if (isSelected) { + MillenniumButtonStyle.Primary + } else { + MillenniumButtonStyle.Secondary + } + ) { + Text(type.tabTitle()) + } + } + } +} + +@Composable +private fun SearchAllTab( + query: String, + tracks: SearchPagedState, + albums: SearchPagedState, + artists: SearchPagedState, + playlists: SearchPagedState, + users: SearchPagedState, + supportedSearchTypes: List, + onSeeAll: (MetadataSupportedSearchType) -> Unit, + onTrackClick: (MetadataTrack) -> Unit, + onTrackOptionsAction: (MetadataTrack, TrackOptionsAction) -> Unit, + onTrackOptionsState: (MetadataTrack) -> TrackOptionsState, + onBulkDownload: (List) -> Unit, + onBulkAddToQueue: (List) -> Unit, + onBulkPlayNext: (List) -> Unit, + onBulkAddToPlaylist: (List) -> Unit, + onBulkAddToJam: (List) -> Unit, + isInJam: Boolean, + isJamGuest: Boolean, + onArtistClick: (MetadataArtist.Basic) -> Unit, + onAlbumClick: (MetadataAlbum.Detailed) -> Unit, + onArtistsOverflowClick: (MetadataTrack) -> Unit, + modifier: Modifier = Modifier +) { + if (query.isBlank()) { + SearchMessage(message = stringResource(Res.string.search_start_typing), modifier = modifier) + return + } + + val hasAnyContent = + tracks.items.isNotEmpty() || playlists.items.isNotEmpty() || albums.items.isNotEmpty() || + artists.items.isNotEmpty() || users.items.isNotEmpty() + + val hasAnyError = listOfNotNull( + tracks.error, + playlists.error, + albums.error, + artists.error, + users.error + ).firstOrNull() + val isLoadingAll = + tracks.isLoading || playlists.isLoading || albums.isLoading || artists.isLoading || users.isLoading + + if (!hasAnyContent && !isLoadingAll && hasAnyError != null) { + ErrorDisplay( + errorMessage = hasAnyError, + onRetry = { }, + modifier = modifier + ) + return + } + + if (!hasAnyContent && !isLoadingAll) { + SearchMessage(message = stringResource(Res.string.search_no_results), modifier = modifier) + return + } + + val bottomInset = LocalAppShellBottomInset.current + val showTracks = + MetadataSupportedSearchType.TRACK in supportedSearchTypes && tracks.items.isNotEmpty() + + TrackList( + tracks = if (showTracks) tracks.items.take(20) else emptyList(), + isLoading = tracks.isLoading && showTracks, + error = if (showTracks) tracks.error else null, + hasMore = false, + simplified = true, + showEmptyMessage = false, + modifier = modifier, + onTrackClick = onTrackClick, + onTrackOptionsAction = onTrackOptionsAction, + trackOptionsState = onTrackOptionsState, + onBulkDownload = onBulkDownload, + onBulkAddToQueue = onBulkAddToQueue, + onBulkPlayNext = onBulkPlayNext, + onBulkAddToPlaylist = onBulkAddToPlaylist, + onBulkAddToJam = onBulkAddToJam, + isInJam = isInJam, + isJamGuest = isJamGuest, + onArtistClick = onArtistClick, + onAlbumClick = onAlbumClick, + onArtistsOverflowClick = onArtistsOverflowClick, + contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset), + headerContent = { + if (showTracks) { + Text( + text = stringResource(Res.string.search_section_tracks), + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp) + ) + } + if (MetadataSupportedSearchType.TRACK in supportedSearchTypes) { + if (tracks.isLoading && tracks.items.isEmpty()) { + SearchLoadingIndicator() + } + if (tracks.error != null && tracks.items.isEmpty()) { + SearchMessageInline(tracks.error, isError = true) + } + } + }, + footerContent = { + Column( + modifier = Modifier.padding(top = 20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + if (MetadataSupportedSearchType.PLAYLIST in supportedSearchTypes && playlists.items.isNotEmpty()) { + SearchHorizontalSection( + title = stringResource(Res.string.library_tab_playlists), + onSeeAll = { onSeeAll(MetadataSupportedSearchType.PLAYLIST) }) { + playlists.items.forEach { playlist -> + item(key = playlist.id) { PlaylistCard(playlist = playlist) } + } + } + } + + if (MetadataSupportedSearchType.ALBUM in supportedSearchTypes && albums.items.isNotEmpty()) { + SearchHorizontalSection( + title = stringResource(Res.string.library_tab_albums), + onSeeAll = { onSeeAll(MetadataSupportedSearchType.ALBUM) }) { + albums.items.forEach { album -> + item(key = album.id) { AlbumCard(album = album) } + } + } + } + + if (MetadataSupportedSearchType.ARTIST in supportedSearchTypes && artists.items.isNotEmpty()) { + SearchHorizontalSection( + title = stringResource(Res.string.library_tab_artists), + onSeeAll = { onSeeAll(MetadataSupportedSearchType.ARTIST) }) { + artists.items.forEach { artist -> + item(key = artist.id) { ArtistCard(artist = artist) } + } + } + } + + if (MetadataSupportedSearchType.USER in supportedSearchTypes && users.items.isNotEmpty()) { + SearchHorizontalSection( + title = stringResource(Res.string.search_section_users), + onSeeAll = { onSeeAll(MetadataSupportedSearchType.USER) }) { + users.items.forEach { user -> + item(key = user.id) { UserCard(user = user) } + } + } + } + } + } + ) +} + +@Composable +private fun SearchTracksTab( + query: String, + tracks: SearchPagedState, + onLoadNextPage: () -> Unit, + onTrackClick: (MetadataTrack) -> Unit, + onTrackOptionsAction: (MetadataTrack, TrackOptionsAction) -> Unit, + onTrackOptionsState: (MetadataTrack) -> TrackOptionsState, + onBulkDownload: (List) -> Unit, + onBulkAddToQueue: (List) -> Unit, + onBulkPlayNext: (List) -> Unit, + onBulkAddToPlaylist: (List) -> Unit, + onBulkAddToJam: (List) -> Unit, + isInJam: Boolean, + isJamGuest: Boolean, + onArtistClick: (MetadataArtist.Basic) -> Unit, + onAlbumClick: (MetadataAlbum.Detailed) -> Unit, + onArtistsOverflowClick: (MetadataTrack) -> Unit, + modifier: Modifier = Modifier +) { + if (query.isBlank()) { + SearchMessage(message = stringResource(Res.string.search_start_typing_tracks), modifier = modifier) + return + } + + if (tracks.isLoading && tracks.items.isEmpty()) { + SearchLoadingIndicator(message = stringResource(Res.string.search_loading_tracks), modifier = modifier) + return + } + + TrackList( + tracks = tracks.items, + error = tracks.error, + hasMore = tracks.hasNextPage, + isLoading = false, + isLoadingNextPage = tracks.isLoading && tracks.items.isNotEmpty(), + onLoadNextPage = onLoadNextPage, + onTrackClick = onTrackClick, + onTrackOptionsAction = onTrackOptionsAction, + trackOptionsState = onTrackOptionsState, + onBulkDownload = onBulkDownload, + onBulkAddToQueue = onBulkAddToQueue, + onBulkPlayNext = onBulkPlayNext, + onBulkAddToPlaylist = onBulkAddToPlaylist, + onBulkAddToJam = onBulkAddToJam, + isInJam = isInJam, + isJamGuest = isJamGuest, + onArtistClick = onArtistClick, + onAlbumClick = onAlbumClick, + onArtistsOverflowClick = onArtistsOverflowClick, + simplified = true, + modifier = modifier + ) +} + +@Composable +private fun SearchAlbumsTab( + query: String, + albums: SearchPagedState, + onLoadNextPage: () -> Unit, + modifier: Modifier = Modifier +) { + SearchGridTab( + query = query, + items = albums.items, + isLoading = albums.isLoading, + error = albums.error, + hasNextPage = albums.hasNextPage, + onLoadNextPage = onLoadNextPage, + emptyMessage = stringResource(Res.string.search_no_albums), + loadingMessage = stringResource(Res.string.search_loading_albums), + modifier = modifier + ) { album -> + AlbumCard(album = album, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun SearchArtistsTab( + query: String, + artists: SearchPagedState, + onLoadNextPage: () -> Unit, + modifier: Modifier = Modifier +) { + SearchGridTab( + query = query, + items = artists.items, + isLoading = artists.isLoading, + error = artists.error, + hasNextPage = artists.hasNextPage, + onLoadNextPage = onLoadNextPage, + emptyMessage = stringResource(Res.string.search_no_artists), + loadingMessage = stringResource(Res.string.search_loading_artists), + modifier = modifier + ) { artist -> + ArtistCard(artist = artist, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun SearchPlaylistsTab( + query: String, + playlists: SearchPagedState, + onLoadNextPage: () -> Unit, + modifier: Modifier = Modifier +) { + SearchGridTab( + query = query, + items = playlists.items, + isLoading = playlists.isLoading, + error = playlists.error, + hasNextPage = playlists.hasNextPage, + onLoadNextPage = onLoadNextPage, + emptyMessage = stringResource(Res.string.search_no_playlists), + loadingMessage = stringResource(Res.string.search_loading_playlists), + modifier = modifier + ) { playlist -> + PlaylistCard(playlist = playlist, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun SearchUsersTab( + query: String, + users: SearchPagedState, + onLoadNextPage: () -> Unit, + modifier: Modifier = Modifier +) { + SearchGridTab( + query = query, + items = users.items, + isLoading = users.isLoading, + error = users.error, + hasNextPage = users.hasNextPage, + onLoadNextPage = onLoadNextPage, + emptyMessage = stringResource(Res.string.search_no_users), + loadingMessage = stringResource(Res.string.search_loading_users), + modifier = modifier + ) { user -> + UserCard(user = user, modifier = Modifier.fillMaxWidth()) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SearchGridTab( + query: String, + items: List, + isLoading: Boolean, + error: String?, + hasNextPage: Boolean, + onLoadNextPage: () -> Unit, + emptyMessage: String, + loadingMessage: String, + modifier: Modifier = Modifier, + itemContent: @Composable (T) -> Unit +) { + if (query.isBlank()) { + SearchMessage(message = stringResource(Res.string.search_start_typing), modifier = modifier) + return + } + + val gridState = rememberLazyGridState() + val bottomInset = LocalAppShellBottomInset.current + + LaunchedEffect(gridState, items.size, hasNextPage, isLoading) { + snapshotFlow { gridState.layoutInfo } + .map { layoutInfo -> + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + lastVisible to layoutInfo.totalItemsCount + } + .distinctUntilChanged() + .collect { (lastVisible, totalItems) -> + if (totalItems > 0 && lastVisible >= totalItems - 6 && hasNextPage && !isLoading) { + onLoadNextPage() + } + } + } + + when { + isLoading && items.isEmpty() -> { + SearchLoadingIndicator( + message = loadingMessage, + modifier = modifier + ) + } + + error != null && items.isEmpty() -> { + ErrorDisplay( + errorMessage = error, + onRetry = onLoadNextPage, + modifier = modifier + ) + } + + !isLoading && items.isEmpty() -> { + SearchMessage(message = emptyMessage, modifier = modifier) + } + + else -> { + LazyVerticalGrid( + state = gridState, + columns = GridCells.Adaptive(minSize = GridMinCellSize), + modifier = modifier.padding(horizontal = 12.dp), + contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(items) { item -> + itemContent(item) + } + + if (isLoading && items.isNotEmpty()) { + items(4) { + SkeletonTree(true) { + PlayableCard( + title = "Sample Title", + subtitle = "Sample Subtitle", + imageURL = "https://placehold.co/600x400" + ) + } + } + } + } + } + } +} + +@Composable +private fun SearchHorizontalSection( + title: String, + onSeeAll: () -> Unit, + content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = title, + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + + Text( + text = stringResource(Res.string.search_see_all), + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.primary, + modifier = Modifier.clickable(onClick = onSeeAll) + ) + } + + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + content = content + ) + } +} + +@Composable +private fun SearchTrackRow( + index: Int, + track: MetadataTrack, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = (index + 1).toString(), + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.End + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = track.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1 + ) + Text( + text = track.artists.joinToString { it.name }, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1 + ) + } + + if (track.album != null) Text( + text = track.album!!.title, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.weight(1f) + ) + } +} + +private val WarmSearchMessages: List = listOf( + Res.string.search_warm_crates, + Res.string.search_warm_cosmos, + Res.string.search_warm_speakers, + Res.string.search_warm_vinyl, + Res.string.search_warm_music_gods, + Res.string.search_warm_antennas, + Res.string.search_warm_records +) + +@Composable +private fun SearchLoadingIndicator( + message: String? = null, + modifier: Modifier = Modifier +) { + val infiniteTransition = rememberInfiniteTransition(label = "search-loading") + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "icon-rotation" + ) + val bounce by infiniteTransition.animateFloat( + initialValue = -8f, + targetValue = 8f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 800, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "icon-bounce" + ) + val messageIndex by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = WarmSearchMessages.size.toFloat(), + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = WarmSearchMessages.size * 2000, + easing = LinearEasing + ), + repeatMode = RepeatMode.Restart + ), + label = "message-cycle" + ) + val displayMessage = + message ?: stringResource(WarmSearchMessages[messageIndex.toInt() % WarmSearchMessages.size]) + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier.size(64.dp), + contentAlignment = Alignment.Center + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxSearchBroken, + contentDescription = stringResource(Res.string.search_searching), + tint = AppTheme.colors.primary, + modifier = Modifier + .size(48.dp) + .graphicsLayer { + rotationZ = rotation + translationY = bounce + }) + } + MillenniumMarqueeProgress( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 16.dp) + ) + AnimatedContent( + targetState = displayMessage, + transitionSpec = { + fadeIn(tween(300)) togetherWith fadeOut(tween(300)) + }, + label = "message-transition" + ) { msg -> + Text( + text = msg, + color = AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +private fun SearchMessage( + message: String, + modifier: Modifier = Modifier, + isError: Boolean = false +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Text( + text = message, + color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp) + ) + } +} + +@Composable +private fun SearchMessageInline( + message: String, + isError: Boolean = false +) { + Text( + text = message, + color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) +} + +@Composable +private fun MetadataSupportedSearchType.tabTitle(): String = when (this) { + MetadataSupportedSearchType.ALL -> stringResource(Res.string.search_tab_all) + MetadataSupportedSearchType.TRACK -> stringResource(Res.string.search_section_tracks) + MetadataSupportedSearchType.PLAYLIST -> stringResource(Res.string.library_tab_playlists) + MetadataSupportedSearchType.ALBUM -> stringResource(Res.string.library_tab_albums) + MetadataSupportedSearchType.ARTIST -> stringResource(Res.string.library_tab_artists) + MetadataSupportedSearchType.USER -> stringResource(Res.string.search_section_users) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt new file mode 100644 index 00000000..d1a8d836 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt @@ -0,0 +1,608 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.search_error_capabilities +import spotube.composeapp.generated.resources.search_error_search +import spotube.composeapp.generated.resources.search_error_tracks +import spotube.composeapp.generated.resources.search_error_albums +import spotube.composeapp.generated.resources.search_error_artists +import spotube.composeapp.generated.resources.search_error_playlists +import spotube.composeapp.generated.resources.search_error_users + +data class SearchPagedState( + val items: List = emptyList(), + val nextPagination: PaginationStrategy? = null, + val hasNextPage: Boolean = true, + val isLoading: Boolean = false, + val error: String? = null) + +data class SearchScreenState( + val query: String = "", + val supportedSearchTypes: List = emptyList(), + val selectedSearchType: MetadataSupportedSearchType? = null, + val isLoadingSearchTypes: Boolean = false, + val recentSearches: List = emptyList(), + val tracks: SearchPagedState = SearchPagedState(), + val albums: SearchPagedState = SearchPagedState(), + val artists: SearchPagedState = SearchPagedState(), + val playlists: SearchPagedState = SearchPagedState(), + val users: SearchPagedState = SearchPagedState()) { + val recentSearchSuggestions: List + get() = if (query.isBlank()) { + recentSearches + } else { + recentSearches.filter { it.contains(query, ignoreCase = true) } + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class SearchScreenViewModel( + private val repository: SearchRepository, + private val savedTracksRepository: SavedTracksRepository, + private val database: Database) : ViewModel() { + companion object { + private val RECENT_SEARCHES_KEY = stringPreferencesKey("recent_searches") + private val SEARCH_TYPES_ORDER = listOf( + MetadataSupportedSearchType.ALL, + MetadataSupportedSearchType.TRACK, + MetadataSupportedSearchType.PLAYLIST, + MetadataSupportedSearchType.ALBUM, + MetadataSupportedSearchType.ARTIST, + MetadataSupportedSearchType.USER) + private const val QUERY_DEBOUNCE_MS = 350L + private const val RECENT_SEARCHES_LIMIT = 12 + private const val TRACK_PAGE_SIZE = 20 + private const val ALL_ROW_PAGE_SIZE = 12 + } + + private val json = Json { ignoreUnknownKeys = true } + + private val _state = MutableStateFlow(SearchScreenState()) + val state: StateFlow = _state.asStateFlow() + + private var queryJob: Job? = null + + init { + viewModelScope.launch { + database.settingsDataStore.data.collect { preferences -> + val recent = parseRecentSearches(preferences[RECENT_SEARCHES_KEY]) + _state.value = _state.value.copy(recentSearches = recent) + } + } + + loadSupportedSearchTypes() + } + + fun onQueryChange(query: String) { + _state.value = _state.value.copy(query = query) + queryJob?.cancel() + queryJob = viewModelScope.launch { + delay(QUERY_DEBOUNCE_MS) + saveRecentSearch(query) + refreshCurrentSelection(reset = true) + } + } + + fun applyRecentSearch(query: String) { + _state.value = _state.value.copy(query = query) + queryJob?.cancel() + viewModelScope.launch { + saveRecentSearch(query) + refreshCurrentSelection(reset = true) + } + } + + fun clearQuery() { + queryJob?.cancel() + _state.value = _state.value.copy( + query = "", + tracks = SearchPagedState(), + albums = SearchPagedState(), + artists = SearchPagedState(), + playlists = SearchPagedState(), + users = SearchPagedState()) + } + + fun clearAllRecentSearches() { + viewModelScope.launch { + database.settingsDataStore.edit { preferences -> + preferences.remove(RECENT_SEARCHES_KEY) + } + } + } + + fun removeRecentSearch(query: String) { + viewModelScope.launch { + val current = _state.value.recentSearches + val updated = current.filterNot { it.equals(query, ignoreCase = true) } + persistRecentSearches(updated) + } + } + + fun onTabSelected(type: MetadataSupportedSearchType) { + _state.value = _state.value.copy( + selectedSearchType = type, + tracks = SearchPagedState(), + albums = SearchPagedState(), + artists = SearchPagedState(), + playlists = SearchPagedState(), + users = SearchPagedState()) + if (_state.value.query.isNotBlank()) { + viewModelScope.launch { + refreshCurrentSelection(reset = true) + } + } + } + + fun loadNextTracks() { + val current = _state.value + if (current.query.isBlank()) return + if (current.tracks.isLoading || current.tracks.nextPagination == null) return + + viewModelScope.launch { + loadTracks(reset = false) + } + } + + fun loadNextAlbums() { + val current = _state.value + if (current.query.isBlank()) return + if (current.albums.isLoading || current.albums.nextPagination == null) return + + viewModelScope.launch { + loadAlbums(reset = false) + } + } + + fun loadNextArtists() { + val current = _state.value + if (current.query.isBlank()) return + if (current.artists.isLoading || current.artists.nextPagination == null) return + + viewModelScope.launch { + loadArtists(reset = false) + } + } + + fun loadNextPlaylists() { + val current = _state.value + if (current.query.isBlank()) return + if (current.playlists.isLoading || current.playlists.nextPagination == null) return + + viewModelScope.launch { + loadPlaylists(reset = false) + } + } + + fun loadNextUsers() { + val current = _state.value + if (current.query.isBlank()) return + if (current.users.isLoading || current.users.nextPagination == null) return + + viewModelScope.launch { + loadUsers(reset = false) + } + } + + private fun loadSupportedSearchTypes() { + viewModelScope.launch { + runCatching { + repository.loadSupportedSearchTypes() + }.onSuccess { supportedTypes -> + val ordered = SEARCH_TYPES_ORDER.filter { it in supportedTypes } + val selected = _state.value.selectedSearchType + val selectedType = when { + selected != null && selected in ordered -> selected + MetadataSupportedSearchType.ALL in ordered -> MetadataSupportedSearchType.ALL + else -> ordered.firstOrNull() + } + + _state.value = _state.value.copy( + supportedSearchTypes = ordered, + selectedSearchType = selectedType, + isLoadingSearchTypes = false) + + if (_state.value.query.isNotBlank()) { + refreshCurrentSelection(reset = true) + } + }.onFailure { + _state.value = _state.value.copy( + isLoadingSearchTypes = false, + supportedSearchTypes = emptyList(), + selectedSearchType = null, + tracks = SearchPagedState(error = it.message ?: getString(Res.string.search_error_capabilities))) + } + } + } + + private suspend fun refreshCurrentSelection(reset: Boolean) { + val current = _state.value + if (current.query.isBlank()) { + _state.value = current.copy( + tracks = SearchPagedState(), + albums = SearchPagedState(), + artists = SearchPagedState(), + playlists = SearchPagedState(), + users = SearchPagedState()) + return + } + + when (current.selectedSearchType) { + MetadataSupportedSearchType.ALL -> loadAll(reset = reset) + MetadataSupportedSearchType.TRACK -> loadTracks(reset = reset) + MetadataSupportedSearchType.ALBUM -> loadAlbums(reset = reset) + MetadataSupportedSearchType.ARTIST -> loadArtists(reset = reset) + MetadataSupportedSearchType.PLAYLIST -> loadPlaylists(reset = reset) + MetadataSupportedSearchType.USER -> loadUsers(reset = reset) + null -> Unit + } + } + + private suspend fun loadAll(reset: Boolean) = coroutineScope { + if (!reset) return@coroutineScope + + val query = _state.value.query.trim() + val supported = _state.value.supportedSearchTypes + + _state.value = _state.value.copy( + tracks = SearchPagedState(isLoading = true), + albums = SearchPagedState(isLoading = true), + artists = SearchPagedState(isLoading = true), + playlists = SearchPagedState(isLoading = true), + users = SearchPagedState(isLoading = true)) + + if (MetadataSupportedSearchType.ALL in supported) { + runCatching { + repository.searchAll(query) + }.onSuccess { results -> + val tracks = results.filterIsInstance().map { it.data } + val playlists = results.filterIsInstance().map { it.data } + val albums = results.filterIsInstance().map { it.data } + val artists = results.filterIsInstance().map { it.data } + val users = results.filterIsInstance().map { it.data } + + val trackSubset = tracks.take(TRACK_PAGE_SIZE) + savedTracksRepository.isSavedTracks(trackSubset.map { it.id }) + + _state.value = _state.value.copy( + tracks = SearchPagedState( + items = tracks.take(TRACK_PAGE_SIZE), + nextPagination = null, + hasNextPage = false, + isLoading = false), + playlists = SearchPagedState( + items = playlists.take(ALL_ROW_PAGE_SIZE), + nextPagination = null, + hasNextPage = false, + isLoading = false), + albums = SearchPagedState( + items = albums.take(ALL_ROW_PAGE_SIZE), + nextPagination = null, + hasNextPage = false, + isLoading = false), + artists = SearchPagedState( + items = artists.take(ALL_ROW_PAGE_SIZE), + nextPagination = null, + hasNextPage = false, + isLoading = false), + users = SearchPagedState( + items = users.take(ALL_ROW_PAGE_SIZE), + nextPagination = null, + hasNextPage = false, + isLoading = false)) + }.onFailure { + val message = it.message ?: getString(Res.string.search_error_search) + _state.value = _state.value.copy( + tracks = SearchPagedState(isLoading = false, error = message), + playlists = SearchPagedState(isLoading = false, error = message), + albums = SearchPagedState(isLoading = false, error = message), + artists = SearchPagedState(isLoading = false, error = message), + users = SearchPagedState(isLoading = false, error = message)) + } + return@coroutineScope + } + + val jobs = mutableListOf() + if (MetadataSupportedSearchType.TRACK in supported) jobs += launch { loadTracks(reset = true) } + if (MetadataSupportedSearchType.ALBUM in supported) jobs += launch { loadAlbums(reset = true) } + if (MetadataSupportedSearchType.ARTIST in supported) jobs += launch { loadArtists(reset = true) } + if (MetadataSupportedSearchType.PLAYLIST in supported) jobs += launch { loadPlaylists(reset = true) } + if (MetadataSupportedSearchType.USER in supported) jobs += launch { loadUsers(reset = true) } + jobs.joinAll() + + _state.value = _state.value.copy( + playlists = _state.value.playlists.copy(items = _state.value.playlists.items.take(ALL_ROW_PAGE_SIZE)), + albums = _state.value.albums.copy(items = _state.value.albums.items.take(ALL_ROW_PAGE_SIZE)), + artists = _state.value.artists.copy(items = _state.value.artists.items.take(ALL_ROW_PAGE_SIZE)), + users = _state.value.users.copy(items = _state.value.users.items.take(ALL_ROW_PAGE_SIZE))) + } + + private suspend fun loadTracks(reset: Boolean) { + val currentState = _state.value.tracks + val query = _state.value.query.trim() + val pagination = if (reset) null else currentState.nextPagination ?: return + + _state.value = _state.value.copy( + tracks = if (reset) { + SearchPagedState(isLoading = true) + } else { + currentState.copy(isLoading = true, error = null) + } + ) + + runCatching { + repository.searchTracks(query, pagination) + }.onSuccess { page -> + val mergedItems = if (reset) { + page.items.map { it.data } + } else { + _state.value.tracks.items + page.items.map { it.data } + } + + savedTracksRepository.isSavedTracks(mergedItems.map { it.id }) + + _state.value = _state.value.copy( + tracks = SearchPagedState( + items = mergedItems, + nextPagination = page.nextPagination, + hasNextPage = page.nextPagination != null, + isLoading = false, + error = null) + ) + }.onFailure { throwable -> + _state.value = _state.value.copy( + tracks = _state.value.tracks.copy( + isLoading = false, + hasNextPage = false, + error = throwable.message ?: getString(Res.string.search_error_tracks)) + ) + } + } + + private suspend fun loadAlbums(reset: Boolean) { + val currentState = _state.value.albums + val query = _state.value.query.trim() + val pagination = if (reset) null else currentState.nextPagination ?: return + + _state.value = _state.value.copy( + albums = if (reset) { + SearchPagedState(isLoading = true) + } else { + currentState.copy(isLoading = true, error = null) + } + ) + + runCatching { + repository.searchAlbums(query, pagination) + }.onSuccess { page -> + val mergedItems = if (reset) { + page.items.map { it.data } + } else { + _state.value.albums.items + page.items.map { it.data } + } + + _state.value = _state.value.copy( + albums = SearchPagedState( + items = mergedItems, + nextPagination = page.nextPagination, + hasNextPage = page.nextPagination != null, + isLoading = false, + error = null) + ) + }.onFailure { throwable -> + _state.value = _state.value.copy( + albums = _state.value.albums.copy( + isLoading = false, + hasNextPage = false, + error = throwable.message ?: getString(Res.string.search_error_albums)) + ) + } + } + + private suspend fun loadArtists(reset: Boolean) { + val currentState = _state.value.artists + val query = _state.value.query.trim() + val pagination = if (reset) null else currentState.nextPagination ?: return + + _state.value = _state.value.copy( + artists = if (reset) { + SearchPagedState(isLoading = true) + } else { + currentState.copy(isLoading = true, error = null) + } + ) + + runCatching { + repository.searchArtists(query, pagination) + }.onSuccess { page -> + val mergedItems = if (reset) { + page.items.map { it.data } + } else { + _state.value.artists.items + page.items.map { it.data } + } + + _state.value = _state.value.copy( + artists = SearchPagedState( + items = mergedItems, + nextPagination = page.nextPagination, + hasNextPage = page.nextPagination != null, + isLoading = false, + error = null) + ) + }.onFailure { throwable -> + _state.value = _state.value.copy( + artists = _state.value.artists.copy( + isLoading = false, + hasNextPage = false, + error = throwable.message ?: getString(Res.string.search_error_artists)) + ) + } + } + + private suspend fun loadPlaylists(reset: Boolean) { + val currentState = _state.value.playlists + val query = _state.value.query.trim() + val pagination = if (reset) null else currentState.nextPagination ?: return + + _state.value = _state.value.copy( + playlists = if (reset) { + SearchPagedState(isLoading = true) + } else { + currentState.copy(isLoading = true, error = null) + } + ) + + runCatching { + repository.searchPlaylists(query, pagination) + }.onSuccess { page -> + val mergedItems = if (reset) { + page.items.map { it.data } + } else { + _state.value.playlists.items + page.items.map { it.data } + } + + _state.value = _state.value.copy( + playlists = SearchPagedState( + items = mergedItems, + nextPagination = page.nextPagination, + hasNextPage = page.nextPagination != null, + isLoading = false, + error = null) + ) + }.onFailure { throwable -> + _state.value = _state.value.copy( + playlists = _state.value.playlists.copy( + isLoading = false, + hasNextPage = false, + error = throwable.message ?: getString(Res.string.search_error_playlists)) + ) + } + } + + private suspend fun loadUsers(reset: Boolean) { + val currentState = _state.value.users + val query = _state.value.query.trim() + val pagination = if (reset) null else currentState.nextPagination ?: return + + _state.value = _state.value.copy( + users = if (reset) { + SearchPagedState(isLoading = true) + } else { + currentState.copy(isLoading = true, error = null) + } + ) + + runCatching { + repository.searchUsers(query, pagination) + }.onSuccess { page -> + val mergedItems = if (reset) { + page.items.map { it.data } + } else { + _state.value.users.items + page.items.map { it.data } + } + + _state.value = _state.value.copy( + users = SearchPagedState( + items = mergedItems, + nextPagination = page.nextPagination, + hasNextPage = page.nextPagination != null, + isLoading = false, + error = null) + ) + }.onFailure { throwable -> + _state.value = _state.value.copy( + users = _state.value.users.copy( + isLoading = false, + hasNextPage = false, + error = throwable.message ?: getString(Res.string.search_error_users)) + ) + } + } + + val savedTrackIds + get() = savedTracksRepository.savedTracksIdsFlow + + fun toggleTrackIsFavorite(trackId: String) { + viewModelScope.launch { + val savedIds = savedTrackIds.value + if (savedIds.contains(trackId)) { + savedTracksRepository.removeSavedTracks(listOf(trackId)) + } else { + savedTracksRepository.saveTracks(listOf(trackId)) + } + } + } + + private suspend fun saveRecentSearch(query: String) { + val normalized = query.trim() + if (normalized.isBlank()) return + + val current = _state.value.recentSearches + val updated = (listOf(normalized) + current.filterNot { it.equals(normalized, ignoreCase = true) }) + .take(RECENT_SEARCHES_LIMIT) + persistRecentSearches(updated) + } + + private suspend fun persistRecentSearches(searches: List) { + database.settingsDataStore.edit { preferences -> + if (searches.isEmpty()) { + preferences.remove(RECENT_SEARCHES_KEY) + } else { + preferences[RECENT_SEARCHES_KEY] = json.encodeToString(searches) + } + } + } + + private fun parseRecentSearches(raw: String?): List { + if (raw.isNullOrBlank()) return emptyList() + return runCatching { + json.decodeFromString>(raw) + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + .take(RECENT_SEARCHES_LIMIT) + }.getOrDefault(emptyList()) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt new file mode 100644 index 00000000..0643cb4b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt @@ -0,0 +1,263 @@ +package org.spotube.spotube.modules.settings + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumButton +import androidx.compose.runtime.getValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.modules.settings.sections.SettingsItemIcon +import org.spotube.spotube.modules.settings.sections.settingsSectionCard +import org.spotube.spotube.modules.settings.sections.settingsSectionHeader +import org.spotube.spotube.resources.iconsax.CustomServer +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxEye +import org.spotube.spotube.resources.iconsax.IconsaxInformation +import org.spotube.spotube.resources.iconsax.IconsaxTag +import org.spotube.spotube.resources.iconsax.IconsaxWifiSquare +import org.spotube.spotube.resources.iconsax.User +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_error_port_range +import spotube.composeapp.generated.resources.settings_error_whole_number +import spotube.composeapp.generated.resources.settings_jam_broker_client_id +import spotube.composeapp.generated.resources.settings_jam_broker_client_id_subtitle +import spotube.composeapp.generated.resources.settings_jam_broker_diagnostics_subtitle +import spotube.composeapp.generated.resources.settings_jam_broker_host +import spotube.composeapp.generated.resources.settings_jam_broker_password +import spotube.composeapp.generated.resources.settings_jam_broker_placeholder_note +import spotube.composeapp.generated.resources.settings_jam_broker_port +import spotube.composeapp.generated.resources.settings_jam_broker_test +import spotube.composeapp.generated.resources.settings_jam_broker_testing +import spotube.composeapp.generated.resources.settings_jam_broker_tls +import spotube.composeapp.generated.resources.settings_jam_broker_tls_subtitle +import spotube.composeapp.generated.resources.settings_jam_section_connection +import spotube.composeapp.generated.resources.settings_jam_section_credentials +import spotube.composeapp.generated.resources.settings_jam_section_diagnostics +import spotube.composeapp.generated.resources.settings_jam_section_identity +import spotube.composeapp.generated.resources.settings_jam_broker_username +import spotube.composeapp.generated.resources.settings_section_jam +import spotube.composeapp.generated.resources.settings_jam_broker_content_description +import spotube.composeapp.generated.resources.settings_jam_broker_username_placeholder +import spotube.composeapp.generated.resources.settings_jam_broker_test_fail +import spotube.composeapp.generated.resources.jam_error_connection_failed + +@Composable +fun JamSettingsScreen( + viewModel: JamSettingsViewModel = koinViewModel()) { + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { Text(stringResource(Res.string.settings_section_jam)) }) + JamSettingsContent( + viewModel = viewModel, + modifier = Modifier.weight(1f)) + } +} + +/** + * The Group Jam settings UI without its own app bar, so it can be embedded + * as a tab in the large-screen settings layout as well as hosted by [JamSettingsScreen]. + */ +@Composable +internal fun JamSettingsContent( + viewModel: JamSettingsViewModel = koinViewModel(), + modifier: Modifier = Modifier) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val broker = uiState.settings.jamBroker + val shellBottomInset = LocalAppShellBottomInset.current + val contentPadding = remember(shellBottomInset) { + PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) + } + + Box( + modifier = modifier + .fillMaxSize()) { + LazyColumn( + modifier = Modifier + .widthIn(max = 1280.dp) + .align(Alignment.TopCenter), + contentPadding = contentPadding) { + settingsSectionHeader(Res.string.settings_jam_section_connection) + settingsSectionCard( + items = listOf( + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_host), + value = broker.host, + placeholder = "broker.example.com", + icon = { SettingsItemIcon(Iconsax.CustomServer, stringResource(Res.string.settings_jam_broker_content_description)) }, + onValueSaved = { host -> + viewModel.update { + copy( + jamBroker = jamBroker.copy( + host = host + ) + ) + } + }) + }, + { + val errorWholeNumber = + stringResource(Res.string.settings_error_whole_number) + val errorPortRange = + stringResource(Res.string.settings_error_port_range) + + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_port), + value = broker.port.toString(), + placeholder = "1883", + normalize = { it.filter(Char::isDigit).take(5) }, + validate = { value -> + val port = value.toIntOrNull() + when { + port == null -> errorWholeNumber + port !in 1..65535 -> errorPortRange + else -> null + } + }, + onValueSaved = { port -> + viewModel.update { copy(jamBroker = jamBroker.copy(port = port.toInt())) } + }, + icon = { + SettingsItemIcon( + Iconsax.CustomServer, + stringResource(Res.string.settings_jam_broker_port) + ) + }) + }, + { + SwitchSettingCard( + title = stringResource(Res.string.settings_jam_broker_tls), + subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle), + checked = broker.useTls, + onCheckedChange = { tls -> + viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) } + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxWifiSquare, + stringResource(Res.string.settings_jam_broker_tls) + ) + }) + })) + settingsSectionHeader(Res.string.settings_jam_section_credentials) + settingsSectionCard( + items = listOf( + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_username), + value = broker.username.orEmpty(), + placeholder = stringResource(Res.string.settings_jam_broker_username_placeholder), + onValueSaved = { username -> + viewModel.update { + copy(jamBroker = jamBroker.copy(username = username.ifBlank { null })) + } + }, + icon = { + SettingsItemIcon( + Iconsax.User, + stringResource(Res.string.settings_jam_broker_username) + ) + }) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_password), + value = broker.password.orEmpty(), + placeholder = "••••••••", + onValueSaved = { password -> + viewModel.update { + copy(jamBroker = jamBroker.copy(password = password.ifBlank { null })) + } + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxEye, + stringResource(Res.string.settings_jam_broker_password) + ) + }) + })) + settingsSectionHeader(Res.string.settings_jam_section_identity) + settingsSectionCard( + items = listOf( + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_client_id), + subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle), + value = broker.clientIdPrefix, + placeholder = "spotube", + onValueSaved = { prefix -> + viewModel.update { + copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" })) + } + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxTag, + stringResource(Res.string.settings_jam_broker_client_id) + ) + }) + })) + settingsSectionHeader(Res.string.settings_jam_section_diagnostics) + settingsSectionCard( + items = listOf( + { + val canTest = broker.host.isNotBlank() && !uiState.isTesting + val resultMessage = uiState.testResult?.fold( + onSuccess = { it }, + onFailure = { + stringResource( + Res.string.settings_jam_broker_test_fail, + it.message ?: stringResource(Res.string.jam_error_connection_failed)) + }) + + SettingCardItem( + enabled = canTest, + title = stringResource(Res.string.settings_jam_broker_test), + subtitle = when { + uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing) + resultMessage != null -> resultMessage + else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle) + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxInformation, + stringResource(Res.string.settings_jam_broker_test)) + }, + trailingContent = { + MillenniumButton( + onClick = viewModel::testConnection, + enabled = broker.host.isNotBlank(), + loading = uiState.isTesting) { + Text(stringResource(Res.string.settings_jam_broker_test)) + } + }, + onClick = viewModel::testConnection) + })) + item { + Text( + text = stringResource(Res.string.settings_jam_broker_placeholder_note), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt new file mode 100644 index 00000000..61e58722 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt @@ -0,0 +1,53 @@ +package org.spotube.spotube.modules.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.core.jam.JamRoomClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class JamSettingsUiState( + val settings: UserSettings = UserSettings(), + val isTesting: Boolean = false, + val testResult: Result? = null) + +class JamSettingsViewModel( + private val settingsRepository: SettingsRepository, + private val jamRoomClient: JamRoomClient) : ViewModel() { + private val _uiState = MutableStateFlow(JamSettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + settingsRepository.userSettings.collect { settings -> + _uiState.update { it.copy(settings = settings) } + } + } + } + + fun update(transform: UserSettings.() -> UserSettings) { + viewModelScope.launch { + settingsRepository.updateSettings(uiState.value.settings.transform()) + } + } + + fun testConnection() { + if (_uiState.value.isTesting) return + _uiState.update { it.copy(isTesting = true, testResult = null) } + viewModelScope.launch { + try { + val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker) + _uiState.update { it.copy(testResult = result) } + } finally { + _uiState.update { it.copy(isTesting = false) } + } + } + } + + fun clearTestResult() { + _uiState.update { it.copy(testResult = null) } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt new file mode 100644 index 00000000..3a0b58b8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt @@ -0,0 +1,128 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.* + +data class LocalMediaFolderRowUi(val path: String, val name: String) + +data class LocalMediaFoldersUiState( + val isOpen: Boolean = false, + val configuredCount: Int = 0, + val singleConfiguredPath: String? = null, + val folders: List = emptyList(), + val isSaving: Boolean = false, + val canSave: Boolean = false, + val saveFailed: Boolean = false, + val automaticNote: StringResource? = null +) + +private data class FolderDraft( + val isOpen: Boolean = false, + val original: List = emptyList(), + val paths: List = emptyList(), + val isSaving: Boolean = false, + val saveFailed: Boolean = false +) + +/** The editor keeps a snapshot until Save; incoming settings updates never reset a draft. */ +class LocalMediaFoldersViewModel( + private val settings: StateFlow, + private val persistFolders: suspend (List) -> Unit +) : ViewModel() { + private val draft = MutableStateFlow(FolderDraft()) + + val uiState = combine(settings, draft, ::toUiState) + .stateIn(viewModelScope, SharingStarted.Eagerly, toUiState(settings.value, draft.value)) + + fun open() { + if (draft.value.isOpen || draft.value.isSaving) return + val paths = normalizedFolders(settings.value.localMediaFolders) + draft.value = FolderDraft(isOpen = true, original = paths, paths = paths) + } + + fun addFolder(path: String?) { + val normalized = normalizeMediaFolderPath(path.orEmpty()) + if (normalized.isBlank()) return + draft.update { current -> + if (!current.isOpen || current.isSaving || normalized in current.paths) current + else current.copy(paths = current.paths + normalized, saveFailed = false) + } + } + + fun removeFolder(path: String) { + draft.update { current -> + if (!current.isOpen || current.isSaving) current + else current.copy(paths = current.paths.filterNot { it == path }, saveFailed = false) + } + } + + fun dismiss() { + if (!draft.value.isSaving) draft.value = FolderDraft() + } + + fun save() { + val current = draft.value + if (!current.isOpen || current.isSaving || current.paths == current.original) return + val paths = current.paths.toList() + draft.value = current.copy(isSaving = true, saveFailed = false) + viewModelScope.launch { + try { + persistFolders(paths) + draft.value = FolderDraft() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + draft.update { it.copy(isSaving = false, saveFailed = true) } + } + } + } + + private fun toUiState(settings: UserSettings, draft: FolderDraft): LocalMediaFoldersUiState { + val configured = normalizedFolders(settings.localMediaFolders) + val hasDownloadFolder = !settings.overloadedDownloadFolder.isNullOrBlank() + return LocalMediaFoldersUiState( + isOpen = draft.isOpen, + configuredCount = configured.size, + singleConfiguredPath = configured.singleOrNull(), + folders = draft.paths.map { path -> + LocalMediaFolderRowUi( + path, + path.substringAfterLast('/').substringAfterLast('\\').ifBlank { path }) + }, + isSaving = draft.isSaving, + canSave = draft.isOpen && draft.paths != draft.original, + saveFailed = draft.saveFailed, + automaticNote = when { + hasDownloadFolder && settings.enableMusicCaching -> Res.string.settings_local_media_folders_automatic_both + hasDownloadFolder -> Res.string.settings_local_media_folders_automatic_downloads + settings.enableMusicCaching -> Res.string.settings_local_media_folders_automatic_cache + else -> null + } + ) + } +} + +private fun normalizedFolders(paths: List) = + paths.map(::normalizeMediaFolderPath).filter { it.isNotBlank() }.distinct() + +internal fun normalizeMediaFolderPath(path: String): String { + val trimmed = path.trim() + if (trimmed == "/" || trimmed == "\\") return trimmed + if (trimmed.length >= 3 && trimmed[0].isLetter() && trimmed[1] == ':' && + trimmed.substring(2).all { it == '/' || it == '\\' } + ) return trimmed.take(3) + return trimmed.trimEnd('/', '\\') +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsConstants.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsConstants.kt new file mode 100644 index 00000000..f7c2cd50 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsConstants.kt @@ -0,0 +1,359 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.Serializable + +@Serializable +enum class CountryCode(val code: String, val displayName: String) { + AD("AD", "Andorra"), + AE("AE", "United Arab Emirates"), + AF("AF", "Afghanistan"), + AG("AG", "Antigua and Barbuda"), + AI("AI", "Anguilla"), + AL("AL", "Albania"), + AM("AM", "Armenia"), + AO("AO", "Angola"), + AQ("AQ", "Antarctica"), + AR("AR", "Argentina"), + AS("AS", "American Samoa"), + AT("AT", "Austria"), + AU("AU", "Australia"), + AW("AW", "Aruba"), + AX("AX", "Aland Islands"), + AZ("AZ", "Azerbaijan"), + BA("BA", "Bosnia and Herzegovina"), + BB("BB", "Barbados"), + BD("BD", "Bangladesh"), + BE("BE", "Belgium"), + BF("BF", "Burkina Faso"), + BG("BG", "Bulgaria"), + BH("BH", "Bahrain"), + BI("BI", "Burundi"), + BJ("BJ", "Benin"), + BL("BL", "Saint Barthelemy"), + BM("BM", "Bermuda"), + BN("BN", "Brunei Darussalam"), + BO("BO", "Bolivia"), + BQ("BQ", "Bonaire, Sint Eustatius and Saba"), + BR("BR", "Brazil"), + BS("BS", "Bahamas"), + BT("BT", "Bhutan"), + BV("BV", "Bouvet Island"), + BW("BW", "Botswana"), + BY("BY", "Belarus"), + BZ("BZ", "Belize"), + CA("CA", "Canada"), + CC("CC", "Cocos (Keeling) Islands"), + CD("CD", "Democratic Republic of the Congo"), + CF("CF", "Central African Republic"), + CG("CG", "Congo"), + CH("CH", "Switzerland"), + CI("CI", "Cote d'Ivoire"), + CK("CK", "Cook Islands"), + CL("CL", "Chile"), + CM("CM", "Cameroon"), + CN("CN", "China"), + CO("CO", "Colombia"), + CR("CR", "Costa Rica"), + CU("CU", "Cuba"), + CV("CV", "Cabo Verde"), + CW("CW", "Curacao"), + CX("CX", "Christmas Island"), + CY("CY", "Cyprus"), + CZ("CZ", "Czechia"), + DE("DE", "Germany"), + DJ("DJ", "Djibouti"), + DK("DK", "Denmark"), + DM("DM", "Dominica"), + DO("DO", "Dominican Republic"), + DZ("DZ", "Algeria"), + EC("EC", "Ecuador"), + EE("EE", "Estonia"), + EG("EG", "Egypt"), + EH("EH", "Western Sahara"), + ER("ER", "Eritrea"), + ES("ES", "Spain"), + ET("ET", "Ethiopia"), + FI("FI", "Finland"), + FJ("FJ", "Fiji"), + FK("FK", "Falkland Islands"), + FM("FM", "Micronesia"), + FO("FO", "Faroe Islands"), + FR("FR", "France"), + GA("GA", "Gabon"), + GB("GB", "United Kingdom"), + GD("GD", "Grenada"), + GE("GE", "Georgia"), + GF("GF", "French Guiana"), + GG("GG", "Guernsey"), + GH("GH", "Ghana"), + GI("GI", "Gibraltar"), + GL("GL", "Greenland"), + GM("GM", "Gambia"), + GN("GN", "Guinea"), + GP("GP", "Guadeloupe"), + GQ("GQ", "Equatorial Guinea"), + GR("GR", "Greece"), + GS("GS", "South Georgia and the South Sandwich Islands"), + GT("GT", "Guatemala"), + GU("GU", "Guam"), + GW("GW", "Guinea-Bissau"), + GY("GY", "Guyana"), + HK("HK", "Hong Kong"), + HM("HM", "Heard Island and McDonald Islands"), + HN("HN", "Honduras"), + HR("HR", "Croatia"), + HT("HT", "Haiti"), + HU("HU", "Hungary"), + ID("ID", "Indonesia"), + IE("IE", "Ireland"), + IL("IL", "Israel"), + IM("IM", "Isle of Man"), + IN("IN", "India"), + IO("IO", "British Indian Ocean Territory"), + IQ("IQ", "Iraq"), + IR("IR", "Iran"), + IS("IS", "Iceland"), + IT("IT", "Italy"), + JE("JE", "Jersey"), + JM("JM", "Jamaica"), + JO("JO", "Jordan"), + JP("JP", "Japan"), + KE("KE", "Kenya"), + KG("KG", "Kyrgyzstan"), + KH("KH", "Cambodia"), + KI("KI", "Kiribati"), + KM("KM", "Comoros"), + KN("KN", "Saint Kitts and Nevis"), + KP("KP", "North Korea"), + KR("KR", "South Korea"), + KW("KW", "Kuwait"), + KY("KY", "Cayman Islands"), + KZ("KZ", "Kazakhstan"), + LA("LA", "Lao People's Democratic Republic"), + LB("LB", "Lebanon"), + LC("LC", "Saint Lucia"), + LI("LI", "Liechtenstein"), + LK("LK", "Sri Lanka"), + LR("LR", "Liberia"), + LS("LS", "Lesotho"), + LT("LT", "Lithuania"), + LU("LU", "Luxembourg"), + LV("LV", "Latvia"), + LY("LY", "Libya"), + MA("MA", "Morocco"), + MC("MC", "Monaco"), + MD("MD", "Moldova"), + ME("ME", "Montenegro"), + MF("MF", "Saint Martin (French part)"), + MG("MG", "Madagascar"), + MH("MH", "Marshall Islands"), + MK("MK", "North Macedonia"), + ML("ML", "Mali"), + MM("MM", "Myanmar"), + MN("MN", "Mongolia"), + MO("MO", "Macao"), + MP("MP", "Northern Mariana Islands"), + MQ("MQ", "Martinique"), + MR("MR", "Mauritania"), + MS("MS", "Montserrat"), + MT("MT", "Malta"), + MU("MU", "Mauritius"), + MV("MV", "Maldives"), + MW("MW", "Malawi"), + MX("MX", "Mexico"), + MY("MY", "Malaysia"), + MZ("MZ", "Mozambique"), + NA("NA", "Namibia"), + NC("NC", "New Caledonia"), + NE("NE", "Niger"), + NF("NF", "Norfolk Island"), + NG("NG", "Nigeria"), + NI("NI", "Nicaragua"), + NL("NL", "Netherlands"), + NO("NO", "Norway"), + NP("NP", "Nepal"), + NR("NR", "Nauru"), + NU("NU", "Niue"), + NZ("NZ", "New Zealand"), + OM("OM", "Oman"), + PA("PA", "Panama"), + PE("PE", "Peru"), + PF("PF", "French Polynesia"), + PG("PG", "Papua New Guinea"), + PH("PH", "Philippines"), + PK("PK", "Pakistan"), + PL("PL", "Poland"), + PM("PM", "Saint Pierre and Miquelon"), + PN("PN", "Pitcairn"), + PR("PR", "Puerto Rico"), + PS("PS", "Palestine, State of"), + PT("PT", "Portugal"), + PW("PW", "Palau"), + PY("PY", "Paraguay"), + QA("QA", "Qatar"), + RE("RE", "Reunion"), + RO("RO", "Romania"), + RS("RS", "Serbia"), + RU("RU", "Russian Federation"), + RW("RW", "Rwanda"), + SA("SA", "Saudi Arabia"), + SB("SB", "Solomon Islands"), + SC("SC", "Seychelles"), + SD("SD", "Sudan"), + SE("SE", "Sweden"), + SG("SG", "Singapore"), + SH("SH", "Saint Helena, Ascension and Tristan da Cunha"), + SI("SI", "Slovenia"), + SJ("SJ", "Svalbard and Jan Mayen"), + SK("SK", "Slovakia"), + SL("SL", "Sierra Leone"), + SM("SM", "San Marino"), + SN("SN", "Senegal"), + SO("SO", "Somalia"), + SR("SR", "Suriname"), + SS("SS", "South Sudan"), + ST("ST", "Sao Tome and Principe"), + SV("SV", "El Salvador"), + SX("SX", "Sint Maarten (Dutch part)"), + SY("SY", "Syrian Arab Republic"), + SZ("SZ", "Eswatini"), + TC("TC", "Turks and Caicos Islands"), + TD("TD", "Chad"), + TF("TF", "French Southern Territories"), + TG("TG", "Togo"), + TH("TH", "Thailand"), + TJ("TJ", "Tajikistan"), + TK("TK", "Tokelau"), + TL("TL", "Timor-Leste"), + TM("TM", "Turkmenistan"), + TN("TN", "Tunisia"), + TO("TO", "Tonga"), + TR("TR", "Turkey"), + TT("TT", "Trinidad and Tobago"), + TV("TV", "Tuvalu"), + TW("TW", "Taiwan"), + TZ("TZ", "Tanzania"), + UA("UA", "Ukraine"), + UG("UG", "Uganda"), + UM("UM", "United States Minor Outlying Islands"), + US("US", "United States"), + UY("UY", "Uruguay"), + UZ("UZ", "Uzbekistan"), + VA("VA", "Holy See"), + VC("VC", "Saint Vincent and the Grenadines"), + VE("VE", "Venezuela"), + VG("VG", "Virgin Islands (British)"), + VI("VI", "Virgin Islands (U.S.)"), + VN("VN", "Viet Nam"), + VU("VU", "Vanuatu"), + WF("WF", "Wallis and Futuna"), + WS("WS", "Samoa"), + YE("YE", "Yemen"), + YT("YT", "Mayotte"), + ZA("ZA", "South Africa"), + ZM("ZM", "Zambia"), + ZW("ZW", "Zimbabwe"); + + companion object { + fun fromCode(code: String): CountryCode? { + return entries.find { it.code == code.uppercase() } + } + } +} + +/** + * App UI languages. Each one (except [SYSTEM]) must have a matching + * `composeResources/values-/strings.xml`. [displayName] is the language's own name + * (endonym), so it is intentionally not localized. + */ +@Serializable +enum class SupportedLanguages( + val locale: String, + val country: String, + val displayName: String, + val isRtl: Boolean = false +) { + /** Follow the device/OS language. */ + SYSTEM("", "", ""), + EN("en", "", "English"), + AR("ar", "", "العربية", isRtl = true), + BN("bn", "", "বাংলা"), + DE("de", "", "Deutsch"), + ES("es", "", "Español"), + FA("fa", "", "فارسی", isRtl = true), + FR("fr", "", "Français"), + HI("hi", "", "हिन्दी"), + ID("id", "", "Bahasa Indonesia"), + IT("it", "", "Italiano"), + JA("ja", "", "日本語"), + KO("ko", "", "한국어"), + NL("nl", "", "Nederlands"), + PL("pl", "", "Polski"), + PT_BR("pt", "BR", "Português (Brasil)"), + RU("ru", "", "Русский"), + TH("th", "", "ไทย"), + TR("tr", "", "Türkçe"), + UK("uk", "", "Українська"), + VI("vi", "", "Tiếng Việt"), + ZH_CN("zh", "CN", "简体中文"), + ZH_TW("zh", "TW", "繁體中文"); + + /** BCP 47 tag (e.g. "zh-TW"), or null for [SYSTEM]. */ + val languageTag: String? + get() = when { + this == SYSTEM -> null + country.isEmpty() -> locale + else -> "$locale-$country" + } +} + +@Serializable +enum class AccentColors(val lightHex: Long, val darkHex: Long) { + // High-contrast Forest / Neon Mint + GREEN_GOBLIN(0xFF006D3A, 0xFF80DB92), + + // Royal Purple / Soft Lavender + ELECTRIC_VIOLET(0xFF6C40BF, 0xFFD0BCFF), + + // Deep Teal / Icy Cyan + OCEANIC_CYAN(0xFF00677D, 0xFF59D3ED), + + // Burnt Sienna / Peach Glow + SUNSET_ORANGE(0xFFA23F16, 0xFFFFB596), + + // Crimson Red / Pastel Pink-Red + ROSE_GARDEN(0xFFB12E49, 0xFFFFB2BB), + + // Deep Cobalt / Sky Blue + MIDNIGHT_BLUE(0xFF0056D2, 0xFFB1C5FF), + + // Slate Gray / Silver + METALLIC_SLATE(0xFF5A5F6B, 0xFFC2C7D4); + + companion object { + fun fromHex(lightHex: Long, darkHex: Long): AccentColors? { + return entries.find { it.lightHex == lightHex && it.darkHex == darkHex } + } + } + + fun toLightColor() = Color(lightHex) + fun toDarkColor() = Color(darkHex) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt new file mode 100644 index 00000000..8d0fa86d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings + +import org.spotube.spotube.core.server.isContainerPlayableOn +import org.spotube.spotube.core.server.currentPlatform +import org.spotube.spotube.PlatformType +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.spotube.millennium.theme.MillenniumPalette + +@Serializable +enum class Theme { + LIGHT, DARK, SYSTEM +} + +@Serializable +data class UserSettings( + // Language and Region + // Stored under a new name: the old "language" field was persisted as EN for everyone while + // it had no effect, so reading it would force English on existing users. + @SerialName("appLanguage") + val language: SupportedLanguages = SupportedLanguages.SYSTEM, + val country: CountryCode = CountryCode.BD, + + // Appearance + val theme: Theme = Theme.SYSTEM, + val palette: MillenniumPalette = MillenniumPalette.Blue, + + // Playback + val streamingMusicFormat: AudioFormat = defaultStreamingMusicFormat(), + val streamingMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000), + val enableMusicCaching: Boolean = true, + val cacheFolder: String? = null, + val cacheSizeLimitMB: Long = -1L, + val enableEndlessPlayback: Boolean = true, + val playbackProxyServerPort: Int = 14769, + + // Remote Control (LAN) + val allowRemoteControl: Boolean = false, + val allowedRemoteDevices: List = emptyList(), + val remoteControlDeviceName: String = "", + val remoteControlDeviceId: String = "", + + // Group Jam (MQTT) + val jamParticipantName: String = "", + val jamBroker: JamBroker = JamBroker(), + val lastJamCode: String = "", + + // Downloads + val overloadedDownloadFolder: String? = null, // When null, uses default music folder + val localMediaFolders: List = emptyList(), + val downloadMusicFormat: AudioFormat = AudioFormat( + codec = "aac", + container = "mp4", + qualities = listOf( + AudioQuality.Lossy(bitrate = 44_000), + AudioQuality.Lossy(bitrate = 96_000), + AudioQuality.Lossy(bitrate = 128_000), + AudioQuality.Lossy(bitrate = 256_000)) + ), + val downloadMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000), + + // Desktop + val minimizeToTray: Boolean = false, + val discordRichPresence: Boolean = true, + + // Updates + val autoCheckForUpdates: Boolean = true, + val ignoredAppUpdateVersion: String? = null, + val ignoredPluginUpdateVersions: Map = emptyMap(), + + // Support + val supportRemindersEnabled: Boolean = true, + val lastSupportReminderEpochMs: Long = 0L) + +/** + * Configuration for the MQTT broker used by Group Jam. The host is a placeholder + * until a real broker is configured; users can self-host and point the app at it. + */ +@Serializable +data class JamBroker( + val name: String = "", + val host: String = "test.mosquitto.org", + val port: Int = 1883, + val useTls: Boolean = false, + val username: String? = null, + val password: String? = null, + val clientIdPrefix: String = "spotube", + val keepAliveSeconds: Int = 30, + val connectionTimeoutSeconds: Int = 10) + +/** + * opus/webm everywhere except iOS, whose AVPlayer can't open WebM/Ogg, so it streams AAC. + * TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend). + */ +internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat { + val ios = platform == PlatformType.IOS + return AudioFormat( + codec = if (ios) "aac" else "opus", + container = if (ios) "mp4" else "webm", + qualities = listOf( + AudioQuality.Lossy(bitrate = 44_000), + AudioQuality.Lossy(bitrate = 96_000), + AudioQuality.Lossy(bitrate = 128_000), + AudioQuality.Lossy(bitrate = 256_000))) +} + +/** Moves settings saved with a streaming format [platform] can't play (iOS' old opus/webm default) to the default. */ +internal fun UserSettings.withPlayableStreamingFormat(platform: PlatformType = currentPlatform): UserSettings = + if (isContainerPlayableOn(streamingMusicFormat.container, platform)) this + else copy(streamingMusicFormat = defaultStreamingMusicFormat(platform)) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt new file mode 100644 index 00000000..b8b3772c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import org.spotube.spotube.core.db.Database +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.* +import kotlinx.serialization.json.Json + +/** Narrow read-only view over the current [UserSettings], for consumers (like + * [org.spotube.spotube.core.server.StreamingUrlRepository]) that only need to read the + * user's streaming preferences without depending on the full [SettingsRepository]. */ +interface UserSettingsSource { + val userSettings: StateFlow +} + +class SettingsRepository(private val database: Database) : UserSettingsSource { + companion object { + private val SETTINGS_KEY = stringPreferencesKey("user_settings") + + /** + * Lenient so settings written by older builds (which stored `accentColor` / + * `surfaceTheme` instead of the Millennium `palette`) still decode, and so a + * missing field falls back to its default. + */ + private val settingsJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + } + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override val userSettings: StateFlow = database.settingsDataStore.data.map { prefs -> + val json = prefs[SETTINGS_KEY] + if (json != null) { + settingsJson.decodeFromString(json as String).withPlayableStreamingFormat() + } else { + UserSettings() // Default value + } + }.stateIn( + scope, + started = SharingStarted.Eagerly, + initialValue = UserSettings() // Default value + ) + + suspend fun updateSettings(newSettings: UserSettings) { + database.settingsDataStore.edit { prefs -> + prefs[SETTINGS_KEY] = settingsJson.encodeToString(newSettings) + } + } + + suspend fun getCurrentSettings(): UserSettings { + val preferences = database.settingsDataStore.data.first() + val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings() + return settingsJson.decodeFromString(storedJson).withPlayableStreamingFormat() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt new file mode 100644 index 00000000..e79ebe0b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings + + +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.PlatformType +import org.spotube.spotube.getPlatform +import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import spotube.composeapp.generated.resources.* +import org.spotube.spotube.modules.settings.sections.appearanceSection +import org.spotube.spotube.modules.settings.sections.cacheSection +import org.spotube.spotube.modules.settings.sections.desktopSection +import org.spotube.spotube.modules.settings.sections.downloadsSection +import org.spotube.spotube.modules.settings.sections.jamSection +import org.spotube.spotube.modules.settings.sections.languageRegionSection +import org.spotube.spotube.modules.settings.sections.playbackSection +import org.spotube.spotube.modules.settings.sections.pluginsSection +import org.spotube.spotube.modules.settings.sections.supportSection +import org.spotube.spotube.modules.settings.sections.updatesSection +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject + +/** + * Minimum width of the settings content area required to show the settings as an + * overlay dialog instead of a full-screen destination. + */ +private val SettingsDialogMinWidth = 720.dp + +/** + * On large screens settings is presented as a modal overlay so it does not sit next to + * the main navigation sidebar. On smaller screens it stays a full-screen destination. + */ +@Composable +fun SettingsScreen(settingsViewModel: SettingsViewModel) { + val navigatorCommands: NavigationCommands = koinInject() + + Box(modifier = Modifier.fillMaxSize()) { + if (currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded) { + SettingsDialog( + settingsViewModel = settingsViewModel, + onDismiss = { navigatorCommands.pop(Routes.Settings) }) + } else { + CompactSettingsScreen(settingsViewModel = settingsViewModel) + } + } +} + +@Composable +private fun CompactSettingsScreen(settingsViewModel: SettingsViewModel) { + val navigatorCommands = koinInject() + val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() + val contentPadding = remember(shellBottomInset) { + PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) + } + + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { + Text(stringResource(Res.string.settings_screen_title)) + }, + backButton = false + ) + CompactSettingsContent( + settings = settingsState, + settingsViewModel = settingsViewModel, + navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission, + contentPadding = contentPadding, + modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun CompactSettingsContent( + settings: UserSettings?, + settingsViewModel: SettingsViewModel, + navigatorCommands: NavigationCommands, + requestLocalNetworkPermission: () -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier) { + val platformType = remember { getPlatform().type } + val isDesktopPlatform = platformType == PlatformType.Windows || + platformType == PlatformType.Linux || + platformType == PlatformType.MacOS + + Box(modifier = modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .widthIn(max = 1280.dp) + .align(Alignment.TopCenter), + contentPadding = contentPadding) { + pluginsSection( + navigatorCommands = navigatorCommands) + if (settings != null) { + languageRegionSection( + settings = settings, + settingsViewModel = settingsViewModel) + appearanceSection( + settings = settings, + settingsViewModel = settingsViewModel) + playbackSection( + settings = settings, + settingsViewModel = settingsViewModel, + navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission) + jamSection( + settings = settings, + navigationCommands = navigatorCommands) + cacheSection( + settings = settings, + settingsViewModel = settingsViewModel) + downloadsSection( + settings = settings, + settingsViewModel = settingsViewModel) + if (isDesktopPlatform) { + desktopSection( + settings = settings, + settingsViewModel = settingsViewModel) + } + supportSection( + settings = settings, + settingsViewModel = settingsViewModel) + updatesSection( + settings = settings, + settingsViewModel = settingsViewModel) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt new file mode 100644 index 00000000..3ff64bac --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt @@ -0,0 +1,339 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings + + +import org.spotube.millennium.composables.MillenniumCircularProgress +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSidebarItem +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.PlatformType +import org.spotube.spotube.getPlatform +import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.modules.blacklist.BlacklistContent +import org.spotube.spotube.modules.plugin.PluginScreenContent +import org.spotube.spotube.modules.settings.sections.appearanceSection +import org.spotube.spotube.modules.settings.sections.cacheSection +import org.spotube.spotube.modules.settings.sections.desktopSection +import org.spotube.spotube.modules.settings.sections.downloadsSection +import org.spotube.spotube.modules.settings.sections.languageRegionSection +import org.spotube.spotube.modules.settings.sections.playbackSection +import org.spotube.spotube.modules.settings.sections.supportSection +import org.spotube.spotube.modules.settings.sections.SettingsPageTitle +import org.spotube.spotube.modules.settings.sections.updatesSection +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxBoxAdd +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxColorSwatch +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxDocumentDownload +import org.spotube.spotube.resources.iconsax.IconsaxForbidden +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.resources.iconsax.IconsaxImportArrow2 +import org.spotube.spotube.resources.iconsax.IconsaxInformation +import org.spotube.spotube.resources.iconsax.IconsaxLanguageSquare +import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay +import org.spotube.spotube.resources.iconsax.IconsaxSoundTwotone +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_close +import spotube.composeapp.generated.resources.settings_blacklist_title +import spotube.composeapp.generated.resources.settings_screen_title +import spotube.composeapp.generated.resources.settings_section_about +import spotube.composeapp.generated.resources.settings_section_appearance +import spotube.composeapp.generated.resources.settings_section_caching +import spotube.composeapp.generated.resources.settings_section_desktop +import spotube.composeapp.generated.resources.settings_section_downloads +import spotube.composeapp.generated.resources.settings_section_jam +import spotube.composeapp.generated.resources.settings_section_language_region +import spotube.composeapp.generated.resources.settings_section_playback +import spotube.composeapp.generated.resources.settings_section_plugins +import spotube.composeapp.generated.resources.settings_section_support + +private val SettingsSidebarWidth = 236.dp + +/** + * The sections reachable from the large-screen settings sidebar, in display order. + * + * Each entry maps to either a list-based settings section or a standalone + * screen (Plugins, Blacklist, Group Jam) whose content is embedded in the tab. + */ +internal enum class SettingsTab( + val titleRes: StringResource, + val icon: ImageVector) { + Plugins(Res.string.settings_section_plugins, Iconsax.IconsaxBoxAdd), + LanguageRegion(Res.string.settings_section_language_region, Iconsax.IconsaxLanguageSquare), + Appearance(Res.string.settings_section_appearance, Iconsax.IconsaxColorSwatch), + Playback(Res.string.settings_section_playback, Iconsax.IconsaxMusicPlay), + Blacklist(Res.string.settings_blacklist_title, Iconsax.IconsaxForbidden), + GroupJam(Res.string.settings_section_jam, Iconsax.IconsaxSoundTwotone), + Caching(Res.string.settings_section_caching, Iconsax.IconsaxDocumentDownload), + Downloads(Res.string.settings_section_downloads, Iconsax.IconsaxDirectboxReceive), + Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2), + Support(Res.string.settings_section_support, Iconsax.IconsaxHeart2), + About(Res.string.settings_section_about, Iconsax.IconsaxInformation), +} + +/** + * Large-screen settings, presented as a modal overlay (dialog) over the whole app so it + * never sits next to the main navigation sidebar. + */ +@Composable +internal fun SettingsDialog( + settingsViewModel: SettingsViewModel, + onDismiss: () -> Unit) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false)) { + // The dialog is its own surface; the player/bottom bars behind it should not + // reserve any inset inside the dialog content. + CompositionLocalProvider(LocalAppShellBottomInset provides 0.dp) { + SettingsPane( + settingsViewModel = settingsViewModel, + onClose = onDismiss) + } + } +} + +@Composable +private fun SettingsPane( + settingsViewModel: SettingsViewModel, + onClose: () -> Unit) { + val navigatorCommands: NavigationCommands = koinInject() + val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle() + val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() + val contentPadding = remember { PaddingValues(top = 16.dp, bottom = 24.dp) } + + val platformType = remember { getPlatform().type } + val isDesktopPlatform = platformType == PlatformType.Windows || + platformType == PlatformType.Linux || + platformType == PlatformType.MacOS + + val tabs = remember(isDesktopPlatform) { + SettingsTab.entries.filter { it != SettingsTab.Desktop || isDesktopPlatform } + } + var selectedIndex by rememberSaveable { mutableStateOf(0) } + val selectedTab = tabs.getOrElse(selectedIndex) { tabs.first() } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + contentAlignment = Alignment.Center) { + Row( + modifier = Modifier + .widthIn(max = 1200.dp) + .heightIn(max = 860.dp) + .fillMaxWidth() + .fillMaxHeight() + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = AppTheme.shapes.large, + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + shadowElevation = 16.dp)) { + SettingsSidebar( + tabs = tabs, + selectedTab = selectedTab, + onSelect = { selectedIndex = tabs.indexOf(it) }) + MillenniumSeparator( + modifier = Modifier.fillMaxHeight(), + vertical = true) + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.weight(1f)) { + SettingsPageTitle(selectedTab.titleRes) + } + MillenniumIconButton( + onClick = onClose, + modifier = Modifier.padding(end = 8.dp), + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = stringResource(Res.string.common_close)) + } + } + MillenniumSeparator() + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth()) { + SettingsTabContent( + tab = selectedTab, + settings = settingsState, + settingsViewModel = settingsViewModel, + navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission, + contentPadding = contentPadding) + } + } + } + } +} + +@Composable +private fun SettingsSidebar( + tabs: List, + selectedTab: SettingsTab, + onSelect: (SettingsTab) -> Unit) { + LazyColumn( + modifier = Modifier + .width(SettingsSidebarWidth) + .fillMaxHeight() + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp)) { + item { + Text( + stringResource(Res.string.settings_screen_title), + style = AppTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp, bottom = 16.dp) + ) + } + items(tabs, key = { it.name }) { tab -> + MillenniumSidebarItem( + label = stringResource(tab.titleRes), + onClick = { onSelect(tab) }, + selected = tab == selectedTab, + contentDescription = stringResource(tab.titleRes), + icon = tab.icon) + } + } +} + +@Composable +private fun SettingsTabContent( + tab: SettingsTab, + settings: UserSettings?, + settingsViewModel: SettingsViewModel, + navigatorCommands: NavigationCommands, + requestLocalNetworkPermission: () -> Unit, + contentPadding: PaddingValues) { + when (tab) { + SettingsTab.Plugins -> { + PluginScreenContent(viewModel = koinViewModel()) + } + + SettingsTab.Blacklist -> { + BlacklistContent(viewModel = koinViewModel()) + } + + SettingsTab.GroupJam -> { + JamSettingsContent(viewModel = koinViewModel()) + } + + else -> { + if (settings == null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center) { + MillenniumCircularProgress(size = 40.dp) + } + } else { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .widthIn(max = 1280.dp) + .align(Alignment.TopCenter), + contentPadding = contentPadding) { + when (tab) { + SettingsTab.LanguageRegion -> + languageRegionSection(settings, settingsViewModel, includeHeader = false) + + SettingsTab.Appearance -> + appearanceSection(settings, settingsViewModel, includeHeader = false) + + SettingsTab.Playback -> + playbackSection( + settings = settings, + settingsViewModel = settingsViewModel, + navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission, + includeBlacklist = false, + includeHeader = false) + + SettingsTab.Caching -> + cacheSection(settings, settingsViewModel, includeHeader = false) + + SettingsTab.Downloads -> + downloadsSection(settings, settingsViewModel, includeHeader = false) + + SettingsTab.Desktop -> + desktopSection(settings, settingsViewModel, includeHeader = false) + + SettingsTab.Support -> + supportSection( + settings = settings, + settingsViewModel = settingsViewModel, + includeHeader = false) + + SettingsTab.About -> + updatesSection(settings, settingsViewModel, includeHeader = false) + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt new file mode 100644 index 00000000..c629111a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.spotube.spotube.core.paths.Paths + +interface SettingsProvider { + val settingsState: StateFlow +} + +class SettingsViewModel( + private val repository: SettingsRepository, + private val paths: Paths +) : ViewModel(), SettingsProvider { + override val settingsState = repository.userSettings + + /** Where downloads go when no override folder is set. */ + val defaultDownloadFolder: String = paths.getUserDownloadsDirPath() + + fun updateSettings(transform: UserSettings.() -> UserSettings) { + viewModelScope.launch { + // Get current state, apply transform, and save + val currentSettings = settingsState.value ?: return@launch + val newSettings = currentSettings.transform() + repository.updateSettings(newSettings) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/InteractiveSettingCards.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/InteractiveSettingCards.kt new file mode 100644 index 00000000..83c49be7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/InteractiveSettingCards.kt @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.components + + +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogFooter +import org.spotube.millennium.composables.MillenniumDialogDescription +import androidx.compose.foundation.layout.Box +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.ui.Alignment +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumRadioGroup +import org.spotube.millennium.composables.MillenniumSwitch +import org.spotube.millennium.composables.MillenniumTextField +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.input.rememberTextFieldState + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.spotube.spotube.core.ui.component.AdaptiveDropdownBottomSheet +import org.spotube.spotube.core.ui.component.AdaptiveMenuItem +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 +import kotlinx.coroutines.flow.drop +import spotube.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun SwitchSettingCard( + title: String, + subtitle: String? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + icon: (@Composable () -> Unit)? = null +) { + SettingCardItem( + title = title, + subtitle = subtitle, + icon = icon, + trailingContent = { + MillenniumSwitch( + checked = checked, + onCheckedChange = onCheckedChange, + contentSpacing = 0.dp + ) + }, + onClick = { + onCheckedChange(!checked) + } + ) +} + +@Composable +internal fun SelectionSettingCard( + title: String, + subtitle: String? = null, + selectedOption: T, + options: List, + optionLabel: @Composable (T) -> String, + onOptionSelected: (T) -> Unit, + dialogTitle: String = title, + icon: (@Composable () -> Unit)? = null, + inlineSelectorMinWidth: Dp = 700.dp, + filter: ((AdaptiveMenuItem, String) -> Boolean)? = null +) { + var isDialogOpen by remember { mutableStateOf(false) } + + Box { + val isWideLayout = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium + + SettingCardItem( + title = title, + subtitle = subtitle, + icon = icon, + trailingContent = { + AdaptiveDropdownBottomSheet( + items = options.map { option -> + AdaptiveMenuItem( + label = optionLabel(option), + onClick = { onOptionSelected(option) }, + selected = option == selectedOption + ) + }, + trigger = { onClick -> + MillenniumButton( + onClick = onClick, + style = MillenniumButtonStyle.Secondary + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text(optionLabel(selectedOption)) + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + } + } + }, + filter = filter + ) + }, + onClick = { + if (isWideLayout) { + isDialogOpen = true + } + } + ) + + if (!isWideLayout && isDialogOpen) { + val labels = options.map { optionLabel(it) } + MillenniumDialog( + visible = true, + onDismissRequest = { isDialogOpen = false }, + title = dialogTitle + ) { + MillenniumRadioGroup( + value = selectedOption, + onValueChange = { option -> + onOptionSelected(option) + isDialogOpen = false + }, + options = options, + label = dialogTitle, + optionLabel = { option -> labels[options.indexOf(option)] }) + MillenniumButton( + onClick = { isDialogOpen = false }, + style = MillenniumButtonStyle.Quiet + ) { + Text(stringResource(Res.string.common_close)) + } + } + } + } +} + +@Composable +internal fun TextInputSettingCard( + title: String, + subtitle: String? = null, + value: String, + onValueSaved: (String) -> Unit, + dialogTitle: String = title, + dialogDescription: String? = null, + placeholder: String = "", + normalize: (String) -> String = { it.trim() }, + validate: (String) -> String? = { null }, + icon: (@Composable () -> Unit)? = null, + inlineTextFieldMinWidth: Dp = 700.dp, + inlineTextFieldWidth: Dp = 220.dp, + enabled: Boolean = true +) { + var isDialogOpen by remember { mutableStateOf(false) } + + Box { + val isWideLayout = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium + val inlineState = rememberTextFieldState(value) + + LaunchedEffect(inlineState, isWideLayout) { + snapshotFlow { inlineState.text.toString() } + .drop(1) + .collect { draft -> + val normalizedValue = normalize(draft) + if (validate(normalizedValue) == null) { + onValueSaved(normalizedValue) + } + } + } + + SettingCardItem( + enabled = enabled, + title = title, + subtitle = subtitle, + icon = icon, + trailingContent = if (isWideLayout) { + { + MillenniumTextField( + state = inlineState, + modifier = Modifier.width(inlineTextFieldWidth), + placeholder = placeholder, + enabled = enabled + ) + } + } else { + null + }, + onClick = { + if (!isWideLayout) { + isDialogOpen = true + } + } + ) + + if (!isWideLayout && isDialogOpen) { + val dialogState = rememberTextFieldState(value) + var draft by remember(value, isDialogOpen) { mutableStateOf(value) } + + LaunchedEffect(dialogState) { + snapshotFlow { dialogState.text.toString() } + .drop(1) + .collect { draft = it } + } + + val normalizedValue = normalize(draft) + val errorMessage = validate(normalizedValue) + + MillenniumDialog(visible = true, onDismissRequest = { isDialogOpen = false }) { + MillenniumDialogHeader { + MillenniumDialogTitle(dialogTitle) + MillenniumDialogClose() + } + MillenniumDialogBody { + if (dialogDescription != null) { + MillenniumDialogDescription(dialogDescription) + } + MillenniumTextField( + state = dialogState, + label = title, + modifier = Modifier.fillMaxWidth(), + placeholder = placeholder, + error = errorMessage + ) + } + MillenniumDialogFooter { + MillenniumButton( + onClick = { isDialogOpen = false }, + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.common_cancel)) + } + MillenniumButton( + onClick = { + if (errorMessage == null) { + onValueSaved(normalizedValue) + isDialogOpen = false + } + }, + enabled = errorMessage == null + ) { + Text(stringResource(Res.string.common_save)) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt new file mode 100644 index 00000000..01795adf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt @@ -0,0 +1,284 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.settings.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.modules.settings.LocalMediaFoldersViewModel +import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher +import io.github.vinceglb.filekit.path +import org.spotube.millennium.composables.MillenniumDialog +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.component.VerticalScrollbar +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.settings.LocalMediaFolderRowUi +import org.spotube.spotube.modules.settings.LocalMediaFoldersUiState +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxFolderOpen +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTooltip +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* + +@Composable +internal fun LocalMediaFoldersEditorContent( + state: LocalMediaFoldersUiState, + onAddFolder: () -> Unit, + onRemoveFolder: (String) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier +) { + val listState = rememberLazyListState() + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(Res.string.settings_local_media_folders_description), + style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant + ) + Row( + Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + pluralStringResource( + Res.plurals.settings_local_media_folders_count, + state.folders.size, + state.folders.size + ), + style = AppTheme.typography.labelMedium, modifier = Modifier.weight(1f) + ) + MillenniumButton( + onClick = onAddFolder, + enabled = !state.isSaving, + style = MillenniumButtonStyle.Secondary, + padding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), + minSize = DpSize(0.dp, 36.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + MillenniumIcon(Iconsax.IconsaxAddSquare, null, modifier = Modifier.size(16.dp)) + Text( + stringResource(Res.string.settings_local_media_folders_add_action), + style = AppTheme.typography.labelMedium + ) + } + } + } + Box( + Modifier.fillMaxWidth() + .heightIn(min = if (state.folders.isEmpty()) 104.dp else 76.dp, max = 240.dp) + .millenniumSurface( + background = SolidColor(MillenniumTheme.colors.background), + shape = RoundedCornerShape(8.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ), + contentAlignment = Alignment.Center + ) { + if (state.folders.isEmpty()) { + Column( + Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxFolderOpen, + null, + modifier = Modifier.size(28.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + stringResource(Res.string.settings_local_media_folders_none_added), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } else { + LazyColumn( + Modifier.fillMaxWidth(), + state = listState, + contentPadding = PaddingValues(vertical = 4.dp) + ) { + itemsIndexed( + state.folders, + key = { _, folder -> folder.path }) { index, folder -> + if (index > 0) MillenniumSeparator(Modifier.padding(horizontal = 12.dp)) + LocalMediaFolderEditorRow( + folder, + !state.isSaving + ) { onRemoveFolder(folder.path) } + } + } + if (listState.canScrollForward || listState.canScrollBackward) { + VerticalScrollbar( + listState = listState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } + } + state.automaticNote?.let { note -> + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxDirectboxReceive, + null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + stringResource(note), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } + if (state.saveFailed) Text( + stringResource(Res.string.settings_local_media_folders_save_error), + style = AppTheme.typography.bodySmall, color = AppTheme.colors.error + ) + MillenniumSeparator() + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + MillenniumButton( + onClick = onCancel, + enabled = !state.isSaving, + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.common_cancel)) + } + MillenniumButton(onClick = onSave, enabled = state.canSave, loading = state.isSaving) { + Text(stringResource(Res.string.common_save)) + } + } + } +} + +@Composable +private fun LocalMediaFolderEditorRow( + folder: LocalMediaFolderRowUi, + enabled: Boolean, + onRemove: () -> Unit +) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + Modifier.size(36.dp).millenniumSurface( + background = millenniumBevelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(6.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ), + contentAlignment = Alignment.Center + ) { + MillenniumIcon( + Iconsax.IconsaxFolderOpen, + null, + modifier = Modifier.size(20.dp), + tint = AppTheme.colors.primary + ) + } + Box(Modifier.weight(1f)) { + MillenniumTooltip(folder.path) { + SelectionContainer { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + folder.name, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + folder.path, + style = AppTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 14.sp + ), + color = AppTheme.colors.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + val removeLabel = + stringResource(Res.string.settings_local_media_folders_remove_named, folder.name) + MillenniumTooltip(removeLabel) { + MillenniumIconButton( + onClick = onRemove, + enabled = enabled, + modifier = Modifier.size(32.dp), + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon(Iconsax.IconsaxTrash, removeLabel, modifier = Modifier.size(16.dp)) + } + } + } +} + +/** Renders the "manage local folders" dialog (folder picker included) while [viewModel] has it open. + * Shared by Settings and the Local Tracks screen; callers trigger it via [LocalMediaFoldersViewModel.open]. */ +@Composable +fun LocalMediaFoldersDialogHost(viewModel: LocalMediaFoldersViewModel) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val pickerLauncher = + rememberDirectoryPickerLauncher { directory -> viewModel.addFolder(directory?.path) } + if (state.isOpen) { + MillenniumDialog( + visible = true, + onDismissRequest = viewModel::dismiss, + title = stringResource(Res.string.settings_local_media_folders_title) + ) { + LocalMediaFoldersEditorContent( + state, { pickerLauncher.launch() }, viewModel::removeFolder, + viewModel::save, viewModel::dismiss + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/SettingsCardItem.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/SettingsCardItem.kt new file mode 100644 index 00000000..2ca288b0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/SettingsCardItem.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.components + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumListTile +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Reusable setting card item component for consistent styling + * across all list items in the settings screen + */ +@Composable +fun SettingCardItem( + enabled: Boolean = true, + title: String, + subtitle: String? = null, + icon: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + onClick: () -> Unit = {}, + modifier: Modifier = Modifier +) { + MillenniumListTile( + selected = false, + enabled = enabled, + onClick = onClick, + modifier = modifier, + leading = icon, + title = { + Text( + title, + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.onSurface + ) + }, + subtitle = subtitle?.let { + { + Text( + it, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + }, + trailing = trailingContent) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt new file mode 100644 index 00000000..9b7ba138 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt @@ -0,0 +1,267 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogFooter +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SelectionSettingCard +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxColorSwatch +import org.spotube.spotube.resources.iconsax.IconsaxMagic +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumText +import org.spotube.millennium.theme.MillenniumColorSchemes +import org.spotube.millennium.theme.MillenniumPalette +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.MillenniumThemeConfig +import org.spotube.millennium.theme.resolve +import spotube.composeapp.generated.resources.* + +internal fun LazyListScope.appearanceSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true) { + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_appearance) + } + settingsSectionCard( + items = listOf( + { + SelectionSettingCard( + title = stringResource(Res.string.settings_theme_title), + subtitle = stringResource( + Res.string.settings_theme_subtitle_current, + settings.theme.displayLabel() + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxColorSwatch, stringResource(Res.string.settings_theme_title)) + }, + selectedOption = settings.theme, + options = Theme.entries, + optionLabel = { it.displayLabel() }, + onOptionSelected = { theme -> + settingsViewModel.updateSettings { + copy(theme = theme) + } + } + ) + }, + { + AppearanceSettingCard( + selectedPalette = settings.palette, + icon = { + SettingsItemIcon( + Iconsax.IconsaxMagic, + stringResource(Res.string.settings_appearance_title) + ) + }, + onSaved = { palette -> + settingsViewModel.updateSettings { + copy(palette = palette) + } + }) + }) + ) +} + +@Composable +private fun Theme.displayLabel(): String { + return when (this) { + Theme.LIGHT -> stringResource(Res.string.settings_theme_light) + Theme.DARK -> stringResource(Res.string.settings_theme_dark) + Theme.SYSTEM -> stringResource(Res.string.settings_theme_system) + } +} + +@Composable +private fun MillenniumPalette.displayLabel(): String = when (this) { + MillenniumPalette.Blue -> stringResource(Res.string.settings_palette_blue) + MillenniumPalette.Orange -> stringResource(Res.string.settings_palette_orange) + MillenniumPalette.Lime -> stringResource(Res.string.settings_palette_lime) +} + +@Composable +private fun AppearanceSettingCard( + selectedPalette: MillenniumPalette, + icon: (@Composable () -> Unit)? = null, + onSaved: (MillenniumPalette) -> Unit) { + var isDialogOpen by remember { mutableStateOf(false) } + + SettingCardItem( + title = stringResource(Res.string.settings_appearance_title), + subtitle = stringResource( + Res.string.settings_appearance_subtitle_palette, + selectedPalette.displayLabel()), + icon = icon, + trailingContent = { + PaletteTrailingSwatch(selectedPalette) + }, + onClick = { isDialogOpen = true }) + + if (isDialogOpen) { + var draftPalette by remember(selectedPalette, isDialogOpen) { + mutableStateOf(selectedPalette) + } + + MillenniumDialog(visible = true, onDismissRequest = { isDialogOpen = false }) { + MillenniumDialogHeader { + MillenniumDialogTitle(stringResource(Res.string.settings_palette_dialog_title)) + MillenniumDialogClose() + } + MillenniumDialogBody { + MillenniumText( + stringResource(Res.string.settings_palette_dialog_description), + color = AppTheme.colors.onSurfaceVariant) + MillenniumText( + stringResource(Res.string.settings_palette_label), + color = AppTheme.colors.onSurface) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumPalette.entries.forEach { palette -> + PaletteSwatch( + palette = palette, + label = palette.displayLabel(), + selected = palette == draftPalette, + onClick = { draftPalette = palette }) + } + } + + PalettePreview(draftPalette) + } + MillenniumDialogFooter { + MillenniumButton( + onClick = { isDialogOpen = false }, + style = MillenniumButtonStyle.Secondary) { + MillenniumText(stringResource(Res.string.common_cancel)) + } + MillenniumButton( + onClick = { + onSaved(draftPalette) + isDialogOpen = false + }) { + MillenniumText(stringResource(Res.string.common_save)) + } + } + } + } +} + +@Composable +private fun PaletteSwatch( + palette: MillenniumPalette, + label: String, + selected: Boolean, + onClick: () -> Unit) { + val scheme = remember(palette) { MillenniumColorSchemes().resolve(palette, dark = false) } + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(scheme.accent) + .border( + width = if (selected) 3.dp else 1.dp, + color = if (selected) AppTheme.colors.onSurface else AppTheme.colors.outlineVariant, + shape = CircleShape) + .clickable(onClick = onClick) + .semantics { contentDescription = label }) +} + +@Composable +private fun PaletteTrailingSwatch(palette: MillenniumPalette) { + val scheme = remember(palette) { MillenniumColorSchemes().resolve(palette, dark = false) } + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(width = 46.dp, height = 28.dp) + .clip(RoundedCornerShape(8.dp)) + .background(scheme.accent.copy(alpha = 0.35f))) + Box( + Modifier + .size(18.dp) + .clip(CircleShape) + .background(scheme.accent)) + } +} + +@Composable +private fun PalettePreview(palette: MillenniumPalette) { + val dark = AppTheme.colors.background.luminance() < 0.5f + MillenniumTheme(dark = dark, config = MillenniumThemeConfig(palette = palette)) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(MillenniumTheme.colors.surface) + .border(1.dp, MillenniumTheme.colors.border, RoundedCornerShape(14.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumText( + stringResource(Res.string.settings_preview_label), + color = MillenniumTheme.colors.muted) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton(onClick = {}) { + MillenniumText(stringResource(Res.string.settings_preview_button)) + } + MillenniumButton(onClick = {}, style = MillenniumButtonStyle.Secondary) { + MillenniumText(stringResource(Res.string.settings_preview_secondary)) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/CacheSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/CacheSection.kt new file mode 100644 index 00000000..53129924 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/CacheSection.kt @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.sections + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.foundation.lazy.LazyListScope + +import androidx.compose.runtime.Composable +import compose.icons.FeatherIcons +import compose.icons.feathericons.Folder +import compose.icons.feathericons.HardDrive +import spotube.composeapp.generated.resources.* +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDocumentDownload +import org.spotube.spotube.resources.iconsax.IconsaxFolder +import org.spotube.spotube.resources.iconsax.IconsaxFolderAdd +import org.spotube.spotube.resources.iconsax.IconsaxStopCircle +import io.github.vinceglb.filekit.path +import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher +import org.jetbrains.compose.resources.stringResource + +internal fun LazyListScope.cacheSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true) { + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_caching) + } + settingsSectionCard( + items = listOf( + { + SwitchSettingCard( + title = stringResource(Res.string.settings_enable_music_caching_title), + subtitle = stringResource(Res.string.settings_enable_music_caching_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxDocumentDownload, + stringResource(Res.string.settings_enable_music_caching_title) + ) + }, + checked = settings.enableMusicCaching, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(enableMusicCaching = enabled) + } + } + ) + }, + { + CacheFolderSettingCard( + enabled = settings.enableMusicCaching, + folder = settings.cacheFolder, + onFolderSelected = { folder -> + settingsViewModel.updateSettings { + copy(cacheFolder = folder) + } + } + ) + }, + { + val error_whole_number = stringResource(Res.string.settings_error_whole_number) + val error_cache_range = stringResource(Res.string.settings_error_cache_size_range) + + TextInputSettingCard( + enabled = settings.enableMusicCaching, + title = stringResource(Res.string.settings_cache_size_limit_title), + subtitle = when { + settings.cacheSizeLimitMB <= 0L -> stringResource(Res.string.settings_cache_size_limit_unlimited) + else -> stringResource( + Res.string.settings_cache_size_limit_current, + settings.cacheSizeLimitMB + ) + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxStopCircle, + stringResource(Res.string.settings_cache_size_limit_title) + ) + }, + value = when { + settings.cacheSizeLimitMB <= 0L -> "" + else -> settings.cacheSizeLimitMB.toString() + }, + dialogDescription = stringResource(Res.string.settings_cache_size_limit_description), + placeholder = stringResource(Res.string.settings_cache_size_limit_placeholder), + normalize = { it.trim() }, + validate = { value -> + if (value.isBlank()) null + else { + val mb = value.toLongOrNull() + when { + mb == null -> error_whole_number + mb < 0L -> error_cache_range + else -> null + } + } + }, + onValueSaved = { value -> + settingsViewModel.updateSettings { + copy(cacheSizeLimitMB = value.toLongOrNull()?.coerceAtLeast(0L) ?: 0L) + } + } + ) + }) + ) +} + +@Composable +private fun CacheFolderSettingCard( + enabled: Boolean, + folder: String?, + onFolderSelected: (String?) -> Unit) { + val pickerLauncher = rememberDirectoryPickerLauncher { directory -> + if (directory != null) { + val normalized = normalizePath(directory.path) + if (normalized.isNotEmpty()) { + onFolderSelected(normalized) + } + } + } + + SettingCardItem( + title = stringResource(Res.string.settings_cache_folder_title), + subtitle = folder?.let { + stringResource(Res.string.settings_cache_folder_current, it) + } ?: stringResource(Res.string.settings_cache_folder_default), + icon = { + SettingsItemIcon( + Iconsax.IconsaxFolder, + stringResource(Res.string.settings_cache_folder_title) + ) + }, + trailingContent = { + MillenniumIconButton( + icon = Iconsax.IconsaxFolderAdd, + contentDescription = stringResource(Res.string.settings_cache_folder_title), + onClick = { pickerLauncher.launch() }, + enabled = enabled, + style = MillenniumIconButtonStyle.Quiet) + }, + onClick = { pickerLauncher.launch() }, + enabled = enabled) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DesktopSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DesktopSection.kt new file mode 100644 index 00000000..ad3dc2e7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DesktopSection.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.sections + +import androidx.compose.foundation.lazy.LazyListScope +import spotube.composeapp.generated.resources.* +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxImportArrow2 +import org.spotube.spotube.resources.iconsax.LineDiscord +import org.jetbrains.compose.resources.stringResource + +internal fun LazyListScope.desktopSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true) { + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_desktop) + } + settingsSectionCard( + items = listOf( + { + SwitchSettingCard( + title = stringResource(Res.string.settings_desktop_minimize_title), + subtitle = stringResource(Res.string.settings_desktop_minimize_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxImportArrow2, + stringResource(Res.string.settings_desktop_minimize_title) + ) + }, + checked = settings.minimizeToTray, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(minimizeToTray = enabled) + } + } + ) + }, + { + SwitchSettingCard( + title = stringResource(Res.string.settings_desktop_discord_title), + subtitle = stringResource(Res.string.settings_desktop_discord_subtitle), + icon = { + SettingsItemIcon( + Iconsax.LineDiscord, + stringResource(Res.string.settings_desktop_discord_title) + ) + }, + checked = settings.discordRichPresence, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(discordRichPresence = enabled) + } + } + ) + }) + ) +} + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt new file mode 100644 index 00000000..fb5289d4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.sections + + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.foundation.lazy.LazyListScope + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.LocalMediaFoldersViewModel +import org.spotube.spotube.modules.settings.components.LocalMediaFoldersDialogHost +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.spotube.modules.settings.components.SelectionSettingCard +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxFolder +import org.spotube.spotube.resources.iconsax.IconsaxFolderAdd +import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay +import org.spotube.spotube.resources.iconsax.IconsaxVerticalSetting +import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher +import io.github.vinceglb.filekit.path +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_download_folder_current +import spotube.composeapp.generated.resources.settings_download_folder_default_path +import spotube.composeapp.generated.resources.settings_download_folder_title +import spotube.composeapp.generated.resources.settings_download_format_subtitle_current +import spotube.composeapp.generated.resources.settings_download_format_title +import spotube.composeapp.generated.resources.settings_download_quality_title +import spotube.composeapp.generated.resources.settings_local_media_folders_manage +import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_current +import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_empty +import spotube.composeapp.generated.resources.settings_local_media_folders_title +import spotube.composeapp.generated.resources.settings_section_downloads +import spotube.composeapp.generated.resources.settings_subtitle_current + +internal fun LazyListScope.downloadsSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true +) { + val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets) + val downloadQualities = availableAudioQualities( + format = settings.downloadMusicFormat, + current = settings.downloadMusicQuality + ) + + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_downloads) + } + settingsSectionCard( + items = listOf( + { + DownloadFolderSettingCard( + folder = settings.overloadedDownloadFolder, + defaultFolder = settingsViewModel.defaultDownloadFolder, + onFolderSelected = { folder -> + settingsViewModel.updateSettings { + copy(overloadedDownloadFolder = folder) + } + } + ) + }, + { + LocalMediaFoldersSettingCard() + }, + { + SelectionSettingCard( + title = stringResource(Res.string.settings_download_format_title), + subtitle = stringResource( + Res.string.settings_download_format_subtitle_current, + settings.downloadMusicFormat.displayLabel() + ), + icon = { + SettingsItemIcon( + Iconsax.IconsaxMusicPlay, + stringResource(Res.string.settings_download_format_title) + ) + }, + selectedOption = settings.downloadMusicFormat, + options = downloadFormats, + optionLabel = { it.displayLabel() }, + onOptionSelected = { format -> + settingsViewModel.updateSettings { + copy( + downloadMusicFormat = format, + downloadMusicQuality = format.resolveQuality(downloadMusicQuality) + ) + } + } + ) + }, + { + SelectionSettingCard( + title = stringResource(Res.string.settings_download_quality_title), + subtitle = stringResource( + Res.string.settings_subtitle_current, + settings.downloadMusicQuality.displayLabel() + ), + icon = { + SettingsItemIcon( + Iconsax.IconsaxVerticalSetting, + stringResource(Res.string.settings_download_quality_title) + ) + }, + selectedOption = settings.downloadMusicQuality, + options = downloadQualities, + optionLabel = { it.displayLabel() }, + onOptionSelected = { quality -> + settingsViewModel.updateSettings { + copy(downloadMusicQuality = quality) + } + } + ) + }) + ) +} + +@Composable +private fun DownloadFolderSettingCard( + folder: String?, + defaultFolder: String, + onFolderSelected: (String?) -> Unit +) { + val pickerLauncher = rememberDirectoryPickerLauncher { directory -> + if (directory != null) { + val normalized = normalizePath(directory.path) + if (normalized.isNotEmpty()) { + onFolderSelected(normalized) + } + } + } + + SettingCardItem( + title = stringResource(Res.string.settings_download_folder_title), + subtitle = folder?.let { + stringResource(Res.string.settings_download_folder_current, it) + } ?: stringResource(Res.string.settings_download_folder_default_path, defaultFolder), + icon = { + SettingsItemIcon( + Iconsax.IconsaxDirectboxReceive, + stringResource(Res.string.settings_download_folder_title) + ) + }, + trailingContent = { + MillenniumIconButton( + icon = Iconsax.IconsaxFolderAdd, + contentDescription = stringResource(Res.string.settings_download_folder_title), + onClick = { pickerLauncher.launch() }, + style = MillenniumIconButtonStyle.Quiet + ) + }, + onClick = { + pickerLauncher.launch() + } + ) +} + +@Composable +private fun LocalMediaFoldersSettingCard( + viewModel: LocalMediaFoldersViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val subtitlePreview = state.singleConfiguredPath ?: if (state.configuredCount == 0) { + stringResource(Res.string.settings_local_media_folders_subtitle_empty) + } else stringResource( + Res.string.settings_local_media_folders_subtitle_current, + state.configuredCount + ) + + SettingCardItem( + title = stringResource(Res.string.settings_local_media_folders_title), + subtitle = subtitlePreview, + icon = { + SettingsItemIcon( + Iconsax.IconsaxFolder, + stringResource(Res.string.settings_local_media_folders_title) + ) + }, + trailingContent = { + MillenniumButton( + onClick = viewModel::open, + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.settings_local_media_folders_manage)) + } + }, + onClick = viewModel::open + ) + + LocalMediaFoldersDialogHost(viewModel) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamSection.kt new file mode 100644 index 00000000..53192bff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamSection.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package org.spotube.spotube.modules.settings.sections + + +import org.spotube.millennium.composables.MillenniumIcon +import androidx.compose.foundation.lazy.LazyListScope + +import org.spotube.spotube.core.ui.theming.AppTheme +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.resources.iconsax.CustomServer +import org.spotube.spotube.resources.iconsax.Iconsax +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_jam_broker_host_subtitle +import spotube.composeapp.generated.resources.settings_jam_broker_title +import spotube.composeapp.generated.resources.settings_section_jam + +internal fun LazyListScope.jamSection( + settings: UserSettings, + navigationCommands: NavigationCommands) { + settingsSectionHeader(Res.string.settings_section_jam) + settingsSectionCard( + items = listOf( + { + SettingCardItem( + title = stringResource(Res.string.settings_jam_broker_title), + subtitle = stringResource( + Res.string.settings_jam_broker_host_subtitle, + settings.jamBroker.host.ifBlank { "—" }, + settings.jamBroker.port), + icon = { + SettingsItemIcon( + Iconsax.CustomServer, + stringResource(Res.string.settings_jam_broker_title)) + }, + trailingContent = { + MillenniumIcon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = stringResource(Res.string.settings_jam_broker_title), + tint = AppTheme.colors.onSurfaceVariant) + }, + onClick = { navigationCommands.navigateTo(Routes.JamSettings) }) + }) + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LanguageRegionSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LanguageRegionSection.kt new file mode 100644 index 00000000..ac3c95f0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LanguageRegionSection.kt @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.sections + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import org.spotube.spotube.modules.settings.CountryCode +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.SupportedLanguages +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SelectionSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxGlobe +import org.spotube.spotube.resources.iconsax.IconsaxLanguageSquare +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_country_subtitle_current +import spotube.composeapp.generated.resources.settings_country_title +import spotube.composeapp.generated.resources.settings_language_subtitle_current +import spotube.composeapp.generated.resources.settings_language_system_default +import spotube.composeapp.generated.resources.settings_language_title +import spotube.composeapp.generated.resources.settings_option_name_and_code +import spotube.composeapp.generated.resources.settings_section_language_region + +internal fun LazyListScope.languageRegionSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true) { + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_language_region) + } + settingsSectionCard( + items = listOf( + { + SelectionSettingCard( + title = stringResource(Res.string.settings_language_title), + subtitle = stringResource( + Res.string.settings_language_subtitle_current, + languageLabel(settings.language) + ), + icon = { + SettingsItemIcon( + Iconsax.IconsaxLanguageSquare, + stringResource(Res.string.settings_language_title) + ) + }, + selectedOption = settings.language, + options = SupportedLanguages.entries, + optionLabel = { languageLabel(it) }, + onOptionSelected = { value -> + settingsViewModel.updateSettings { + copy(language = value) + } + }, + filter = { item, query -> item.label.contains(query, ignoreCase = true) }) + }, + { + SelectionSettingCard( + title = stringResource(Res.string.settings_country_title), + subtitle = stringResource( + Res.string.settings_country_subtitle_current, + settings.country.displayName + ), + icon = { + SettingsItemIcon( + Iconsax.IconsaxGlobe, + stringResource(Res.string.settings_country_title) + ) + }, + selectedOption = settings.country, + options = CountryCode.entries, + optionLabel = { + stringResource(Res.string.settings_option_name_and_code, it.displayName, it.code) + }, + onOptionSelected = { value -> + settingsViewModel.updateSettings { + copy(country = value) + } + }, + filter = { item, query -> item.label.contains(query, ignoreCase = true) }) + }) + ) +} + +@Composable +private fun languageLabel(language: SupportedLanguages): String = + if (language == SupportedLanguages.SYSTEM) { + stringResource(Res.string.settings_language_system_default) + } else { + stringResource(Res.string.settings_option_name_and_code, language.displayName, language.languageTag!!) + } + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt new file mode 100644 index 00000000..c60df146 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.sections + + +import org.spotube.millennium.composables.MillenniumIcon +import androidx.compose.foundation.lazy.LazyListScope + +import org.spotube.spotube.core.ui.theming.AppTheme +import androidx.compose.runtime.Composable +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import compose.icons.feathericons.Cast +import compose.icons.feathericons.Radio +import compose.icons.feathericons.Repeat +import compose.icons.feathericons.Server +import compose.icons.feathericons.Sliders +import spotube.composeapp.generated.resources.* +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SelectionSettingCard +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.resources.iconsax.CustomServer +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxForbidden +import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay +import org.spotube.spotube.resources.iconsax.IconsaxRepeatArrow +import org.spotube.spotube.resources.iconsax.IconsaxVerticalSetting +import org.jetbrains.compose.resources.stringResource + +internal fun LazyListScope.playbackSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + navigatorCommands: NavigationCommands, + requestLocalNetworkPermission: () -> Unit, + includeBlacklist: Boolean = true, + includeHeader: Boolean = true) { + val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets) + val streamingQualities = availableAudioQualities( + format = settings.streamingMusicFormat, + current = settings.streamingMusicQuality) + + val cards: List<@Composable () -> Unit> = buildList { + add { + SelectionSettingCard( + title = stringResource(Res.string.settings_streaming_format_title), + subtitle = stringResource( + Res.string.settings_streaming_format_subtitle_current, + settings.streamingMusicFormat.displayLabel() + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_streaming_format_title)) + }, + selectedOption = settings.streamingMusicFormat, + options = streamingFormats, + optionLabel = { it.displayLabel() }, + onOptionSelected = { format -> + settingsViewModel.updateSettings { + copy( + streamingMusicFormat = format, + streamingMusicQuality = format.resolveQuality(streamingMusicQuality)) + } + } + ) + } + add { + SelectionSettingCard( + title = stringResource(Res.string.settings_streaming_quality_title), + subtitle = stringResource( + Res.string.settings_subtitle_current, + settings.streamingMusicQuality.displayLabel() + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_streaming_quality_title)) + }, + selectedOption = settings.streamingMusicQuality, + options = streamingQualities, + optionLabel = { it.displayLabel() }, + onOptionSelected = { quality -> + settingsViewModel.updateSettings { + copy(streamingMusicQuality = quality) + } + } + ) + } + add { + SwitchSettingCard( + title = stringResource(Res.string.settings_enable_endless_playback_title), + subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxRepeatArrow, + stringResource(Res.string.settings_enable_endless_playback_title) + ) + }, + checked = settings.enableEndlessPlayback, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(enableEndlessPlayback = enabled) + } + } + ) + } + addAll(remoteControlSettingCards(settings, settingsViewModel, requestLocalNetworkPermission)) + add { + val error_whole_number = stringResource(Res.string.settings_error_whole_number) + val error_port_range = stringResource(Res.string.settings_error_port_range) + + TextInputSettingCard( + title = stringResource(Res.string.settings_playback_port_title), + subtitle = stringResource( + Res.string.settings_playback_port_subtitle_current, + settings.playbackProxyServerPort + ), + icon = { + SettingsItemIcon(Iconsax.CustomServer, stringResource(Res.string.settings_playback_port_title)) + }, + value = settings.playbackProxyServerPort.toString(), + dialogDescription = stringResource(Res.string.settings_playback_port_description), + placeholder = stringResource(Res.string.settings_playback_port_placeholder), + normalize = { it.trim() }, + validate = { value -> + val port = value.toIntOrNull() + when { + port == null -> error_whole_number + port !in 1..65535 -> error_port_range + else -> null + } + }, + onValueSaved = { value -> + settingsViewModel.updateSettings { + copy(playbackProxyServerPort = value.toInt()) + } + } + ) + } + if (includeBlacklist) { + add { + SettingCardItem( + title = stringResource(Res.string.settings_blacklist_title), + subtitle = stringResource(Res.string.settings_blacklist_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxForbidden, + stringResource(Res.string.settings_blacklist_title)) + }, + trailingContent = { + MillenniumIcon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = stringResource(Res.string.settings_blacklist_title), + tint = AppTheme.colors.onSurfaceVariant) + }, + onClick = { + navigatorCommands.navigateTo(Routes.Blacklist) + }) + } + } + } + + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_playback) + } + settingsSectionCard(items = cards) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PluginsSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PluginsSection.kt new file mode 100644 index 00000000..d43a1ca9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PluginsSection.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.sections + + +import org.spotube.millennium.composables.MillenniumIcon +import androidx.compose.foundation.lazy.LazyListScope + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import compose.icons.feathericons.Package +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import spotube.composeapp.generated.resources.* +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxBoxAdd +import org.jetbrains.compose.resources.stringResource + +internal fun LazyListScope.pluginsSection( + navigatorCommands: NavigationCommands +) { + settingsSectionHeader(Res.string.settings_section_plugins) + settingsSectionCard( + items = listOf { + SettingCardItem( + title = stringResource(Res.string.settings_plugins_manage_title), + subtitle = stringResource(Res.string.settings_plugins_manage_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxBoxAdd, + stringResource(Res.string.settings_section_plugins), + Color(0xFFFF9800) + ) + }, + trailingContent = { + MillenniumIcon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = stringResource(Res.string.settings_plugins_manage_title), + tint = AppTheme.colors.onSurfaceVariant + ) + }, + onClick = { + navigatorCommands.navigateTo(Routes.Plugins) + } + ) + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/RemoteControlSettings.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/RemoteControlSettings.kt new file mode 100644 index 00000000..5485f195 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/RemoteControlSettings.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.sections + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.stringResource +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxEdit +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_allow_remote_control_subtitle +import spotube.composeapp.generated.resources.settings_allow_remote_control_title +import spotube.composeapp.generated.resources.settings_remote_device_name_default +import spotube.composeapp.generated.resources.settings_remote_device_name_description +import spotube.composeapp.generated.resources.settings_remote_device_name_placeholder +import spotube.composeapp.generated.resources.settings_remote_device_name_subtitle +import spotube.composeapp.generated.resources.settings_remote_device_name_title + +/** + * "Allow remote control" and "Device name" cards. Shared by the Playback settings section and the + * Devices screen so both stay identical. + */ +internal fun remoteControlSettingCards( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + requestLocalNetworkPermission: () -> Unit +): List<@Composable () -> Unit> = listOf( + { + SwitchSettingCard( + title = stringResource(Res.string.settings_allow_remote_control_title), + subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxMirroringScreen, + stringResource(Res.string.settings_allow_remote_control_title) + ) + }, + checked = settings.allowRemoteControl, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(allowRemoteControl = enabled) + } + // Request the local network permission when enabling remote control + // so that DNS-SD registration can succeed on Android 16+ + if (enabled) { + requestLocalNetworkPermission() + } + } + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_remote_device_name_title), + subtitle = stringResource( + Res.string.settings_remote_device_name_subtitle, + settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) } + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title)) + }, + value = settings.remoteControlDeviceName, + dialogDescription = stringResource(Res.string.settings_remote_device_name_description), + placeholder = stringResource(Res.string.settings_remote_device_name_placeholder), + normalize = { it.trim() }, + validate = { _ -> null }, + onValueSaved = { value -> + settingsViewModel.updateSettings { + copy(remoteControlDeviceName = value) + } + } + ) + } +) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/SupportSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/SupportSection.kt new file mode 100644 index 00000000..6d54f4de --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/SupportSection.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.sections + +import androidx.compose.foundation.lazy.LazyListScope +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.modules.support.donationOptions +import org.spotube.spotube.modules.support.openDonation +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_section_support +import spotube.composeapp.generated.resources.settings_support_reminders_subtitle +import spotube.composeapp.generated.resources.settings_support_reminders_title + +/** Support settings: the periodic reminder toggle plus the donation destinations. */ +internal fun LazyListScope.supportSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true) { + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_support) + } + settingsSectionCard( + items = buildList { + add { + SwitchSettingCard( + title = stringResource(Res.string.settings_support_reminders_title), + subtitle = stringResource(Res.string.settings_support_reminders_subtitle), + icon = { + SettingsItemIcon( + imageVector = Iconsax.IconsaxHeart2, + contentDescription = stringResource( + Res.string.settings_support_reminders_title)) + }, + checked = settings.supportRemindersEnabled, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(supportRemindersEnabled = enabled) + } + }) + } + donationOptions.forEach { option -> + add { + SettingCardItem( + title = stringResource(option.title), + subtitle = stringResource(option.subtitle), + icon = { + SettingsItemIcon( + imageVector = option.icon, + contentDescription = stringResource(option.title)) + }, + onClick = { openDonation(option) }) + } + } + }) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/UpdatesSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/UpdatesSection.kt new file mode 100644 index 00000000..dbf5af6d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/UpdatesSection.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.sections + +import androidx.compose.foundation.lazy.LazyListScope +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxRefreshArrow2 +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_section_updates +import spotube.composeapp.generated.resources.settings_updates_auto_check_subtitle +import spotube.composeapp.generated.resources.settings_updates_auto_check_title + +internal fun LazyListScope.updatesSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true) { + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_updates) + } + settingsSectionCard( + items = listOf { + SwitchSettingCard( + title = stringResource(Res.string.settings_updates_auto_check_title), + subtitle = stringResource(Res.string.settings_updates_auto_check_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxRefreshArrow2, + stringResource(Res.string.settings_updates_auto_check_title) + ) + }, + checked = settings.autoCheckForUpdates, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(autoCheckForUpdates = enabled) + } + } + ) + } + ) +} + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt new file mode 100644 index 00000000..34849447 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.sections + + +import org.spotube.spotube.core.server.isContainerPlayableOn +import org.spotube.spotube.core.server.currentPlatform +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_audio_format_label +import spotube.composeapp.generated.resources.settings_audio_quality_lossless +import spotube.composeapp.generated.resources.settings_audio_quality_lossy + +private val standardLossyQualities = listOf( + AudioQuality.Lossy(bitrate = 44_000), + AudioQuality.Lossy(bitrate = 96_000), + AudioQuality.Lossy(bitrate = 128_000), + AudioQuality.Lossy(bitrate = 256_000)) + +// Formats the platform's player can't open aren't offered (iOS: no WebM/Ogg). +internal val streamingFormatPresets = listOf( + AudioFormat( + codec = "opus", + container = "webm", + qualities = standardLossyQualities), + AudioFormat( + codec = "aac", + container = "mp4", + qualities = standardLossyQualities), + AudioFormat( + codec = "vorbis", + container = "ogg", + qualities = standardLossyQualities) +).filter { isContainerPlayableOn(it.container, currentPlatform) } + +internal val downloadFormatPresets = listOf( + AudioFormat( + codec = "aac", + container = "mp4", + qualities = standardLossyQualities), + AudioFormat( + codec = "mp3", + container = "mp3", + qualities = standardLossyQualities), + AudioFormat( + codec = "opus", + container = "webm", + qualities = standardLossyQualities), + AudioFormat( + codec = "flac", + container = "flac", + qualities = listOf( + AudioQuality.Lossless(sampleRate = 44_100, channels = 2), + AudioQuality.Lossless(sampleRate = 48_000, channels = 2)))) + +internal fun LazyListScope.settingsSectionHeader(title: StringResource) { + item { + Text( + text = stringResource(title), + style = AppTheme.typography.labelMedium, + color = Color.Gray, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + } +} + +/** + * A page-level title for a settings destination (used by the large-screen sidebar + * layout, where each section is its own page rather than one entry in a long list). + */ +@Composable +internal fun SettingsPageTitle(title: StringResource) { + Text( + text = stringResource(title), + style = AppTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) +} + +@Composable +internal fun SettingsItemIcon( + imageVector: ImageVector, + contentDescription: String, + tint: Color = AppTheme.colors.primary) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(tint.copy(alpha = 0.12f))) { + Box(modifier = Modifier.padding(8.dp)) { + MillenniumIcon( + imageVector = imageVector, + contentDescription = contentDescription, + tint = tint) + } + } +} + +internal fun availableAudioFormats(current: AudioFormat, presets: List): List { + val options = mutableListOf(current) + presets.forEach { preset -> + val alreadyIncluded = options.any { + it.codec.equals(preset.codec, ignoreCase = true) && + it.container.equals(preset.container, ignoreCase = true) + } + if (!alreadyIncluded) { + options += preset + } + } + return options +} + +internal fun availableAudioQualities(format: AudioFormat, current: AudioQuality): List { + val options = format.qualities.toMutableList() + if (options.none { it == current }) { + options += current + } + return options +} + +@Composable +internal fun AudioFormat.displayLabel(): String { + return stringResource(Res.string.settings_audio_format_label, codec.uppercase(), container.uppercase()) +} + +internal fun AudioFormat.preferredQuality(): AudioQuality { + return qualities.lastOrNull() ?: AudioQuality.Lossy(bitrate = 256_000) +} + +internal fun AudioFormat.resolveQuality(preferred: AudioQuality): AudioQuality { + return qualities.firstOrNull { it == preferred } ?: preferredQuality() +} + +@Composable +internal fun AudioQuality.displayLabel(): String { + return when (this) { + is AudioQuality.Lossy -> stringResource(Res.string.settings_audio_quality_lossy, bitrate / 1000) + is AudioQuality.Lossless -> { + val sampleRateLabel = if (sampleRate % 1000 == 0) { + "${sampleRate / 1000}" + } else { + "${sampleRate / 1000.0}" + } + stringResource(Res.string.settings_audio_quality_lossless, sampleRateLabel, channels) + } + } +} + +internal fun normalizePath(path: String): String { + return path.trim().trimEnd('/', '\\') +} + +internal fun LazyListScope.settingsSectionCard( + items: List<@Composable () -> Unit>) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = AppTheme.shapes.medium, + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + shadowElevation = 6.dp) + .padding(top = 4.dp, bottom = 4.dp) + ) { + items.forEachIndexed { index, content -> + if (index > 0) { + MillenniumSeparator() + } + content() + } + } + } +} + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppBottombar.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppBottombar.kt new file mode 100644 index 00000000..9dc42cc4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppBottombar.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + +import com.composeunstyled.LocalTextStyle +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumBottomNavigationBar +import org.spotube.millennium.composables.MillenniumBottomNavigationItem +import org.spotube.millennium.composables.MillenniumIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.spotube.spotube.NavigationItem +import org.jetbrains.compose.resources.stringResource +import org.spotube.spotube.bottombarTabs +import org.spotube.spotube.core.navigation.NavigationState +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.downloads.DownloadBadgeIndicator + +@Composable +fun AppBottombar( + navigator: Navigator, + navigationState: NavigationState, + modifier: Modifier = Modifier, + libraryBadge: @Composable () -> Unit = { DownloadBadgeIndicator() } +) { + MillenniumBottomNavigationBar(modifier = modifier) { + bottombarTabs.filterIsInstance>() + .forEach { tab -> + val selected = navigationState.topLevelRoute == tab.route + val title = stringResource(tab.title) + MillenniumBottomNavigationItem( + selected = selected, + onClick = { navigator.navigate(tab.route) }, + contentDescription = title, + icon = { + MillenniumIcon( + imageVector = tab.icon, + contentDescription = null, + tint = LocalTextStyle.current.color + ) + }, + label = { Text(title) }, + badge = if (tab.route == Routes.Library) { + libraryBadge + } else { + null + } + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt new file mode 100644 index 00000000..2270c98e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt @@ -0,0 +1,774 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + + +import org.spotube.millennium.composables.MillenniumIcon +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll + +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.composables.MillenniumSnackbarHost +import org.spotube.millennium.composables.MillenniumSnackbarHostState +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumSlider +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState + +import com.composeunstyled.Text +import com.composeunstyled.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.downloads.DownloadProgressIcon +import org.spotube.spotube.modules.downloads.DownloadStatus +import org.spotube.spotube.modules.downloads.DownloadsViewModel +import org.spotube.spotube.modules.lyrics.LyricsViewModel +import org.spotube.spotube.modules.lyrics.SyncedLyricsList +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID +import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 +import org.spotube.spotube.resources.iconsax.IconsaxArrowSquareUp +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxPrevious +import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight +import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne +import org.spotube.spotube.resources.iconsax.IconsaxShuffle +import org.spotube.spotube.resources.iconsax.InconsaxClock +import org.spotube.spotube.resources.iconsax.SwapHorizontal2 +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import kotlin.time.Duration.Companion.milliseconds +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.downloads_status_cancelled +import spotube.composeapp.generated.resources.downloads_status_failed +import spotube.composeapp.generated.resources.downloads_status_queued +import spotube.composeapp.generated.resources.player_alternative_source +import spotube.composeapp.generated.resources.player_collapse_content_description +import spotube.composeapp.generated.resources.player_disable_shuffle_content_description +import spotube.composeapp.generated.resources.player_download +import spotube.composeapp.generated.resources.player_download_already_downloaded +import spotube.composeapp.generated.resources.player_download_retrying +import spotube.composeapp.generated.resources.player_download_started +import spotube.composeapp.generated.resources.player_download_status_downloaded +import spotube.composeapp.generated.resources.player_download_status_downloading +import spotube.composeapp.generated.resources.player_enable_shuffle_content_description +import spotube.composeapp.generated.resources.player_expand_lyrics_content_description +import spotube.composeapp.generated.resources.player_go_to_album +import spotube.composeapp.generated.resources.player_loop_mode_all +import spotube.composeapp.generated.resources.player_loop_mode_none +import spotube.composeapp.generated.resources.player_loop_mode_one +import spotube.composeapp.generated.resources.player_lyrics +import spotube.composeapp.generated.resources.player_next_content_description +import spotube.composeapp.generated.resources.player_now_playing +import spotube.composeapp.generated.resources.player_options_content_description +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.player_previous_content_description +import spotube.composeapp.generated.resources.player_seek_label +import spotube.composeapp.generated.resources.player_sleep_timer +import spotube.composeapp.generated.resources.player_track_details +import spotube.composeapp.generated.resources.queue_title + +// The expanded player on small screens +// on the top left it has a angle/chevron down that can be used to collapse the player back to the +// floating player +// on the top right it has a three dots for options +// The top bar can be used to swipe down and close the player back to the floating player. It should +// follow the user's finger while swiping down and should have a nice animation when collapsing back +// to the floating player +// On the middle the album art stays rounded, centered and takes up most of the space +// Below the album art, on the left the song title and artists are shown and on the right the like +// button is shown +// below that we have the playback controls and the progress bar, which should be similar to the +// large player but with a different layout to fit the smaller screen +// And at the very end we will have the volume slider +@Composable +fun AppExpandedPlayer( + modifier: Modifier = Modifier, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, + onCollapse: () -> Unit = {}, + onQueue: () -> Unit = {}, + onAlternativeSource: () -> Unit = {}, + onExpandLyrics: () -> Unit = {}, + onDownloadTrack: () -> Unit = {}, + onGoToAlbum: () -> Unit = {}, + onSleepTimer: () -> Unit = {}, + audioPlayer: AudioPlayerInterface = koinInject(), + audioPlayerQueue: AudioPlayerQueue = koinInject(), + playerOptionsViewModel: PlayerOptionsViewModel = koinViewModel(), + savedTracksViewModel: SavedTracksViewModel = koinViewModel( + key = SAVED_TRACKS_COLLECTION_ID, + parameters = { parametersOf() } + ) +) { + val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) + val jamRoomService: JamRoomService = koinInject() + val isJamGuest by jamRoomService.role + .map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) + val scope = rememberCoroutineScope() + val downloadsViewModel: DownloadsViewModel = koinViewModel() + val navigationCommands: NavigationCommands = koinInject() + val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() + val playerOptionsUiState by playerOptionsViewModel.uiState.collectAsStateWithLifecycle() + val currentTrack = remember(currentEntry) { + (currentEntry as? QueueEntry.StreamingTrack)?.track + } + val downloads by downloadsViewModel.downloads.collectAsStateWithLifecycle() + val currentDownload = remember(currentTrack, downloads) { + currentTrack?.let { t -> + downloads.lastOrNull { it.track?.id == t.id } + } + } + val snackbarHostState = remember { MillenniumSnackbarHostState() } + val albumArtModifier = + rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope) + val coverModel = playerUiState.coverModel + var isSeeking by remember { mutableStateOf(false) } + var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } + val moreOptionsSheetState = rememberModalBottomSheetState( + initialDetent = SheetDetent.FullyExpanded + ) + + PlayerOptionDialogs(viewModel = playerOptionsViewModel) + + LaunchedEffect(playerUiState.progress, isSeeking) { + if (!isSeeking) { + seekProgress = playerUiState.progress + } + } + + fun onPlayPause() { + scope.launch { + if (playerUiState.isPlaying) { + audioPlayer.pause() + } else { + audioPlayer.play() + } + } + } + + fun onSkipPrevious() { + if (isJamGuest) return + scope.launch { audioPlayer.skipToPrevious() } + } + + fun onSkipNext() { + if (isJamGuest) return + scope.launch { audioPlayer.skipToNext() } + } + + fun onShuffleToggle() { + if (isJamGuest) return + scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } + } + + fun onLoopToggle() { + if (isJamGuest) return + scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } + } + + fun onSeekFinished() { + val durationMillis = playerUiState.seekDuration.inWholeMilliseconds + if (durationMillis <= 0L) return + scope.launch { + audioPlayer.seekTo( + (durationMillis * seekProgress.coerceIn( + 0f, + 1f + )).toLong().milliseconds + ) + } + } + + LaunchedEffect(moreOptionsSheetState.bottomSheetState.currentDetent) { + if (moreOptionsSheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) { + playerOptionsViewModel.dismissMoreOptionsSheet() + } + } + + LaunchedEffect(playerOptionsUiState.isMoreOptionsSheetOpen) { + if (playerOptionsUiState.isMoreOptionsSheetOpen) { + moreOptionsSheetState.animateTo(SheetDetent.FullyExpanded) + } + } + + Box(modifier = modifier.fillMaxSize().background(AppTheme.colors.background)) { + if (playerOptionsUiState.isMoreOptionsSheetOpen) { + MillenniumModalBottomSheet( + state = moreOptionsSheetState, + contentPadding = PaddingValues(horizontal = 0.dp, vertical = 8.dp), + contentSpacing = 4.dp + ) { + // Grid of Options + LazyVerticalGrid( + columns = GridCells.Fixed(3), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + item { + OptionTile( + icon = Iconsax.SwapHorizontal2, + label = stringResource(Res.string.player_alternative_source), + onClick = { + playerOptionsViewModel.dismissMoreOptionsSheet() + onAlternativeSource() + }) + } + item { + val download = currentDownload + val status = download?.status + val statusLabel = when (status) { + is DownloadStatus.Completed -> stringResource(Res.string.player_download_status_downloaded) + is DownloadStatus.Failed -> stringResource(Res.string.downloads_status_failed) + is DownloadStatus.Cancelled -> stringResource(Res.string.downloads_status_cancelled) + is DownloadStatus.Queued -> stringResource(Res.string.downloads_status_queued) + is DownloadStatus.Downloading -> stringResource(Res.string.player_download_status_downloading) + null -> stringResource(Res.string.player_download) + } + val statusColor = when (status) { + is DownloadStatus.Completed -> AppTheme.colors.primary + is DownloadStatus.Failed -> AppTheme.colors.error + else -> AppTheme.colors.onSurfaceVariant + } + + Box( + modifier = Modifier + .aspectRatio(1f) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(14.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + shadowElevation = 2.dp + ) + .clickable { + playerOptionsViewModel.dismissMoreOptionsSheet() + when (status) { + is DownloadStatus.Completed -> { + scope.launch { + snackbarHostState.showSnackbar(getString(Res.string.player_download_already_downloaded)) + } + } + + is DownloadStatus.Failed, is DownloadStatus.Cancelled -> { + download?.let { downloadsViewModel.retry(it.id) } + scope.launch { + snackbarHostState.showSnackbar(getString(Res.string.player_download_retrying)) + } + } + + is DownloadStatus.Downloading, is DownloadStatus.Queued -> { + // already in progress + } + + null -> { + val track = currentTrack + if (track != null) { + downloadsViewModel.downloadTrack(track) + scope.launch { + snackbarHostState.showSnackbar( + message = getString(Res.string.player_download_started, track.title) + ) + } + } + } + } + onDownloadTrack() + }) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + if (status is DownloadStatus.Downloading) { + DownloadProgressIcon( + track = currentTrack, + icon = Iconsax.IconsaxDirectboxReceive, + contentDescription = stringResource(Res.string.player_download), + modifier = Modifier.size(28.dp), + tint = AppTheme.colors.onSurface + ) + } else { + val icon = when (status) { + is DownloadStatus.Completed -> Iconsax.IconsaxCheckCircle + is DownloadStatus.Failed -> Iconsax.IconsaxCloseSquare + is DownloadStatus.Cancelled -> Iconsax.IconsaxRefreshRight + is DownloadStatus.Queued -> Iconsax.InconsaxClock + null -> Iconsax.IconsaxDirectboxReceive + } + MillenniumIcon( + imageVector = icon, + contentDescription = statusLabel, + modifier = Modifier.size(28.dp), + tint = when (status) { + is DownloadStatus.Completed -> AppTheme.colors.primary + is DownloadStatus.Failed -> AppTheme.colors.error + else -> AppTheme.colors.onSurface + } + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = statusLabel, + style = AppTheme.typography.labelSmall, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = statusColor + ) + } + } + } + item { + OptionTile( + icon = Iconsax.InconsaxClock, + label = stringResource(Res.string.player_sleep_timer), + onClick = { + playerOptionsViewModel.dismissMoreOptionsSheet() + playerOptionsViewModel.showSleepTimerDialog() + onSleepTimer() + }) + } + item { + OptionTile( + icon = Iconsax.IconsaxCd, + label = stringResource(Res.string.player_go_to_album), + onClick = { + playerOptionsViewModel.dismissMoreOptionsSheet() + val albumId = currentTrack?.album?.id + if (albumId != null) { + navigationCommands.navigateTo(Routes.Album(albumId)) + onCollapse() + } + onGoToAlbum() + }) + } + item { + OptionTile( + icon = Iconsax.Iconsax3DotsMore, + label = stringResource(Res.string.player_track_details), + onClick = { + playerOptionsViewModel.dismissMoreOptionsSheet() + playerOptionsViewModel.showTrackDetails() + }) + } + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + MillenniumIconButton( + onClick = onCollapse, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxArrowDown4, + contentDescription = stringResource(Res.string.player_collapse_content_description) + ) + } + Text( + text = stringResource(Res.string.player_now_playing), + style = AppTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + maxLines = 1 + ) + MillenniumIconButton( + onClick = playerOptionsViewModel::showMoreOptionsSheet, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon(Iconsax.Iconsax3DotsMore, contentDescription = stringResource(Res.string.player_options_content_description)) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp, bottom = 24.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.84f) + .aspectRatio(1f) + .then(albumArtModifier) + .clip(RoundedCornerShape(22.dp)) + .background(AppTheme.colors.surfaceContainerHighest) + ) { + AsyncImage( + model = coverModel, + contentDescription = playerUiState.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + PlayerHeartButton( + audioPlayerQueue = audioPlayerQueue, + savedTracksViewModel = savedTracksViewModel + ) + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = playerUiState.title, + style = AppTheme.typography.headlineSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + PlayerArtistsText( + artists = playerUiState.artistList, + onArtistClick = { artistId -> + navigationCommands.navigateTo(Routes.Artist(artistId)) + // The page opens beneath the player, so get out of the way + onCollapse() + }, + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + MillenniumIconButton( + onClick = onQueue, + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = stringResource(Res.string.queue_title)) + } + } + + Column(modifier = Modifier.padding(top = 20.dp)) { + MillenniumSlider( + value = seekProgress, + onValueChange = { + seekProgress = it + isSeeking = true + }, + onValueChangeFinished = { + isSeeking = false + onSeekFinished() + }, + enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L, + label = stringResource(Res.string.player_seek_label), + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatPlayerTime(playerUiState.position), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + Text( + text = formatPlayerTime(playerUiState.displayDuration), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + MillenniumIconButton( + onClick = ::onShuffleToggle, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxShuffle, + contentDescription = stringResource(if (playerUiState.isShuffling) Res.string.player_disable_shuffle_content_description else Res.string.player_enable_shuffle_content_description), + tint = if (playerUiState.isShuffling) { + AppTheme.colors.primary + } else { + AppTheme.colors.onSurfaceVariant + } + ) + } + MillenniumIconButton( + onClick = ::onSkipPrevious, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = stringResource(Res.string.player_previous_content_description)) + } + MillenniumIconButton( + onClick = ::onPlayPause, + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_content_description), + modifier = Modifier.size(30.dp), + tint = LocalTextStyle.current.color + ) + } + MillenniumIconButton( + onClick = ::onSkipNext, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon(Iconsax.IconsaxNext, contentDescription = stringResource(Res.string.player_next_content_description)) + } + MillenniumIconButton( + onClick = ::onLoopToggle, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = when (playerUiState.loopState) { + LoopState.NONE -> Iconsax.IconsaxRepeateMusic + LoopState.ONE -> Iconsax.IconsaxRepeateOne + LoopState.ALL -> Iconsax.IconsaxRepeatMusic + }, + contentDescription = stringResource(when (playerUiState.loopState) { LoopState.NONE -> Res.string.player_loop_mode_none; LoopState.ONE -> Res.string.player_loop_mode_one; LoopState.ALL -> Res.string.player_loop_mode_all }), + tint = if (playerUiState.loopState == LoopState.NONE) { + AppTheme.colors.onSurfaceVariant + } else { + AppTheme.colors.primary + } + ) + } + } + } + + LyricsPreviewCard( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + onExpand = onExpandLyrics + ) + } + + MillenniumSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun rememberSharedAlbumArtModifier( + sharedTransitionScope: SharedTransitionScope?, + animatedVisibilityScope: AnimatedVisibilityScope? +): Modifier { + if (sharedTransitionScope == null || animatedVisibilityScope == null) return Modifier + + with(sharedTransitionScope) { + return Modifier.sharedElement( + sharedContentState = rememberSharedContentState(key = "player_album_art"), + animatedVisibilityScope = animatedVisibilityScope + ) + } +} + +@Composable +private fun LyricsPreviewCard( + modifier: Modifier = Modifier, + onExpand: () -> Unit, + viewModel: LyricsViewModel = koinViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val syncedLyrics = uiState.syncedLyrics ?: emptyList() + val isLoading = uiState.isLoading + if (syncedLyrics.isEmpty() || isLoading) return + + MillenniumCard( + modifier = modifier.clickable(onClick = onExpand), + contentPadding = PaddingValues(12.dp), + contentSpacing = 8.dp, + shadowElevation = 2.dp + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(Res.string.player_lyrics), + style = AppTheme.typography.labelMedium, + color = MillenniumTheme.colors.muted + ) + MillenniumIconButton( + onClick = onExpand, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxArrowSquareUp, + contentDescription = stringResource(Res.string.player_expand_lyrics_content_description), + tint = MillenniumTheme.colors.muted + ) + } + } + + // Passive preview: no onLineClick, so lines never seek playback. + SyncedLyricsList( + lines = syncedLyrics, + currentIndex = uiState.currentIndex, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + ) + } +} + +@Composable +private fun OptionTile( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + MillenniumCard( + modifier = modifier + .aspectRatio(1f) + .clickable(onClick = onClick), + contentPadding = PaddingValues(12.dp) + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + MillenniumIcon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(28.dp), + tint = AppTheme.colors.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = label, + style = AppTheme.typography.labelSmall, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = AppTheme.colors.onSurfaceVariant + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt new file mode 100644 index 00000000..fd64f559 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt @@ -0,0 +1,242 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumProgress +import org.spotube.millennium.theme.millenniumSurface +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_nothing_playing +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface + +internal val FloatingPlayerContentHeight = 64.dp +internal val FloatingPlayerBottomBleed = 16.dp + +// The floating player on small screens that appears above the floating AppBottombar +// +// on left the album art, song title, artists grouped together +// on right the playback controls +// top border is actually the progress bar +// users can skip to next or previous track by swiping left or right on the player +// +// the player can be expanded to the full screen by swiping up on the player, and can be collapsed +// back to the floating player by swiping down on the player +@Composable +fun AppFloatingPlayer( + modifier: Modifier = Modifier, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, + audioPlayer: AudioPlayerInterface = koinInject(), + audioPlayerQueue: AudioPlayerQueue = koinInject(), + savedTracksViewModel: SavedTracksViewModel = koinViewModel( + key = SAVED_TRACKS_COLLECTION_ID, + parameters = { parametersOf() } + ) +) { + val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) + val scope = rememberCoroutineScope() + val albumArtModifier = + rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope) + + fun onPlayPause() { + scope.launch { + if (playerUiState.isPlaying) { + audioPlayer.pause() + } else { + audioPlayer.play() + } + } + } + + FloatingPlayerContent( + playerUiState = playerUiState, + modifier = modifier, + albumArtModifier = albumArtModifier, + onPlayPause = ::onPlayPause, + heartButton = { + PlayerHeartButton( + audioPlayerQueue = audioPlayerQueue, + savedTracksViewModel = savedTracksViewModel + ) + } + ) +} + +@Composable +internal fun FloatingPlayerContent( + playerUiState: PlayerUiState, + modifier: Modifier = Modifier, + albumArtModifier: Modifier = Modifier, + onPlayPause: () -> Unit, + heartButton: @Composable () -> Unit +) { + val coverModel = playerUiState.coverModel + + Box( + modifier = modifier + .fillMaxWidth() + .height(FloatingPlayerContentHeight + FloatingPlayerBottomBleed) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + // Only the surface extends beneath the navigation bar, not the controls. + .padding(bottom = FloatingPlayerBottomBleed) + ) { + MillenniumProgress( + progress = playerUiState.progress, + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + ) + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(42.dp) + .then(albumArtModifier) + .clip(RoundedCornerShape(6.dp)) + .background(AppTheme.colors.onSurface.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + if (coverModel != null) AsyncImage( + model = coverModel, + contentDescription = playerUiState.title, + modifier = Modifier + .fillMaxSize(), + contentScale = ContentScale.Crop + ) + else MillenniumIcon( + Iconsax.IconsaxMusic, contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant, modifier = Modifier.size(22.dp) + ) + } + Column(modifier = Modifier.padding(start = 10.dp)) { + Text( + text = if (playerUiState.queue.isEmpty()) stringResource(Res.string.player_nothing_playing) else playerUiState.title, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (playerUiState.queue.isNotEmpty()) Text( + text = playerUiState.artists, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + heartButton() + Spacer(modifier = Modifier.size(8.dp)) + MillenniumIconButton( + onClick = onPlayPause, + enabled = playerUiState.queue.isNotEmpty(), + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_content_description) + ) + } + } + } + } + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun rememberSharedAlbumArtModifier( + sharedTransitionScope: SharedTransitionScope?, + animatedVisibilityScope: AnimatedVisibilityScope? +): Modifier { + if (sharedTransitionScope == null || animatedVisibilityScope == null) return Modifier + + with(sharedTransitionScope) { + return Modifier.sharedElement( + sharedContentState = rememberSharedContentState(key = "player_album_art"), + animatedVisibilityScope = animatedVisibilityScope + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt new file mode 100644 index 00000000..3490f2fa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt @@ -0,0 +1,524 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + + +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxMoon +import org.spotube.spotube.resources.iconsax.IconsaxInformation +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.composeunstyled.AnchorSide +import com.composeunstyled.Text +import com.composeunstyled.LocalTextStyle +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.blurEffect +import dev.chrisbanes.haze.hazeEffect +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.downloads.DownloadProgressIcon +import org.spotube.spotube.modules.downloads.DownloadsViewModel +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID +import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxPrevious +import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne +import org.spotube.spotube.resources.iconsax.IconsaxShuffle +import org.spotube.spotube.resources.iconsax.IconsaxVolumeCross +import org.spotube.spotube.resources.iconsax.IconsaxVolumeHigh +import org.spotube.spotube.resources.iconsax.IconsaxVolumeLow +import org.spotube.spotube.resources.iconsax.SwapHorizontal2 +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSlider +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_go_to_album +import spotube.composeapp.generated.resources.player_sleep_timer +import spotube.composeapp.generated.resources.player_track_details +import kotlin.time.Duration.Companion.milliseconds +import spotube.composeapp.generated.resources.player_alternative_source_content_description +import spotube.composeapp.generated.resources.player_disable_shuffle_content_description +import spotube.composeapp.generated.resources.player_download +import spotube.composeapp.generated.resources.player_enable_shuffle_content_description +import spotube.composeapp.generated.resources.player_loop_mode_all +import spotube.composeapp.generated.resources.player_loop_mode_none +import spotube.composeapp.generated.resources.player_loop_mode_one +import spotube.composeapp.generated.resources.player_lyrics +import spotube.composeapp.generated.resources.player_more_options_content_description +import spotube.composeapp.generated.resources.player_mute_content_description +import spotube.composeapp.generated.resources.player_next_content_description +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_or_pause_content_description +import spotube.composeapp.generated.resources.player_previous_content_description +import spotube.composeapp.generated.resources.player_seek_label +import spotube.composeapp.generated.resources.player_unmute_content_description +import spotube.composeapp.generated.resources.player_volume_label +import spotube.composeapp.generated.resources.queue_title + +// The main player on large screens shown at the bottom of the screen permanently. It should be +// below the sidebar. And should be similarly give a floating vibe with a translucent background +// just like the AppBottombar. +// +// On the left it contains the album art, song title, artists and like btn grouped together +// On the center it contains the playback controls and the progress bar +// On the right it contains the queue btn, download button btn, alternative track source button +// and three dots for options grouped together and below these buttons the volume slider will be shown +@Composable +fun AppLargePlayer( + modifier: Modifier = Modifier, + hazeState: HazeState, + onQueue: () -> Unit = {}, + onAlternativeSource: () -> Unit = {}, + onMoreOptions: () -> Unit = {}, + onLyrics: () -> Unit = {}, + audioPlayer: AudioPlayerInterface = koinInject(), + audioPlayerQueue: AudioPlayerQueue = koinInject(), + downloadsViewModel: DownloadsViewModel = koinViewModel(), + playerOptionsViewModel: PlayerOptionsViewModel = koinViewModel(), + savedTracksViewModel: SavedTracksViewModel = koinViewModel( + key = SAVED_TRACKS_COLLECTION_ID, + parameters = { parametersOf() } + ) +) { + val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) + val jamRoomService: JamRoomService = koinInject() + val isJamGuest by jamRoomService.role + .map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) + val scope = rememberCoroutineScope() + val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() + val currentTrack = (currentEntry as? QueueEntry.StreamingTrack)?.track + val playerOptionsUiState by playerOptionsViewModel.uiState.collectAsStateWithLifecycle() + val navigationCommands: NavigationCommands = koinInject() + var isSeeking by remember { mutableStateOf(false) } + var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } + var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) } + val coverModel = playerUiState.coverModel + + PlayerOptionDialogs(viewModel = playerOptionsViewModel) + + LaunchedEffect(playerUiState.progress, isSeeking) { + if (!isSeeking) { + seekProgress = playerUiState.progress + } + } + + LaunchedEffect(playerUiState.volume) { + if (playerUiState.volume > 0f) { + lastNonZeroVolume = playerUiState.volume + } + } + + fun onPlayPause() { + scope.launch { + if (playerUiState.isPlaying) { + audioPlayer.pause() + } else { + audioPlayer.play() + } + } + } + + fun onSkipPrevious() { + if (isJamGuest) return + scope.launch { audioPlayer.skipToPrevious() } + } + + fun onSkipNext() { + if (isJamGuest) return + scope.launch { audioPlayer.skipToNext() } + } + + fun onShuffleToggle() { + if (isJamGuest) return + scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } + } + + fun onLoopToggle() { + if (isJamGuest) return + scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } + } + + fun onSeekFinished() { + val durationMillis = playerUiState.seekDuration.inWholeMilliseconds + if (durationMillis <= 0L) return + scope.launch { + audioPlayer.seekTo( + (durationMillis * seekProgress.coerceIn( + 0f, + 1f + )).toLong().milliseconds + ) + } + } + + // A translucent tint (not near-opaque) so the blurred art behind still reads through, and + // the material's watery caustics sit on top of it. + val surfaceTint = AppTheme.colors.surface.copy(alpha = 0.6f) + + Column( + modifier = modifier + .windowInsetsPadding(WindowInsets.navigationBars) + ) { + MillenniumSeparator(modifier = Modifier.fillMaxWidth()) + Box( + modifier = Modifier + .fillMaxWidth() + .hazeEffect(state = hazeState) { + blurEffect { + blurRadius = 20.dp + colorEffects = listOf( + HazeColorEffect.tint(surfaceTint) + ) + } + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(12.dp)) + .background(AppTheme.colors.surfaceContainerHighest) + ) { + AsyncImage( + model = coverModel, + contentDescription = playerUiState.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + Column( + modifier = Modifier + .weight(1f, fill = false) + .padding(start = 12.dp) + ) { + Text( + text = playerUiState.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + PlayerArtistsText( + artists = playerUiState.artistList, + onArtistClick = { artistId -> + navigationCommands.navigateTo(Routes.Artist(artistId)) + }, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(10.dp)) + PlayerHeartButton( + audioPlayerQueue = audioPlayerQueue, + savedTracksViewModel = savedTracksViewModel + ) + } + + Column( + modifier = Modifier.weight(1.1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + MillenniumSlider( + value = seekProgress, + onValueChange = { + seekProgress = it + isSeeking = true + }, + onValueChangeFinished = { + isSeeking = false + onSeekFinished() + }, + enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L, + label = stringResource(Res.string.player_seek_label), + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatPlayerTime(playerUiState.position), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + Text( + text = formatPlayerTime(playerUiState.displayDuration), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + MillenniumIconButton( + onClick = ::onShuffleToggle, + enabled = !isJamGuest, + style = if (playerUiState.isShuffling) MillenniumIconButtonStyle.Secondary else MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxShuffle, + contentDescription = stringResource(if (playerUiState.isShuffling) Res.string.player_disable_shuffle_content_description else Res.string.player_enable_shuffle_content_description), + tint = if (playerUiState.isShuffling) { + AppTheme.colors.primary + } else { + AppTheme.colors.onSurfaceVariant + } + ) + } + MillenniumIconButton( + onClick = ::onSkipPrevious, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = stringResource(Res.string.player_previous_content_description)) + } + MillenniumIconButton( + onClick = ::onPlayPause, + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_or_pause_content_description), + tint = LocalTextStyle.current.color + ) + } + MillenniumIconButton( + onClick = ::onSkipNext, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon(Iconsax.IconsaxNext, contentDescription = stringResource(Res.string.player_next_content_description)) + } + MillenniumIconButton( + onClick = ::onLoopToggle, + enabled = !isJamGuest, + style = if (playerUiState.loopState == LoopState.NONE) MillenniumIconButtonStyle.Quiet else MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + imageVector = when (playerUiState.loopState) { + LoopState.NONE -> Iconsax.IconsaxRepeateMusic + LoopState.ONE -> Iconsax.IconsaxRepeateOne + LoopState.ALL -> Iconsax.IconsaxRepeatMusic + }, + contentDescription = stringResource(when (playerUiState.loopState) { LoopState.NONE -> Res.string.player_loop_mode_none; LoopState.ONE -> Res.string.player_loop_mode_one; LoopState.ALL -> Res.string.player_loop_mode_all }), + tint = if (playerUiState.loopState == LoopState.NONE) { + AppTheme.colors.onSurfaceVariant + } else { + AppTheme.colors.primary + } + ) + } + } + } + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.End + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = onQueue + ) { + MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = stringResource(Res.string.queue_title)) + } + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, + onClick = { + val track = (currentEntry as? QueueEntry.StreamingTrack)?.track + if (track != null) { + downloadsViewModel.downloadTrack(track) + } + }) { + DownloadProgressIcon( + track = (currentEntry as? QueueEntry.StreamingTrack)?.track, + icon = Iconsax.IconsaxDirectboxReceive, + contentDescription = stringResource(Res.string.player_download) + ) + } + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = onAlternativeSource + ) { + MillenniumIcon( + Iconsax.SwapHorizontal2, + contentDescription = stringResource(Res.string.player_alternative_source_content_description) + ) + } + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = onLyrics + ) { + MillenniumIcon(Iconsax.IconsaxMusic, contentDescription = stringResource(Res.string.player_lyrics)) + } + val goToAlbumLabel = stringResource(Res.string.player_go_to_album) + val sleepTimerLabel = stringResource(Res.string.player_sleep_timer) + val trackDetailsLabel = stringResource(Res.string.player_track_details) + MillenniumDropdownMenu( + expanded = playerOptionsUiState.isMoreOptionsMenuOpen, + onExpandedChange = { expanded -> + if (!expanded) playerOptionsViewModel.dismissMoreOptionsMenu() + }, + side = AnchorSide.Top, + anchor = { + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, + onClick = { + playerOptionsViewModel.showMoreOptionsMenu() + onMoreOptions() + } + ) { + MillenniumIcon( + Iconsax.Iconsax3DotsMore, + contentDescription = stringResource(Res.string.player_more_options_content_description) + ) + } + }) { + MillenniumDropdownMenuItem( + label = goToAlbumLabel, + icon = Iconsax.IconsaxCd, + onClick = { + playerOptionsViewModel.dismissMoreOptionsMenu() + currentTrack?.album?.id?.let { albumId -> + navigationCommands.navigateTo(Routes.Album(albumId)) + } + }) + MillenniumDropdownMenuItem( + label = sleepTimerLabel, + icon = Iconsax.IconsaxMoon, + onClick = { + playerOptionsViewModel.dismissMoreOptionsMenu() + playerOptionsViewModel.showSleepTimerDialog() + }) + MillenniumDropdownMenuItem( + label = trackDetailsLabel, + icon = Iconsax.IconsaxInformation, + onClick = { + playerOptionsViewModel.dismissMoreOptionsMenu() + playerOptionsViewModel.showTrackDetails() + }) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, + onClick = { + scope.launch { + if (playerUiState.volume <= 0f) { + audioPlayer.setVolume(lastNonZeroVolume) + } else { + lastNonZeroVolume = playerUiState.volume + audioPlayer.setVolume(0f) + } + } + } + ) { + val volumeIcon = when { + playerUiState.volume <= 0f -> Iconsax.IconsaxVolumeCross + playerUiState.volume < 0.5f -> Iconsax.IconsaxVolumeLow + else -> Iconsax.IconsaxVolumeHigh + } + MillenniumIcon( + imageVector = volumeIcon, + contentDescription = stringResource(if (playerUiState.volume <= 0f) Res.string.player_unmute_content_description else Res.string.player_mute_content_description) + ) + } + MillenniumSlider( + value = playerUiState.volume, + onValueChange = { + lastNonZeroVolume = it + scope.launch { + audioPlayer.setVolume(it) + } + }, + label = stringResource(Res.string.player_volume_label), + modifier = Modifier.fillMaxWidth(0.62f) + ) + } + } + } + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt new file mode 100644 index 00000000..fec5626a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_fallback_artists +import spotube.composeapp.generated.resources.player_fallback_title +import spotube.composeapp.generated.resources.player_unknown_album + +internal data class PlayerArtist( + val id: String?, + val name: String +) + +internal data class PlayerUiState( + val queue: List = emptyList(), + val currentQueueEntry: QueueEntry? = null, + val title: String = "No-op track title", + val artists: String = "No-op artists", + val artistList: List = emptyList(), + val album: String = "", + val position: Duration = 0.milliseconds, + val actualDuration: Duration = 0.milliseconds, + val metadataDuration: Duration = 0.milliseconds, + val playerState: PlayerState = PlayerState.IDLE, + val loopState: LoopState = LoopState.NONE, + val isShuffling: Boolean = false, + val volume: Float = 0f +) { + val isPlaying: Boolean get() = playerState == PlayerState.PLAYING + + val coverModel: Any? get() = currentQueueEntry?.artworkModel + val displayDuration: Duration + get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration + val seekDuration: Duration + get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else Duration.ZERO + val progress: Float + get() = if (seekDuration.inWholeMilliseconds > 0L) { + (position.inWholeMilliseconds.toFloat() / seekDuration.inWholeMilliseconds.toFloat()) + .coerceIn(0f, 1f) + } else { + 0f + } + + fun toDebugString(): String { + return """PlayerUiState( + queue=${queue.map { it.displayTitle() }}, + currentQueueEntry=${currentQueueEntry?.displayTitle()}, + title='$title', artists='$artists', + album='$album', coverModel=${coverModel?.let { it::class.simpleName }}, + position=$position, actualDuration=$actualDuration, metadataDuration=$metadataDuration, + playerState=$playerState, loopState=$loopState, isShuffling=$isShuffling, volume=$volume + ) + """.trimIndent() + } +} + +@Composable +internal fun rememberPlayerUiState( + audioPlayer: AudioPlayerInterface, + audioPlayerQueue: AudioPlayerQueue +): PlayerUiState { + val queue by audioPlayerQueue.queueFlow.collectAsState(initial = emptyList()) + val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsState(initial = null) + val playerState by audioPlayer.playerStateFlow.collectAsState() + val position by audioPlayer.positionFlow.collectAsState() + val duration by audioPlayer.durationFlow.collectAsState() + val loopState by audioPlayer.loopStateFlow.collectAsState() + val isShuffling by audioPlayer.shuffleModeFlow.collectAsState() + val volume by audioPlayer.volumeFlow.collectAsState() + val metadataDuration = (currentQueueEntry?.durationInMilliseconds() ?: 0L).milliseconds + val fallbackTitle = stringResource(Res.string.player_fallback_title) + val fallbackArtists = stringResource(Res.string.player_fallback_artists) + val unknownAlbum = stringResource(Res.string.player_unknown_album) + + val state = PlayerUiState( + queue = queue, + currentQueueEntry = currentQueueEntry, + title = currentQueueEntry?.displayTitle().orEmpty().ifBlank { fallbackTitle }, + artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { fallbackArtists }, + artistList = currentQueueEntry?.displayArtistList().orEmpty(), + album = currentQueueEntry?.displayAlbum(unknownAlbum).orEmpty(), + position = position, + actualDuration = duration, + metadataDuration = metadataDuration, + playerState = playerState, + loopState = loopState, + isShuffling = isShuffling, + volume = volume + ) + +// val logger = rememberLogger() +// logger.d { state.toDebugString() } + return state +} + +private fun QueueEntry.displayTitle(): String { + return when (this) { + is QueueEntry.StreamingTrack -> track.title + is QueueEntry.LocalTrack -> name + } +} + +private fun QueueEntry.displayArtists(): String { + return when (this) { + is QueueEntry.StreamingTrack -> track.artists.joinToString(", ") { artist -> artist.name } + is QueueEntry.LocalTrack -> artists.joinToString(", ") + } +} + +private fun QueueEntry.displayArtistList(): List { + return when (this) { + is QueueEntry.StreamingTrack -> track.artists.map { PlayerArtist(it.id, it.name) } + // Local tracks have no artist ids, so they are shown but not navigable. + is QueueEntry.LocalTrack -> artists.map { PlayerArtist(id = null, name = it) } + } +} + +private fun QueueEntry.displayAlbum(unknownAlbum: String): String { + return when (this) { + is QueueEntry.StreamingTrack -> track.album?.title ?: unknownAlbum + is QueueEntry.LocalTrack -> album.orEmpty() + } +} + +private fun QueueEntry.durationInMilliseconds(): Long { + return when (this) { + is QueueEntry.StreamingTrack -> track.durationMs + is QueueEntry.LocalTrack -> duration + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt new file mode 100644 index 00000000..484fe48b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt @@ -0,0 +1,388 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.composeunstyled.Sheet +import com.composeunstyled.SheetDetent +import com.composeunstyled.UnstyledBottomSheet +import com.composeunstyled.rememberBottomSheetState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.NavigationState +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.remote.ConnectionRequestDialogHost +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.modules.devices.PlayDestinationPickerHost +import org.spotube.spotube.modules.lyrics.LyricsScreen +import org.spotube.spotube.modules.shell.alternative_track.AlternativeTrackContent +import org.spotube.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel +import org.spotube.spotube.modules.shell.alternative_track.AlternativeTrackSheet +import org.spotube.spotube.modules.shell.player_queue.PlayerQueueContent +import org.spotube.spotube.modules.shell.player_queue.PlayerQueueContentViewModel +import org.spotube.spotube.modules.shell.player_queue.QueueSheet +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSnackbarHost +import org.spotube.millennium.composables.MillenniumSnackbarHostState +import org.spotube.millennium.theme.MillenniumTheme + +val LocalAppShellBottomInset = staticCompositionLocalOf { 0.dp } + +@Composable +fun AppShell( + navigator: Navigator, + navigationState: NavigationState, + viewModel: AppShellViewModel = koinViewModel(), + queueViewModel: PlayerQueueContentViewModel = koinViewModel(), + alternativeViewModel: AlternativeTrackContentViewModel = koinViewModel(), + content: @Composable () -> Unit +) { + val navigatorCommands: NavigationCommands = koinInject() + val remotePlaybackController: RemotePlaybackController = koinInject() + val snackbarHostState = remember { MillenniumSnackbarHostState() } + LaunchedEffect(remotePlaybackController) { + remotePlaybackController.events.collect { message -> + snackbarHostState.showSnackbar(message) + } + } + val isQueueVisible by queueViewModel.isQueueVisible.collectAsState() + val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState() + val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState() + + LaunchedEffect(navigatorCommands, navigator) { + launch { + navigatorCommands.navigationCommandFlow.collect { route -> + navigator.navigate(route) + } + } + launch { + navigatorCommands.navigationPopCommandFlow.collect { route -> + navigationState.backStacks[navigationState.topLevelRoute]?.let { backStack -> + backStack.lastOrNull()?.let { currentRoute -> + if (route == null || route == currentRoute) { + navigator.pop() + } + } + } + } + } + } + + // Onboarding is a bootstrap, full-screen destination. It draws its own app bar (and thus + // the desktop window controls), so render it without the sidebar, players or bottom bar. + if (navigationState.topLevelRoute == Routes.Welcome) { + content() + return + } + + ConnectionRequestDialogHost() + PlayDestinationPickerHost() + + Box(modifier = Modifier.fillMaxSize()) { + val useSidebar = viewModel.useSidebar() + val chromeUiState by viewModel.compactChromeUiState.collectAsState() + val density = LocalDensity.current + val navigationBarInset = with(density) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + val bottomOverlayInset = + viewModel.bottomOverlayInset(useSidebar, navigationBarInset, chromeUiState) + + CompositionLocalProvider(LocalAppShellBottomInset provides bottomOverlayInset) { + if (useSidebar) { + val hazeState = rememberHazeState() + val bgHazeState = rememberHazeState() + + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) + .hazeSource(state = bgHazeState) // <-- Put it here! + ) + Column(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.weight(1f)) { + AppSidebar( + navigator = navigator, + navigationState = navigationState + ) + MillenniumSeparator( + modifier = Modifier.fillMaxHeight(), + vertical = true + ) + Box( + modifier = Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState) + ) { + content() + } + } + AppLargePlayer( + hazeState = hazeState, + modifier = Modifier.fillMaxWidth(), + onQueue = queueViewModel::toggleQueueVisibility, + onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, + onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) }) + } + + if (isQueueVisible) { + Box( + modifier = Modifier.fillMaxSize().clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { queueViewModel.setQueueVisibility(false) }) + ) + } + + if (isAlternativeVisible) { + Box( + modifier = Modifier.fillMaxSize().clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { alternativeViewModel.setAlternativeVisibility(false) }) + ) + } + + QueueSheet( + isVisible = isQueueVisible, + onDismiss = { queueViewModel.setQueueVisibility(false) }, + modifier = Modifier.fillMaxSize() + ) { + PlayerQueueContent( + viewModel = queueViewModel, + modifier = Modifier.fillMaxSize() + ) + } + + AlternativeTrackSheet( + isVisible = isAlternativeVisible, + onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, + modifier = Modifier.fillMaxSize() + ) { + AlternativeTrackContent( + viewModel = alternativeViewModel, + modifier = Modifier.fillMaxSize() + ) + } + } + } else { + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) + ) + CompactPlayerOverlay( + navigator = navigator, + navigationState = navigationState, + viewModel = viewModel, + onQueue = queueViewModel::toggleQueueVisibility, + onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, + onExpandLyrics = viewModel::showLyricsOverlay, + content = content + ) + + QueueSheet( + isVisible = isQueueVisible, + onDismiss = { queueViewModel.setQueueVisibility(false) }, + modifier = Modifier.fillMaxSize() + ) { + PlayerQueueContent( + viewModel = queueViewModel, + modifier = Modifier.fillMaxSize() + ) + } + + AlternativeTrackSheet( + isVisible = isAlternativeVisible, + onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, + modifier = Modifier.fillMaxSize() + ) { + AlternativeTrackContent( + viewModel = alternativeViewModel, + modifier = Modifier.fillMaxSize() + ) + } + + if (isLyricsOverlayVisible) { + Dialog( + onDismissRequest = { viewModel.hideLyricsOverlay() }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + Box( + modifier = Modifier.fillMaxSize() + .background(MillenniumTheme.colors.background) + ) { + LyricsScreen( + modifier = Modifier.fillMaxSize(), + onClose = { viewModel.hideLyricsOverlay() }) + } + } + } + } + } + } + + // Drawn last so it floats above the players/sheets, just above the + // bottom overlay (large player or compact player + bottombar). + MillenniumSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = bottomOverlayInset + 12.dp) + ) + } +} + +@OptIn( + ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class +) +@Composable +internal fun CompactPlayerOverlay( + navigator: Navigator, + navigationState: NavigationState, + viewModel: AppShellViewModel, + onQueue: () -> Unit, + onAlternativeSource: () -> Unit, + onExpandLyrics: () -> Unit, + floatingPlayer: @Composable (Modifier) -> Unit = { AppFloatingPlayer(modifier = it) }, + bottomBar: @Composable (Modifier) -> Unit = { + AppBottombar(navigator = navigator, navigationState = navigationState, modifier = it) + }, + content: @Composable () -> Unit +) { + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val chromeUiState by viewModel.compactChromeUiState.collectAsState() + + val floatingPlayerHeightPx = with(density) { viewModel.floatingPlayerHeight.toPx() } + val navBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat() + val bottomBarHeightPx = with(density) { + chromeUiState.measuredBottomBarHeight?.toPx() + ?: (viewModel.bottomBarHeight.toPx() + navBarInsetPx) + } + val peekHeightPx = floatingPlayerHeightPx + bottomBarHeightPx + val peekHeight = with(density) { peekHeightPx.toDp() } + + val partialDetent = remember(peekHeight) { + SheetDetent("player-peek") { _, _ -> peekHeight } + } + // The library remembers its detent definitions at creation. Recreate the state when + // the measured chrome height changes, rather than keeping a stale peek anchor. + val sheetState = key(peekHeight) { + rememberBottomSheetState( + initialDetent = partialDetent, + detents = listOf(partialDetent, SheetDetent.FullyExpanded) + ) + } + + val progress = if (!sheetState.offset.isFinite() || + (sheetState.isIdle && sheetState.currentDetent == partialDetent) + ) 0f else runCatching { + sheetState.progress(partialDetent, SheetDetent.FullyExpanded) + }.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f + val uiState = viewModel.compactSheetUiState(progress.coerceIn(0f, 1f)) + + Box(modifier = Modifier.fillMaxSize()) { + content() + + UnstyledBottomSheet( + state = sheetState, + modifier = Modifier.fillMaxSize() + ) { + Sheet(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { + if (uiState.showExpandedPlayer) { + AppExpandedPlayer( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = uiState.expandedPlayerAlpha }, + onCollapse = { + scope.launch { sheetState.animateTo(partialDetent) } + }, + onQueue = onQueue, + onAlternativeSource = onAlternativeSource, + onExpandLyrics = onExpandLyrics + ) + } + + if (uiState.floatingPlayerAlpha > 0.01f) { + Column(modifier = Modifier.fillMaxWidth()) { + floatingPlayer( + Modifier + .graphicsLayer { + alpha = uiState.floatingPlayerAlpha + }) + } + } + } + } + } + + if (uiState.floatingPlayerAlpha > 0.01f) { + bottomBar( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .onSizeChanged { size -> + viewModel.onBottomBarMeasured(with(density) { size.height.toDp() }) + } + .graphicsLayer { + alpha = uiState.floatingPlayerAlpha + }) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShellViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShellViewModel.kt new file mode 100644 index 00000000..482acbbf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShellViewModel.kt @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + + +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.runtime.Composable +import androidx.lifecycle.ViewModel +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlin.math.min + +data class CompactSheetUiState( + val progress: Float, + val showExpandedPlayer: Boolean, + val expandedPlayerAlpha: Float, + val floatingPlayerAlpha: Float, + val bottomBarAlpha: Float +) + +data class LyricsOverlayState( + val isVisible: Boolean = false +) + +data class CompactChromeUiState(val measuredBottomBarHeight: Dp? = null) + +class AppShellViewModel : ViewModel() { + val sidebarMinWidth = 840.dp + val bottomBarHeight = 80.dp + // The background bleed overlaps the bottom bar and is not part of the peek height. + val floatingPlayerHeight = FloatingPlayerContentHeight + val largePlayerInset = 100.dp + val floatingPlayerDismissProgress = 0.7f + val expandedPlayerVisibilityThreshold = 0.001f + + private val lyricsOverlayVisible = MutableStateFlow(false) + private val compactChrome = MutableStateFlow(CompactChromeUiState()) + val compactChromeUiState = compactChrome.asStateFlow() + + fun onBottomBarMeasured(height: Dp) { + if (height > 0.dp) compactChrome.value = CompactChromeUiState(height) + } + + val isLyricsOverlayVisible: StateFlow = lyricsOverlayVisible.asStateFlow() + + @Composable + fun useSidebar(maxWidth: Dp? = null): Boolean { + if (maxWidth != null) { + return maxWidth >= sidebarMinWidth + } + return currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded + } + + fun bottomOverlayInset( + useSidebar: Boolean, + navigationBarInset: Dp, + chrome: CompactChromeUiState + ): Dp = + if (useSidebar) largePlayerInset + navigationBarInset + else (chrome.measuredBottomBarHeight ?: (bottomBarHeight + navigationBarInset)) + + floatingPlayerHeight + + fun sheetProgress(sheetOffsetPx: Float, sheetHeightPx: Float): Float = + if (sheetHeightPx > 0f) { + (sheetOffsetPx / sheetHeightPx).coerceIn(0f, 1f) + } else { + 0f + } + + fun floatingPlayerAlpha(progress: Float): Float { + val normalized = min(progress / floatingPlayerDismissProgress, 1f) + return 1f - normalized + } + + fun compactSheetUiState(progress: Float): CompactSheetUiState { + return CompactSheetUiState( + progress = progress, + showExpandedPlayer = progress > expandedPlayerVisibilityThreshold, + expandedPlayerAlpha = progress, + floatingPlayerAlpha = floatingPlayerAlpha(progress), + bottomBarAlpha = 1f - progress + ) + } + + fun showLyricsOverlay() { + lyricsOverlayVisible.value = true + } + + fun hideLyricsOverlay() { + lyricsOverlayVisible.value = false + } + + fun toggleLyricsOverlay() { + lyricsOverlayVisible.update { !it } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt new file mode 100644 index 00000000..c28cff4b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt @@ -0,0 +1,277 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.LocalTextStyle +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.LocalMillenniumSidebar +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumControlState +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import org.spotube.spotube.NavigationItem +import org.spotube.spotube.core.navigation.NavigationState +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab +import org.spotube.spotube.modules.support.LocalSupportPrompt +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.resources.iconsax.IconsaxSetting2 +import org.spotube.spotube.resources.iconsax.IconsaxSidebarLeftBroken +import org.spotube.spotube.resources.iconsax.IconsaxSidebarRightBroken +import org.spotube.spotube.sidebarTabs +import org.jetbrains.compose.resources.Font +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSidebar +import org.spotube.millennium.composables.MillenniumSidebarContent +import org.spotube.millennium.composables.MillenniumSidebarFooter +import org.spotube.millennium.composables.MillenniumSidebarGroup +import org.spotube.millennium.composables.MillenniumSidebarGroupContent +import org.spotube.millennium.composables.MillenniumSidebarGroupLabel +import org.spotube.millennium.composables.MillenniumSidebarHeader +import org.spotube.millennium.composables.MillenniumSidebarItem +import org.spotube.millennium.composables.MillenniumSidebarMenu +import org.spotube.millennium.composables.MillenniumSidebarTrigger +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.cookie_regular +import spotube.composeapp.generated.resources.settings_screen_title +import spotube.composeapp.generated.resources.support_button_label +import spotube.composeapp.generated.resources.shell_sidebar_toggle_content_description + +@Composable +fun AppSidebar( + navigator: Navigator, + navigationState: NavigationState, + modifier: Modifier = Modifier, + libraryState: LibraryState = koinInject(), + connectStatusViewModel: ConnectStatusViewModel = koinViewModel() +) { + val connectStatus by connectStatusViewModel.uiState.collectAsStateWithLifecycle() + val remoteStatus = connectStatus.remote?.let { remoteStatusLabel(it) } + val jamStatus = connectStatus.jam?.let { jamStatusLabel(it) } + var expanded by rememberSaveable { mutableStateOf(true) } + val groupExpansion = remember { mutableStateMapOf() } + val currentLibraryTab by libraryState.currentTab.collectAsState() + val openSupport = LocalSupportPrompt.current + + MillenniumSidebar( + modifier = modifier, + collapsed = !expanded, + shape = RectangleShape, + onCollapsedChange = { expanded = !it }, + ) { + MillenniumSidebarHeader { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { + Text( + text = "Spotube", + style = AppTheme.typography.headlineLarge.copy( + fontFamily = FontFamily( + listOf(Font(Res.font.cookie_regular, weight = FontWeight.Normal)) + ) + ), + color = AppTheme.colors.onSurface + ) + } + MillenniumSidebarTrigger { + MillenniumIconButton( + if (it.collapsed) Iconsax.IconsaxSidebarRightBroken else Iconsax.IconsaxSidebarLeftBroken, + contentDescription = stringResource(Res.string.shell_sidebar_toggle_content_description), + style = MillenniumIconButtonStyle.Quiet, + onClick = { it.toggle() } + ) + } + } + } + MillenniumSidebarContent( + contentSpacing = 0.dp + ) { + sidebarTabs.forEach { entry -> + when (entry) { + is NavigationItem.Group<*> -> { + val isGroupExpanded = groupExpansion[entry.title.key] ?: true + MillenniumSidebarGroup( + expanded = isGroupExpanded, + collapsible = true, + groupSpacing = 0.dp, + onExpandedChange = { groupExpansion[entry.title.key] = it }) { + MillenniumSidebarGroupLabel(stringResource(entry.title)) + MillenniumSidebarGroupContent( + contentSpacing = 0.dp + ) { + MillenniumSidebarMenu(contentSpacing = 0.dp) { + entry.items.forEach { item -> + val selected = + navigationState.topLevelRoute == item.route && + (item.route != Routes.Library || currentLibraryTab == item.data) + SidebarNavItem( + item = item, + selected = selected, + status = when (item.route) { + Routes.Devices -> remoteStatus + Routes.Jam -> jamStatus + else -> null + }, + onClick = { + if (item.route == Routes.Library) { + (item.data as? LibraryTab)?.let { + libraryState.currentTab.value = it + } + } + navigator.navigate(item.route) + }) + } + } + } + } + } + + is NavigationItem.Tab<*> -> { + SidebarNavItem( + item = entry, + selected = navigationState.topLevelRoute == entry.route, + onClick = { navigator.navigate(entry.route) }) + } + } + } + } + // Compact the footer chrome only (padding + gap between items); items keep their size. + MillenniumSidebarFooter( + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 4.dp), + contentSpacing = 2.dp + ) { + val supportLabel = stringResource(Res.string.support_button_label) + MillenniumSidebarItem( + label = supportLabel, + onClick = { openSupport() }, + contentDescription = supportLabel, + icon = Iconsax.IconsaxHeart2 + ) + MillenniumSidebarItem( + label = stringResource(Res.string.settings_screen_title), + onClick = { navigator.navigate(Routes.Settings) }, + selected = navigationState.topLevelRoute == Routes.Settings, + contentDescription = stringResource(Res.string.settings_screen_title), + icon = Iconsax.IconsaxSetting2 + ) + } + } +} + +@Composable +private fun SidebarNavItem( + item: NavigationItem.Tab<*>, + selected: Boolean, + onClick: () -> Unit, + /** Live status (e.g. "Playing on Krtx"); when set the row gets a dot and a second line. */ + status: String? = null +) { + val title = stringResource(item.title) + if (status != null) { + LiveSidebarNavItem(item, title, status, selected, onClick) + return + } + MillenniumSidebarItem( + label = title, + onClick = onClick, + selected = selected, + contentDescription = title, + icon = item.icon + ) +} + +@Composable +private fun LiveSidebarNavItem( + item: NavigationItem.Tab<*>, + title: String, + status: String, + selected: Boolean, + onClick: () -> Unit +) { + MillenniumSidebarItem( + onClick = onClick, + selected = selected, + contentDescription = "$title, $status" + ) { + // Match the text overload's icon metrics so live rows line up with their siblings. + val metrics = MillenniumTheme.sidebarTheme.item.resolve(MillenniumControlState.Default) + val contentColor = LocalTextStyle.current.color + Box { + MillenniumIcon( + imageVector = item.icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(metrics.iconSize) + ) + LiveDot(Modifier.align(Alignment.TopEnd).offset(x = 3.dp, y = (-3).dp)) + } + if (LocalMillenniumSidebar.current?.collapsed != true) { + Spacer(Modifier.width(metrics.iconLabelSpacing)) + Column(Modifier.weight(1f)) { + Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = status, + style = AppTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusBanner.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusBanner.kt new file mode 100644 index 00000000..b2c14807 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusBanner.kt @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.spotube.core.ui.theming.AppTheme +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.connect_jam_hosting +import spotube.composeapp.generated.resources.connect_jam_joined +import spotube.composeapp.generated.resources.devices_remote_playing_on +import spotube.composeapp.generated.resources.devices_status_connected + +/** + * Slim "something is live" strip under the Home header: one row while remote-controlling a device, + * one while in a Group Jam. Renders nothing when neither is active. + */ +@Composable +fun ConnectStatusBanner( + onOpenRemote: () -> Unit, + onOpenJam: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ConnectStatusViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val remote = state.remote + val jam = state.jam + if (remote == null && jam == null) return + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (remote != null) StatusRow(remoteStatusLabel(remote), onOpenRemote) + if (jam != null) StatusRow(jamStatusLabel(jam), onOpenJam) + } +} + +@Composable +private fun StatusRow(label: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(AppTheme.shapes.medium) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + LiveDot() + Text( + text = label, + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + MillenniumIcon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } +} + +@Composable +internal fun remoteStatusLabel(status: RemoteConnectStatus): String = + status.deviceName?.let { stringResource(Res.string.devices_remote_playing_on, it) } + ?: stringResource(Res.string.devices_status_connected) + +@Composable +internal fun jamStatusLabel(status: JamConnectStatus): String = pluralStringResource( + if (status.isHost) Res.plurals.connect_jam_hosting else Res.plurals.connect_jam_joined, + status.listenerCount, + status.listenerCount) + +/** Small "something is live" marker, ringed so it reads on any background. */ +@Composable +internal fun LiveDot(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(10.dp) + .background(MillenniumTheme.colors.success, CircleShape) + .border(2.dp, MillenniumTheme.colors.surface, CircleShape) + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusViewModel.kt new file mode 100644 index 00000000..c83abac5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusViewModel.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.remote.RemoteControlClient + +/** Set while this app is remote-controlling another device. */ +data class RemoteConnectStatus(val deviceName: String?) + +/** Set while this app is in a Group Jam room. */ +data class JamConnectStatus(val isHost: Boolean, val listenerCount: Int) + +data class ConnectStatusUiState( + val remote: RemoteConnectStatus? = null, + val jam: JamConnectStatus? = null) + +/** Live remote-control / Group Jam status shown by the Home header and the sidebar. */ +class ConnectStatusViewModel( + remoteControlClient: RemoteControlClient, + jamRoomService: JamRoomService +) : ViewModel() { + val uiState: StateFlow = combine( + remoteControlClient.connectionState, + jamRoomService.role, + jamRoomService.participants + ) { connection, role, participants -> + ConnectStatusUiState( + remote = (connection as? ConnectionState.Connected)?.let { + RemoteConnectStatus(it.deviceName.ifBlank { null }) + }, + jam = role?.let { JamConnectStatus(isHost = it == JamRole.Host, listenerCount = participants.size) }) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ConnectStatusUiState()) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerArtistsText.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerArtistsText.kt new file mode 100644 index 00000000..56c1f042 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerArtistsText.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.theming.AppTheme + +/** + * Renders a track's artists on a single, ellipsized line. Artists that have an [PlayerArtist.id] + * are tappable links that open their artist page; local tracks (no ids) render as plain text. + */ +@Composable +internal fun PlayerArtistsText( + artists: List, + onArtistClick: (String) -> Unit, + style: TextStyle, + color: Color, + modifier: Modifier = Modifier, + textAlign: TextAlign = TextAlign.Unspecified) { + if (artists.isEmpty()) return + val hoverColor = AppTheme.colors.primary + val annotatedArtists = buildAnnotatedString { + artists.forEachIndexed { index, artist -> + if (index > 0) append(", ") + val artistId = artist.id + if (artistId == null) { + append(artist.name) + } else { + withLink( + LinkAnnotation.Clickable( + tag = artistId, + styles = TextLinkStyles( + style = SpanStyle(textDecoration = TextDecoration.Underline), + hoveredStyle = SpanStyle( + color = hoverColor, + textDecoration = TextDecoration.Underline), + ), + linkInteractionListener = { onArtistClick(artistId) }, + ) + ) { + append(artist.name) + } + } + } + } + Text( + text = annotatedArtists, + modifier = modifier, + style = style, + color = color, + textAlign = textAlign, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt new file mode 100644 index 00000000..732a4d0a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.foundation.shape.CircleShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.saved_tracks.SavedState +import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel +import org.spotube.spotube.modules.saved_tracks.rememberIsSavedTracks +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_like_content_description +import spotube.composeapp.generated.resources.player_unlike_content_description + +@Composable +fun PlayerHeartButton( + audioPlayerQueue: AudioPlayerQueue, + savedTracksViewModel: SavedTracksViewModel) { + val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() + val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id + val isSavedTrackState = + rememberIsSavedTracks(trackIds = currentTrackId?.let { listOf(it) } ?: emptyList()) + val savedTrackIds by savedTracksViewModel.savedTrackIdsFlow.collectAsStateWithLifecycle() + val pendingSavedTrackIds by savedTracksViewModel.pendingSavedTrackIds.collectAsStateWithLifecycle() + + val isInSavedIds = if (currentTrackId != null) currentTrackId in savedTrackIds else false + val isLiked = if (isSavedTrackState is SavedState.Success) { + isSavedTrackState.data.firstOrNull() == true || isInSavedIds + } else { + isInSavedIds + } + + MillenniumIconButton( + onClick = { currentTrackId?.let { savedTracksViewModel.toggleSavedTrack(it, isLiked) } }, + enabled = isSavedTrackState is SavedState.Success, + loading = currentTrackId != null && currentTrackId in pendingSavedTrackIds, + style = MillenniumIconButtonStyle.Secondary) { + MillenniumIcon( + imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, + contentDescription = stringResource(if (isLiked) Res.string.player_unlike_content_description else Res.string.player_like_content_description), + tint = if (isLiked) AppTheme.colors.primary else AppTheme.colors.onSurfaceVariant) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionDialogs.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionDialogs.kt new file mode 100644 index 00000000..96cd9ca0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionDialogs.kt @@ -0,0 +1,434 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + + +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogFooter +import org.spotube.millennium.composables.MillenniumMarqueeProgress +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.core.server.CacheEntry +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_tab_artists +import spotube.composeapp.generated.resources.media_artist +import spotube.composeapp.generated.resources.player_sleep_timer +import spotube.composeapp.generated.resources.player_sleep_timer_cancel +import spotube.composeapp.generated.resources.player_sleep_timer_description +import spotube.composeapp.generated.resources.player_sleep_timer_minutes_label +import spotube.composeapp.generated.resources.player_sleep_timer_minutes_placeholder +import spotube.composeapp.generated.resources.player_sleep_timer_remaining +import spotube.composeapp.generated.resources.player_sleep_timer_start +import spotube.composeapp.generated.resources.player_track_details +import spotube.composeapp.generated.resources.player_track_details_active_url +import spotube.composeapp.generated.resources.player_track_details_album +import spotube.composeapp.generated.resources.player_track_details_album_genres +import spotube.composeapp.generated.resources.player_track_details_album_id +import spotube.composeapp.generated.resources.player_track_details_album_release_date +import spotube.composeapp.generated.resources.player_track_details_album_track_count +import spotube.composeapp.generated.resources.player_track_details_album_type +import spotube.composeapp.generated.resources.player_track_details_available_streams +import spotube.composeapp.generated.resources.player_track_details_confidence +import spotube.composeapp.generated.resources.player_track_details_disc_number +import spotube.composeapp.generated.resources.player_track_details_duration +import spotube.composeapp.generated.resources.player_track_details_explicit +import spotube.composeapp.generated.resources.player_track_details_external_uri +import spotube.composeapp.generated.resources.player_track_details_id +import spotube.composeapp.generated.resources.player_track_details_isrc +import spotube.composeapp.generated.resources.player_track_details_loading_source +import spotube.composeapp.generated.resources.player_track_details_loading_streams +import spotube.composeapp.generated.resources.player_track_details_matched_source +import spotube.composeapp.generated.resources.player_track_details_matches_preference +import spotube.composeapp.generated.resources.player_track_details_metadata_track +import spotube.composeapp.generated.resources.player_track_details_no_source +import spotube.composeapp.generated.resources.player_track_details_no_streams +import spotube.composeapp.generated.resources.player_track_details_popularity +import spotube.composeapp.generated.resources.player_track_details_selected_stream_content_description +import spotube.composeapp.generated.resources.player_track_details_served_from_cache +import spotube.composeapp.generated.resources.player_track_details_served_from_cache_description +import spotube.composeapp.generated.resources.player_track_details_source_not_needed +import spotube.composeapp.generated.resources.player_track_details_stream_mismatch +import spotube.composeapp.generated.resources.player_track_details_thumbnail_urls +import spotube.composeapp.generated.resources.player_track_details_title +import spotube.composeapp.generated.resources.player_track_details_track_number +import spotube.composeapp.generated.resources.common_close + +@Composable +fun PlayerOptionDialogs(viewModel: PlayerOptionsViewModel) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + if (uiState.isSleepTimerDialogOpen) { + SleepTimerDialog( + minutes = uiState.sleepTimerMinutes, + remainingMs = uiState.sleepTimerRemainingMs, + onMinutesChange = viewModel::updateSleepTimerMinutes, + onStart = viewModel::startSleepTimer, + onCancelTimer = viewModel::cancelSleepTimer, + onDismiss = viewModel::dismissSleepTimerDialog) + } + + uiState.trackDetails?.let { details -> + TrackDetailsDialog( + details = details, + onDismiss = viewModel::dismissTrackDetails) + } +} + +@Composable +private fun SleepTimerDialog( + minutes: String, + remainingMs: Long?, + onMinutesChange: (String) -> Unit, + onStart: () -> Unit, + onCancelTimer: () -> Unit, + onDismiss: () -> Unit) { + MillenniumDialog(visible = true, onDismissRequest = onDismiss) { + MillenniumDialogHeader { + MillenniumDialogTitle(stringResource(Res.string.player_sleep_timer)) + MillenniumDialogClose() + } + MillenniumDialogBody { + if (remainingMs != null) { + Text( + text = stringResource(Res.string.player_sleep_timer_remaining, formatRemainingTime(remainingMs)), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary) + } else { + Text( + text = stringResource(Res.string.player_sleep_timer_description), + style = AppTheme.typography.bodyMedium) + } + val minutesState = rememberTextFieldState(minutes) + LaunchedEffect(minutesState.text) { + onMinutesChange(minutesState.text.toString()) + } + MillenniumTextField( + state = minutesState, + label = stringResource(Res.string.player_sleep_timer_minutes_label), + placeholder = stringResource(Res.string.player_sleep_timer_minutes_placeholder), + modifier = Modifier.fillMaxWidth()) + } + MillenniumDialogFooter { + if (remainingMs != null) { + MillenniumButton( + onClick = onCancelTimer, + style = MillenniumButtonStyle.Quiet) { Text(stringResource(Res.string.player_sleep_timer_cancel)) } + } + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) } + MillenniumButton( + onClick = onStart, + enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true) { + Text(stringResource(Res.string.player_sleep_timer_start)) + } + } + } +} + +@Composable +private fun TrackDetailsDialog( + details: PlayerTrackDetails, + onDismiss: () -> Unit) { + MillenniumDialog(visible = true, onDismissRequest = onDismiss) { + MillenniumDialogHeader { + MillenniumDialogTitle(stringResource(Res.string.player_track_details)) + MillenniumDialogClose() + } + MillenniumDialogBody(contentSpacing = 14.dp) { + Text( + details.track.title, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) + if (details.isLoading) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), + horizontalArrangement = Arrangement.Center) { + MillenniumMarqueeProgress(modifier = Modifier.fillMaxWidth()) + } + } + + if (details.localCacheEntry != null) { + LocalCacheBanner(details.localCacheEntry) + } + if (details.localCacheEntry == null && details.activeStreamMatchesPreference == false) { + Box( + modifier = Modifier + .fillMaxWidth() + .millenniumSurface( + background = SolidColor(AppTheme.colors.tertiaryContainer), + shape = RoundedCornerShape(12.dp))) { + Text( + stringResource(Res.string.player_track_details_stream_mismatch), + modifier = Modifier.padding(12.dp), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onTertiaryContainer) + } + } + + TrackMetadataSection(details.track) + + Text( + stringResource(Res.string.player_track_details_matched_source), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) + val source = details.matchedSource + if (details.isLoading) { + Text(stringResource(Res.string.player_track_details_loading_source), style = AppTheme.typography.bodySmall) + } else if (source == null) { + Text( + details.error ?: details.errorRes?.let { stringResource(it) } ?: if (details.localCacheEntry != null) { + stringResource(Res.string.player_track_details_source_not_needed) + } else { + stringResource(Res.string.player_track_details_no_source) + }, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } else { + DetailRow(stringResource(Res.string.player_track_details_title), source.title) + DetailRow(stringResource(Res.string.player_track_details_id), source.id) + DetailRow(stringResource(Res.string.media_artist), source.artist) + DetailRow(stringResource(Res.string.player_track_details_album), source.album) + DetailRow(stringResource(Res.string.player_track_details_confidence), "${(source.confidence * 100).toInt()}%") + DetailRow(stringResource(Res.string.player_track_details_external_uri), source.externalUri) + } + + Text( + stringResource(Res.string.player_track_details_available_streams), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) + when { + details.localCacheEntry != null && details.streams.isEmpty() -> Text( + stringResource(Res.string.player_track_details_served_from_cache_description), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + + details.isLoading -> Text(stringResource(Res.string.player_track_details_loading_streams), style = AppTheme.typography.bodySmall) + details.streams.isEmpty() -> Text( + details.error ?: details.errorRes?.let { stringResource(it) } ?: stringResource(Res.string.player_track_details_no_streams), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + + else -> details.streams.forEach { stream -> + val isSelected = stream == details.preferredStream + Box( + modifier = Modifier + .fillMaxWidth() + .millenniumSurface( + background = SolidColor( + if (isSelected) { + AppTheme.colors.primaryContainer + } else { + AppTheme.colors.surfaceContainerHigh + }), + shape = RoundedCornerShape(12.dp))) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = streamFormatAndQuality(stream), + style = AppTheme.typography.bodyMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) { + AppTheme.colors.onPrimaryContainer + } else { + AppTheme.colors.onSurface + }, + modifier = Modifier.weight(1f)) + if (isSelected) { + MillenniumIcon( + imageVector = Iconsax.IconsaxCheckCircle, + contentDescription = stringResource(Res.string.player_track_details_selected_stream_content_description), + tint = AppTheme.colors.primary) + } + } + Text( + "${stream.protocol} • ${stream.codec} • ${stream.container}", + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + Text( + stream.url, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + if (isSelected) { + Text( + stringResource(Res.string.player_track_details_matches_preference), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary) + } + } + } + } + } + + details.activeStreamUrl?.let { activeUrl -> + DetailRow(stringResource(Res.string.player_track_details_active_url), activeUrl) + } + + } + MillenniumDialogFooter { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) } + } + } +} + +@Composable +private fun LocalCacheBanner(entry: CacheEntry) { + Box( + modifier = Modifier + .fillMaxWidth() + .millenniumSurface( + background = SolidColor(AppTheme.colors.secondaryContainer), + shape = RoundedCornerShape(12.dp))) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( + imageVector = Iconsax.IconsaxDirectboxReceive, + contentDescription = null, + tint = AppTheme.colors.onSecondaryContainer) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + stringResource(Res.string.player_track_details_served_from_cache), + style = AppTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSecondaryContainer) + Text( + "${entry.filename} • ${formatFileSize(entry.sizeBytes)}", + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSecondaryContainer) + } + } + } +} + +@Composable +private fun TrackMetadataSection(track: MetadataTrack) { + Text( + stringResource(Res.string.player_track_details_metadata_track), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) + DetailRow(stringResource(Res.string.player_track_details_id), track.id) + DetailRow(stringResource(Res.string.player_track_details_title), track.title) + DetailRow(stringResource(Res.string.library_tab_artists), track.artists.joinToString { it.name }) + DetailRow(stringResource(Res.string.player_track_details_album), track.album?.title) + DetailRow(stringResource(Res.string.player_track_details_album_id), track.album?.id) + DetailRow(stringResource(Res.string.player_track_details_album_type), track.album?.albumType?.name) + DetailRow(stringResource(Res.string.player_track_details_album_release_date), track.album?.releaseDate) + DetailRow(stringResource(Res.string.player_track_details_album_track_count), track.album?.trackCount?.toString()) + DetailRow(stringResource(Res.string.player_track_details_album_genres), track.album?.genres?.joinToString()) + DetailRow(stringResource(Res.string.player_track_details_duration), formatRemainingTime(track.durationMs)) + DetailRow(stringResource(Res.string.player_track_details_track_number), track.trackNumber?.toString()) + DetailRow(stringResource(Res.string.player_track_details_disc_number), track.discNumber?.toString()) + DetailRow(stringResource(Res.string.player_track_details_explicit), track.explicit?.toString()) + DetailRow(stringResource(Res.string.player_track_details_popularity), track.popularity?.toString()) + DetailRow(stringResource(Res.string.player_track_details_isrc), track.isrcCode) + DetailRow(stringResource(Res.string.player_track_details_external_uri), track.externalUri) + DetailRow(stringResource(Res.string.player_track_details_thumbnail_urls), track.thumbnails?.joinToString { it.url }) +} + +@Composable +private fun DetailRow(label: String, value: String?) { + if (value.isNullOrBlank()) return + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + label, + modifier = Modifier.weight(0.38f), + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) + Text( + value, + modifier = Modifier.weight(0.62f), + style = AppTheme.typography.bodySmall) + } +} + +private fun streamFormatAndQuality(stream: AudioStream): String = when (stream) { + is AudioStream.Lossy -> "${stream.bitrate / 1_000} kbps" + is AudioStream.Lossless -> { + val rate = if (stream.sampleRate % 1_000 == 0) { + "${stream.sampleRate / 1_000}" + } else { + "${stream.sampleRate / 1_000.0}" + } + "$rate kHz • ${stream.channels} ch" + } +} + +private fun formatRemainingTime(milliseconds: Long): String { + val totalSeconds = (milliseconds / 1_000).coerceAtLeast(0) + val hours = totalSeconds / 3_600 + val minutes = (totalSeconds % 3_600) / 60 + val seconds = totalSeconds % 60 + return if (hours > 0) { + "$hours:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" + } else { + "$minutes:${seconds.toString().padStart(2, '0')}" + } +} + +private fun formatFileSize(sizeBytes: Long): String = when { + sizeBytes >= 1024L * 1024L -> "${sizeBytes / (1024L * 1024L)} MB" + sizeBytes >= 1024L -> "${sizeBytes / 1024L} KB" + else -> "$sizeBytes B" +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionsViewModel.kt new file mode 100644 index 00000000..d010d1b2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionsViewModel.kt @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.server.CacheEntry +import org.spotube.spotube.core.server.CacheManager +import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.core.server.TrackSourceRepository +import org.spotube.spotube.core.server.normalizeManifestUrl +import org.spotube.spotube.core.server.selectPreferredAudioStream +import org.spotube.spotube.modules.plugin.AudioPluginSource +import org.spotube.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_track_details_load_failed +import spotube.composeapp.generated.resources.player_track_details_no_cached_source +import spotube.composeapp.generated.resources.player_track_details_streams_not_cached + +data class PlayerTrackDetails( + val track: MetadataTrack, + val matchedSource: AudioSource.Basic? = null, + val streams: List = emptyList(), + /** Stream picked by the current streaming format/quality preference. */ + val preferredStream: AudioStream? = null, + /** URL currently used for playback, read from the playback URL cache. */ + val activeStreamUrl: String? = null, + /** Whether the active URL matches what the current preferences select. */ + val activeStreamMatchesPreference: Boolean? = null, + val localCacheEntry: CacheEntry? = null, + val isLoading: Boolean = false, + /** Raw error message (e.g. from an exception); takes precedence over [errorRes]. */ + val error: String? = null, + /** Localized error to show when [error] is null. */ + val errorRes: StringResource? = null) + +data class PlayerOptionsUiState( + val isMoreOptionsMenuOpen: Boolean = false, + val isMoreOptionsSheetOpen: Boolean = false, + val isSleepTimerDialogOpen: Boolean = false, + val sleepTimerMinutes: String = "30", + val sleepTimerRemainingMs: Long? = null, + val trackDetails: PlayerTrackDetails? = null) + +class PlayerOptionsViewModel( + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val matchedTracksRepository: TrackSourceRepository, + private val streamingUrlRepository: StreamingUrlRepository, + private val cacheManager: CacheManager, + private val settingsRepository: SettingsRepository, + private val pluginManager: AudioPluginSource) : ViewModel() { + private val _uiState = MutableStateFlow(PlayerOptionsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var sleepTimerJob: Job? = null + private var trackDetailsJob: Job? = null + + fun showMoreOptionsMenu() { + _uiState.update { + it.copy(isMoreOptionsMenuOpen = true, isMoreOptionsSheetOpen = false) + } + } + + fun dismissMoreOptionsMenu() { + _uiState.update { it.copy(isMoreOptionsMenuOpen = false) } + } + + fun showMoreOptionsSheet() { + _uiState.update { + it.copy(isMoreOptionsSheetOpen = true, isMoreOptionsMenuOpen = false) + } + } + + fun dismissMoreOptionsSheet() { + _uiState.update { it.copy(isMoreOptionsSheetOpen = false) } + } + + fun showSleepTimerDialog() { + _uiState.update { it.copy(isSleepTimerDialogOpen = true) } + } + + fun dismissSleepTimerDialog() { + _uiState.update { it.copy(isSleepTimerDialogOpen = false) } + } + + fun updateSleepTimerMinutes(minutes: String) { + if (minutes.all(Char::isDigit)) { + _uiState.update { it.copy(sleepTimerMinutes = minutes) } + } + } + + fun startSleepTimer() { + val minutes = _uiState.value.sleepTimerMinutes.toLongOrNull() + ?.takeIf { it in 1..600 } ?: return + val durationMs = minutes * MILLIS_PER_MINUTE + + sleepTimerJob?.cancel() + sleepTimerJob = viewModelScope.launch { + var remainingMs = durationMs + while (remainingMs > 0L) { + _uiState.update { it.copy(sleepTimerRemainingMs = remainingMs) } + val tickMs = minOf(remainingMs, MILLIS_PER_SECOND) + delay(tickMs.milliseconds) + remainingMs -= tickMs + } + runCatching { audioPlayer.pause() } + _uiState.update { it.copy(sleepTimerRemainingMs = null, isSleepTimerDialogOpen = false) } + } + } + + fun cancelSleepTimer() { + sleepTimerJob?.cancel() + sleepTimerJob = null + _uiState.update { it.copy(sleepTimerRemainingMs = null) } + } + + fun showTrackDetails() { + val track = (audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack) + ?.track ?: return + trackDetailsJob?.cancel() + _uiState.update { + it.copy( + trackDetails = PlayerTrackDetails(track = track, isLoading = true)) + } + trackDetailsJob = viewModelScope.launch { + runCatching { + val settings = settingsRepository.userSettings.value + withContext(Dispatchers.IO) { + val pluginId = pluginManager.selectedAudioPlugin.value?.pluginId + val matchedSource = pluginId?.let { matchedTracksRepository.getTrackSource(track, it) } + val cachedStream = streamingUrlRepository.getCachedStreamUrlEntry(track.id) + val localCacheEntry = if (settings.enableMusicCaching) { + cacheManager.findCachedEntry(track.id)?.second + } else { + null + } + val cachedStreamSource = cachedStream?.source?.takeIf { + matchedSource == null || it.id == matchedSource.id + } + + val source = matchedSource ?: cachedStreamSource?.toBasic() + val streams = cachedStreamSource?.streams.orEmpty() + val preferredStream = selectPreferredAudioStream( + streams = streams, + preferredFormat = settings.streamingMusicFormat, + preferredQuality = settings.streamingMusicQuality) + val activeStreamUrl = if (cachedStreamSource != null) cachedStream.url else null + val activeStreamMatchesPreference = when { + activeStreamUrl == null || preferredStream == null -> null + else -> activeStreamUrl == normalizeManifestUrl( + url = preferredStream.url, + protocol = preferredStream.protocol) + } + + PlayerTrackDetails( + track = track, + matchedSource = source, + streams = streams, + preferredStream = preferredStream, + activeStreamUrl = activeStreamUrl, + activeStreamMatchesPreference = activeStreamMatchesPreference, + localCacheEntry = localCacheEntry, + errorRes = when { + source == null && localCacheEntry == null -> + Res.string.player_track_details_no_cached_source + streams.isEmpty() && localCacheEntry == null -> + Res.string.player_track_details_streams_not_cached + else -> null + }) + } + }.onSuccess { details -> + _uiState.update { current -> + if (current.trackDetails?.track?.id == track.id) { + current.copy(trackDetails = details) + } else current + } + }.onFailure { error -> + if (error is CancellationException) throw error + _uiState.update { current -> + if (current.trackDetails?.track?.id == track.id) { + current.copy( + trackDetails = PlayerTrackDetails( + track = track, + isLoading = false, + error = error.message, + errorRes = Res.string.player_track_details_load_failed)) + } else current + } + } + } + } + + fun dismissTrackDetails() { + trackDetailsJob?.cancel() + trackDetailsJob = null + _uiState.update { it.copy(trackDetails = null) } + } + + override fun onCleared() { + sleepTimerJob?.cancel() + trackDetailsJob?.cancel() + super.onCleared() + } + + companion object { + private const val MILLIS_PER_SECOND = 1_000L + private const val MILLIS_PER_MINUTE = 60_000L + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerTimeFormat.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerTimeFormat.kt new file mode 100644 index 00000000..ea7736ad --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerTimeFormat.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + +import kotlin.time.Duration + +internal fun formatPlayerTime(duration: Duration): String { + return duration.toComponents { hours, minutes, seconds, _ -> + val pMin = minutes.toString().padStart(2, '0') + val pSec = seconds.toString().padStart(2, '0') + + if (hours > 0) { + "${hours.toString().padStart(2, '0')}:$pMin:$pSec" + } else { + "$pMin:$pSec" + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt new file mode 100644 index 00000000..8e97ec04 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell.alternative_track + + +import org.spotube.millennium.composables.MillenniumSpokeSpinner +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumListTile +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.koin.compose.viewmodel.koinViewModel +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.alternative_track_active_source +import spotube.composeapp.generated.resources.alternative_track_confidence +import spotube.composeapp.generated.resources.alternative_track_empty +import spotube.composeapp.generated.resources.alternative_track_no_track +import spotube.composeapp.generated.resources.alternative_track_searching +import spotube.composeapp.generated.resources.alternative_track_title + +@Composable +fun AlternativeTrackContent( + viewModel: AlternativeTrackContentViewModel = koinViewModel(), + modifier: Modifier = Modifier) { + val uiState by viewModel.alternativeTrackUiState.collectAsState() + + LaunchedEffect(uiState.currentTrack?.id) { + viewModel.loadAlternatives() + } + + Box(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = stringResource(Res.string.alternative_track_title), + style = AppTheme.typography.titleLarge) + + val currentTrack = uiState.currentTrack + if (currentTrack == null) { + Text( + text = stringResource(Res.string.alternative_track_no_track), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) + return@Column + } + + if (uiState.isLoading && uiState.alternatives.isEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically) { + MillenniumSpokeSpinner(size = 24.dp) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(Res.string.alternative_track_searching), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) + } + return@Column + } + + if (uiState.alternatives.isEmpty()) { + Text( + text = stringResource(Res.string.alternative_track_empty), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) + return@Column + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(6.dp)) { + items( + items = uiState.alternatives, + key = { source -> source.id }) { source -> + AlternativeSourceRow( + source = source, + isActive = source.id == uiState.activeSourceId, + onClick = { viewModel.selectAlternative(source) }) + } + } + } + } +} + +@Composable +private fun AlternativeSourceRow( + source: AudioSource, + isActive: Boolean, + onClick: () -> Unit) { + val thumbnailUrl = source.thumbnails.maxByOrNull { it.width * it.height }?.url + val rowColor = if (isActive) { + AppTheme.colors.secondaryContainer + } else { + AppTheme.colors.surfaceContainerHigh + } + + MillenniumListTile( + onClick = onClick, + selected = isActive, + leading = { + Box( + modifier = Modifier + .size(44.dp) + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceVariant), + contentAlignment = Alignment.Center) { + if (thumbnailUrl != null) { + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop) + } else { + Text( + text = source.title.firstOrNull()?.uppercase() ?: "?", + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) + } + } + }, + title = { + Text( + text = source.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + }, + subtitle = { + Column { + val subtitle = buildString { + source.artist?.let { append(it) } + if (source.album != null) { + if (isNotEmpty()) append(" - ") + append(source.album) + } + } + if (subtitle.isNotEmpty()) { + Text( + text = subtitle, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + } + Text( + text = stringResource(Res.string.alternative_track_confidence, (source.confidence * 100).toInt()), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + } + }, + trailing = { + if (isActive) { + MillenniumIcon( + Iconsax.IconsaxCheckCircle, + contentDescription = stringResource(Res.string.alternative_track_active_source), + tint = AppTheme.colors.primary, + modifier = Modifier.size(22.dp)) + } + }) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt new file mode 100644 index 00000000..891d7baf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell.alternative_track + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.server.AlternativeTracksRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class AlternativeTrackUiState( + val currentTrack: MetadataTrack? = null, + val alternatives: List = emptyList(), + val activeSourceId: String? = null, + val isLoading: Boolean = false) + +class AlternativeTrackContentViewModel( + private val audioPlayerQueue: AudioPlayerQueue, + private val alternativeTracksRepository: AlternativeTracksRepository) : ViewModel() { + private val alternativeVisibilityFlow = MutableStateFlow(false) + private val alternativesFlow = MutableStateFlow>(emptyList()) + private val activeSourceIdFlow = MutableStateFlow(null) + private val isLoadingFlow = MutableStateFlow(false) + + val isAlternativeVisible: StateFlow = alternativeVisibilityFlow.asStateFlow() + + val alternativeTrackUiState: StateFlow = combine( + audioPlayerQueue.currentQueueEntryFlow, + alternativesFlow, + activeSourceIdFlow, + isLoadingFlow) { currentEntry, alternatives, activeSourceId, isLoading -> + val currentTrack = (currentEntry as? QueueEntry.StreamingTrack)?.track + AlternativeTrackUiState( + currentTrack = currentTrack, + alternatives = alternatives, + activeSourceId = activeSourceId, + isLoading = isLoading) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = AlternativeTrackUiState()) + + fun toggleAlternativeVisibility() { + alternativeVisibilityFlow.update { !it } + } + + fun setAlternativeVisibility(isVisible: Boolean) { + alternativeVisibilityFlow.value = isVisible + } + + fun loadAlternatives() { + val currentTrack = (audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack)?.track + if (currentTrack == null) { + alternativesFlow.value = emptyList() + activeSourceIdFlow.value = null + return + } + viewModelScope.launch { + isLoadingFlow.value = true + val sources = alternativeTracksRepository.resolveAlternatives(currentTrack) + alternativesFlow.value = sources + activeSourceIdFlow.value = alternativeTracksRepository.getActiveSourceId(currentTrack) + isLoadingFlow.value = false + } + } + + fun selectAlternative(source: AudioSource) { + val currentTrack = + (audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack)?.track + ?: return + viewModelScope.launch { + alternativeTracksRepository.selectAlternative(currentTrack, source) + activeSourceIdFlow.value = source.id + setAlternativeVisibility(false) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt new file mode 100644 index 00000000..97db75d2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell.alternative_track + + + +import androidx.compose.foundation.layout.Box +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.spotube.spotube.modules.shell.player_queue.BottomSheetContent +import org.spotube.spotube.modules.shell.player_queue.EndDrawerSheet + +private val SlidingSheetBreakpoint = 840.dp + +@Composable +fun AlternativeTrackSheet( + isVisible: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit) { + Box(modifier = modifier) { + if (currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded) { + EndDrawerSheet(isVisible, onDismiss, content) + } else { + BottomSheetContent(isVisible, onDismiss, content) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt new file mode 100644 index 00000000..6b3181ac --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -0,0 +1,446 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell.player_queue + + +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.composeunstyled.Text +import org.spotube.spotube.core.jam.JamParticipant +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxDragHandle +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemove +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import org.spotube.spotube.resources.iconsax.IconsaxUserRemove +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.composables.MillenniumTooltip +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.queue_clear_queue +import spotube.composeapp.generated.resources.queue_filter_queue +import spotube.composeapp.generated.resources.queue_no_queue_entries +import spotube.composeapp.generated.resources.queue_title +import spotube.composeapp.generated.resources.jam_participants_ban +import spotube.composeapp.generated.resources.jam_participants_host +import spotube.composeapp.generated.resources.player_more_options_content_description +import spotube.composeapp.generated.resources.queue_item_subtitle_added_by +import spotube.composeapp.generated.resources.queue_participant_ban_description +import spotube.composeapp.generated.resources.queue_participant_kick +import spotube.composeapp.generated.resources.queue_participant_kick_description +import spotube.composeapp.generated.resources.queue_participant_remove_suggestions +import spotube.composeapp.generated.resources.queue_participant_remove_suggestions_description +import spotube.composeapp.generated.resources.queue_remove_from_queue +import spotube.composeapp.generated.resources.queue_reorder_content_description + +@Composable +fun PlayerQueueContent( + viewModel: PlayerQueueContentViewModel = koinViewModel(), + modifier: Modifier = Modifier +) { + val state by viewModel.queueContentUiState.collectAsState() + val displayItems = state.displayItems + val filterQuery = state.filterQuery + val isFiltered = state.isFiltered + val isReadOnly = state.isReadOnly + var selectedParticipant by remember { mutableStateOf(null) } + + val lazyListState = rememberLazyListState() + val reorderableLazyListState = rememberReorderableLazyListState( + lazyListState, + onMove = { from, to -> + if (isFiltered || isReadOnly) return@rememberReorderableLazyListState + viewModel.onMove(from.index, to.index) + }) + + Box(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = stringResource(Res.string.queue_title), + style = AppTheme.typography.titleLarge + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val filterState = rememberTextFieldState(filterQuery) + LaunchedEffect(filterState.text) { + viewModel.setQueueFilter(filterState.text.toString()) + } + MillenniumTextField( + state = filterState, + label = "", + placeholder = stringResource(Res.string.queue_filter_queue), + modifier = Modifier.weight(1f) + ) + if (!isReadOnly) { + MillenniumTooltip(stringResource(Res.string.queue_clear_queue)) { + MillenniumIconButton( + onClick = viewModel::clearQueue, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxTrash, + contentDescription = stringResource(Res.string.queue_clear_queue) + ) + } + } + } + } + + if (displayItems.isEmpty()) { + Text( + text = stringResource(Res.string.queue_no_queue_entries), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant + ) + } else { + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + contentPadding = PaddingValues(bottom = 8.dp) + ) { + items(displayItems, key = { it.id }) { item -> + ReorderableItem(reorderableLazyListState, key = item.id) { isDragging -> + QueueItemRow( + item = item, + reorderScope = if (isFiltered || isReadOnly) null else this, + onPlayClick = { viewModel.playQueueItem(item.originalIndex) }, + onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) }, + onDragStarted = { viewModel.onDragStart() }, + onDragStopped = { viewModel.onDragStop() }, + showOptions = !isReadOnly, + enabled = !isReadOnly, + onParticipantClick = { selectedParticipant = it }) + } + } + } + } + + selectedParticipant?.let { participant -> + ParticipantDialog( + participant = participant, + isJamHost = state.isJamHost, + onDismiss = { selectedParticipant = null }, + onKick = { viewModel.kickParticipant(participant.id) }, + onBan = { viewModel.banParticipant(participant.id) }, + onRemoveSuggestions = { viewModel.removeParticipantTracks(participant.id) }) + } + } + } +} + +@Composable +private fun ParticipantDialog( + participant: JamParticipant, + isJamHost: Boolean, + onDismiss: () -> Unit, + onKick: () -> Unit, + onBan: () -> Unit, + onRemoveSuggestions: () -> Unit +) { + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { Text(participant.displayName, style = AppTheme.typography.titleLarge) }) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(vertical = 8.dp) + ) { + ParticipantAvatar(participant, size = 40) + Text( + text = participant.displayName, + style = AppTheme.typography.bodyLarge + ) + if (participant.isHost) { + Text( + text = stringResource(Res.string.jam_participants_host), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary + ) + } + } + + if (isJamHost && !participant.isHost) { + MillenniumSeparator( + modifier = Modifier.fillMaxWidth() + ) + MillenniumListTile( + onClick = { + onKick() + onDismiss() + }, + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = null, + tint = AppTheme.colors.error + ) + }, + title = { Text(stringResource(Res.string.queue_participant_kick)) }, + subtitle = { Text(stringResource(Res.string.queue_participant_kick_description)) }) + MillenniumListTile( + onClick = { + onBan() + onDismiss() + }, + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = null, + tint = AppTheme.colors.error + ) + }, + title = { Text(stringResource(Res.string.jam_participants_ban)) }, + subtitle = { Text(stringResource(Res.string.queue_participant_ban_description)) }) + MillenniumListTile( + onClick = { + onRemoveSuggestions() + onDismiss() + }, + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusicSquareRemove, + contentDescription = null + ) + }, + title = { Text(stringResource(Res.string.queue_participant_remove_suggestions)) }, + subtitle = { Text(stringResource(Res.string.queue_participant_remove_suggestions_description)) }) + } + } + } +} + +@Composable +private fun ParticipantAvatar(participant: JamParticipant, size: Int) { + Box( + modifier = Modifier + .size(size.dp) + .clip(CircleShape) + .background(AppTheme.colors.primaryContainer), + contentAlignment = Alignment.Center + ) { + Text( + text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onPrimaryContainer + ) + } +} + +@Composable +private fun QueueItemRow( + item: QueueItemUi, + reorderScope: sh.calvin.reorderable.ReorderableCollectionItemScope?, + onPlayClick: () -> Unit, + onRemoveClick: () -> Unit, + onDragStarted: () -> Unit, + onDragStopped: () -> Unit, + showOptions: Boolean = true, + enabled: Boolean = true, + onParticipantClick: (JamParticipant) -> Unit = {} +) { + var showMenu by remember { mutableStateOf(false) } + + MillenniumListTile( + onClick = onPlayClick, + enabled = enabled, + selected = item.isCurrent, + modifier = Modifier, + leading = { + Row( + modifier = Modifier + .height(56.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MillenniumIcon( + Iconsax.IconsaxDragHandle, + contentDescription = if (reorderScope != null) stringResource(Res.string.queue_reorder_content_description) else null, + modifier = Modifier + .size(24.dp) + .then( + if (reorderScope != null) { + with(reorderScope) { + Modifier.draggableHandle( + onDragStarted = { onDragStarted() }, + onDragStopped = onDragStopped + ) + } + } else { + Modifier + }), + tint = AppTheme.colors.onSurfaceVariant) + + Box( + modifier = Modifier + .size(40.dp) + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceVariant), + contentAlignment = Alignment.Center + ) { + if (item.artwork != null) { + AsyncImage( + model = item.artwork, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Text( + text = "${item.originalIndex + 1}", + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant + ) + } + } + } + }, + title = { + Text( + text = item.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (item.isCurrent) { + AppTheme.colors.onSecondaryContainer + } else { + AppTheme.colors.onSurface + } + ) + }, + subtitle = { + Text( + text = item.addedByParticipant?.let { stringResource(Res.string.queue_item_subtitle_added_by, item.subtitle, it.displayName) } ?: item.subtitle, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + trailing = { + Text( + text = item.durationLabel, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + + Spacer(modifier = Modifier.width(4.dp)) + + item.addedByParticipant?.let { participant -> + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(AppTheme.colors.surfaceVariant) + .clickable { onParticipantClick(participant) }, + contentAlignment = Alignment.Center + ) { + Text( + text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(4.dp)) + } + + if (showOptions) { + MillenniumDropdownMenu( + expanded = showMenu, + onExpandedChange = { showMenu = it }, + anchor = { + MillenniumIconButton( + onClick = { showMenu = true }, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.Iconsax3DotsMore, + contentDescription = stringResource(Res.string.player_more_options_content_description) + ) + } + }) { + MillenniumDropdownMenuItem( + label = stringResource(Res.string.queue_remove_from_queue), + icon = Iconsax.IconsaxMusicSquareRemove, + destructive = true, + onClick = { + onRemoveClick() + showMenu = false + }) + } + } + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt new file mode 100644 index 00000000..8da73f4c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell.player_queue + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamParticipant +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class QueueItemUi( + val id: String, + val title: String, + val subtitle: String, + val durationLabel: String, + val isCurrent: Boolean, + val artwork: Any?, + val originalIndex: Int, + /** Participant who added this item to the jam queue, if any. */ + val addedByParticipant: JamParticipant? = null) + +data class QueueContentUiState( + val filterQuery: String = "", + val displayItems: List = emptyList(), + val isFiltered: Boolean = false, + /** Guests cannot reorder/remove/clear the shared jam queue. */ + val isReadOnly: Boolean = false, + val isJamHost: Boolean = false, + val participants: List = emptyList()) + +class PlayerQueueContentViewModel( + private val audioPlayerQueue: AudioPlayerQueue, + private val jamRoomService: JamRoomService) : ViewModel() { + private val queueVisibilityFlow = MutableStateFlow(false) + private val queueFilterFlow = MutableStateFlow("") + private val reorderBuffer = MutableStateFlow?>(null) + private var moveFromOriginal: Int? = null + private var moveToDisplay: Int? = null + + val isQueueVisible: StateFlow = queueVisibilityFlow.asStateFlow() + + private val computedItems: StateFlow> = combine( + audioPlayerQueue.queueFlow, + audioPlayerQueue.currentQueueEntryFlow, + jamRoomService.participants) { queue, currentEntry, participants -> + val currentIndex = if (currentEntry != null) { + queue.indexOfFirst { it.matchesCurrent(currentEntry) } + } else { + -1 + } + queue.mapIndexed { index, entry -> + val title: String + val subtitle: String + val durationMs: Long + + when (entry) { + is QueueEntry.StreamingTrack -> { + title = entry.track.title + subtitle = entry.track.artists.joinToString(", ") { it.name } + durationMs = entry.track.durationMs + } + + is QueueEntry.LocalTrack -> { + title = entry.name + subtitle = entry.artists.joinToString(", ") + durationMs = entry.duration + } + } + + val addedByParticipant = participants.firstOrNull { it.id == entry.addedBy } + + QueueItemUi( + id = "${entry.url}@$index", + title = title, + subtitle = subtitle, + durationLabel = durationMs.toDurationLabel(), + isCurrent = index == currentIndex, + artwork = entry.artworkModel, + originalIndex = index, + addedByParticipant = addedByParticipant) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList()) + + val queueContentUiState: StateFlow = combine( + computedItems, + reorderBuffer, + queueFilterFlow, + jamRoomService.role, + jamRoomService.participants) { items, buffer, filterQuery, role, participants -> + val normalizedFilter = filterQuery.trim().lowercase() + val isFiltered = normalizedFilter.isNotBlank() + val filtered = if (isFiltered) { + items.filter { item -> + item.title.lowercase().contains(normalizedFilter) || + item.subtitle.lowercase().contains(normalizedFilter) || + item.addedByParticipant?.displayName?.lowercase()?.contains(normalizedFilter) == true + } + } else { + items + } + QueueContentUiState( + filterQuery = filterQuery, + displayItems = buffer ?: filtered, + isFiltered = isFiltered, + isReadOnly = role == JamRole.Guest, + isJamHost = role == JamRole.Host, + participants = participants) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = QueueContentUiState()) + + fun toggleQueueVisibility() { + queueVisibilityFlow.update { !it } + } + + fun setQueueVisibility(isVisible: Boolean) { + queueVisibilityFlow.value = isVisible + } + + fun setQueueFilter(query: String) { + queueFilterFlow.value = query + } + + fun playQueueItem(index: Int) { + if (index < 0 || queueContentUiState.value.isReadOnly) return + viewModelScope.launch { + audioPlayerQueue.jumpTo(index) + } + } + + fun removeQueueItem(index: Int) { + if (index < 0 || queueContentUiState.value.isReadOnly) return + viewModelScope.launch { + val currentQueue = audioPlayerQueue.queueFlow.value + if (index < currentQueue.size) { + audioPlayerQueue.removeFromQueue(currentQueue[index]) + } + } + } + + fun moveQueueItem(fromIndex: Int, toIndex: Int) { + if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return + if (queueContentUiState.value.isReadOnly) return + viewModelScope.launch { + audioPlayerQueue.move(fromIndex, toIndex) + } + } + + fun clearQueue() { + if (queueContentUiState.value.isReadOnly) return + viewModelScope.launch { + audioPlayerQueue.clear() + } + } + + // ---------- Jam participant moderation (host only) ---------- + + fun kickParticipant(participantId: String) { + if (!queueContentUiState.value.isJamHost) return + viewModelScope.launch { jamRoomService.kickParticipant(participantId) } + } + + fun banParticipant(participantId: String) { + if (!queueContentUiState.value.isJamHost) return + viewModelScope.launch { jamRoomService.banParticipant(participantId) } + } + + /** Removes every queue item that the given participant suggested. */ + fun removeParticipantTracks(participantId: String) { + if (!queueContentUiState.value.isJamHost) return + viewModelScope.launch { + val entries = audioPlayerQueue.queueFlow.value.filter { it.addedBy == participantId } + entries.forEach { audioPlayerQueue.removeFromQueue(it) } + } + } + + fun onDragStart() { + if (reorderBuffer.value != null) return + if (queueContentUiState.value.isReadOnly) return + val currentItems = queueContentUiState.value.displayItems + reorderBuffer.value = currentItems.toList() + } + + fun onMove(from: Int, to: Int) { + if (queueContentUiState.value.isReadOnly) return + val buffer = reorderBuffer.value ?: return + if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return + val item = buffer[from] + val newList = buffer.toMutableList().apply { + removeAt(from) + add(to, item) + } + reorderBuffer.value = newList + moveFromOriginal = item.originalIndex + moveToDisplay = to + } + + fun onDragStop() { + val fromOriginal = moveFromOriginal + val toDisplay = moveToDisplay + if (fromOriginal != null && toDisplay != null) { + moveQueueItem(fromOriginal, toDisplay) + } + moveFromOriginal = null + moveToDisplay = null + reorderBuffer.value = null + } +} + +private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean { + return when { + this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> { + this.track.id == current.track.id + } + + this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> { + this.url == current.url && this.name == current.name + } + + else -> false + } +} + +private fun Long.toDurationLabel(): String { + val totalSeconds = (this / 1000).coerceAtLeast(0) + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "$minutes:${seconds.toString().padStart(2, '0')}" +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt new file mode 100644 index 00000000..a15efb6a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell.player_queue + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp +import com.composeunstyled.DrawerPlacement +import com.composeunstyled.SheetDetent +import com.composeunstyled.currentWindowWidthBreakpoint +import com.composeunstyled.rememberModalBottomSheetState +import org.spotube.millennium.adaptive.WindowBreakpoints +import org.spotube.millennium.composables.MillenniumDrawer +import org.spotube.millennium.composables.MillenniumDrawerValue +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.rememberMillenniumDrawerState + +private val SlidingSheetBreakpoint = 840.dp + +@Composable +fun QueueSheet( + isVisible: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val isExpanded = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded + + if (isExpanded) { + EndDrawerSheet(isVisible, onDismiss, content) + } else { + BottomSheetContent(isVisible, onDismiss, content) + } +} + +@Composable +internal fun EndDrawerSheet( + isVisible: Boolean, + onDismiss: () -> Unit, + content: @Composable () -> Unit +) { + val drawerState = rememberMillenniumDrawerState() + LaunchedEffect(isVisible) { + drawerState.targetValue = + if (isVisible) MillenniumDrawerValue.Open else MillenniumDrawerValue.Closed + } + LaunchedEffect(drawerState.currentValue) { + if (drawerState.currentValue == MillenniumDrawerValue.Closed && isVisible) { + onDismiss() + } + } + MillenniumDrawer( + state = drawerState, + placement = DrawerPlacement.End, + shape = RectangleShape, + maximumWidth = 400.dp, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp) + ) { + content() + } +} + +@Composable +internal fun BottomSheetContent( + isVisible: Boolean, + onDismiss: () -> Unit, + content: @Composable () -> Unit +) { + if (!isVisible) return + // Show the whole sheet and cap the content instead: a detent that reveals only part of a + // full-height sheet leaves the bottom of the queue list off-screen and unreachable. + val sheetState = rememberModalBottomSheetState( + initialDetent = SheetDetent.FullyExpanded, + detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded) + ) + LaunchedEffect(sheetState.bottomSheetState.currentDetent) { + if (sheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } + MillenniumModalBottomSheet( + state = sheetState, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + contentSpacing = 8.dp + ) { + Box(Modifier.fillMaxWidth().fillMaxHeight(0.85f)) { + content() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/support/Support.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/support/Support.kt new file mode 100644 index 00000000..0781aae2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/support/Support.kt @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.support + +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogDescription +import org.spotube.millennium.composables.MillenniumDialogFooter +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.openUrlInBrowser +import org.spotube.spotube.resources.iconsax.BrandBuyMeACoffee +import org.spotube.spotube.resources.iconsax.BrandOpencollective +import org.spotube.spotube.resources.iconsax.BrandPatreon +import org.spotube.spotube.resources.iconsax.Iconsax +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIcon +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.support_dialog_dismiss +import spotube.composeapp.generated.resources.support_dialog_message +import spotube.composeapp.generated.resources.support_dialog_reminder_hint +import spotube.composeapp.generated.resources.support_dialog_title +import spotube.composeapp.generated.resources.welcome_donate_bmac_subtitle +import spotube.composeapp.generated.resources.welcome_donate_bmac_title +import spotube.composeapp.generated.resources.welcome_donate_opencollective_subtitle +import spotube.composeapp.generated.resources.welcome_donate_opencollective_title +import spotube.composeapp.generated.resources.welcome_donate_patreon_subtitle +import spotube.composeapp.generated.resources.welcome_donate_patreon_title + +/** A single donation destination offered across the app. */ +data class DonationOption( + val id: String, + val title: StringResource, + val subtitle: StringResource, + val url: String, + val icon: ImageVector) + +/** The donation destinations shown in onboarding, settings and the support prompt. */ +val donationOptions: List = listOf( + DonationOption( + id = "patreon", + title = Res.string.welcome_donate_patreon_title, + subtitle = Res.string.welcome_donate_patreon_subtitle, + url = "https://patreon.com/krtirtho", + icon = Iconsax.BrandPatreon), + DonationOption( + id = "buymeacoffee", + title = Res.string.welcome_donate_bmac_title, + subtitle = Res.string.welcome_donate_bmac_subtitle, + url = "https://www.buymeacoffee.com/krtirtho", + icon = Iconsax.BrandBuyMeACoffee), + DonationOption( + id = "opencollective", + title = Res.string.welcome_donate_opencollective_title, + subtitle = Res.string.welcome_donate_opencollective_subtitle, + url = "https://opencollective.com/spotube", + icon = Iconsax.BrandOpencollective)) + +/** Minimum time between two automatic support reminders. */ +const val SupportReminderIntervalMillis: Long = 7L * 24 * 60 * 60 * 1000 + +/** + * Opens the support prompt from anywhere inside the app shell. Hosted at the app root so the + * sidebar, home top bar and the periodic reminder all drive the same dialog. + */ +val LocalSupportPrompt = staticCompositionLocalOf { {} } + +/** Opens a donation destination in the user's browser. */ +fun openDonation(option: DonationOption) { + runCatching { openUrlInBrowser(option.url) } +} + +/** A dialog that lets the user pick a way to support the project. */ +@Composable +fun SupportDialog( + visible: Boolean, + onDismissRequest: () -> Unit, + isReminder: Boolean = false) { + if (!visible) return + MillenniumDialog(visible = true, onDismissRequest = onDismissRequest) { + MillenniumDialogHeader { + MillenniumDialogTitle(stringResource(Res.string.support_dialog_title)) + MillenniumDialogClose() + } + MillenniumDialogBody { + MillenniumDialogDescription(stringResource(Res.string.support_dialog_message)) + if (isReminder) { + Text( + stringResource(Res.string.support_dialog_reminder_hint), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + donationOptions.forEach { option -> + MillenniumButton( + onClick = { + openDonation(option) + onDismissRequest() + }, + modifier = Modifier.fillMaxWidth(), + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + imageVector = option.icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (option.id == "patreon") { + AppTheme.colors.onSurface + } else { + Color.Unspecified + }) + Text(stringResource(option.title)) + } + } + } + } + MillenniumDialogFooter { + MillenniumButton( + onClick = onDismissRequest, + style = MillenniumButtonStyle.Quiet) { + Text(stringResource(Res.string.support_dialog_dismiss)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateDialog.kt new file mode 100644 index 00000000..535929f8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateDialog.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.update + +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.runtime.Composable +import com.composeunstyled.Text +import com.mikepenz.markdown.m3.Markdown +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumAlertDialog +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.app_update_dialog_no_notes +import spotube.composeapp.generated.resources.app_update_dialog_title +import spotube.composeapp.generated.resources.update_action_ignore +import spotube.composeapp.generated.resources.update_action_update + +/** + * Prompts the user that a newer Spotube release is available on GitHub. [onUpdate] + * should take the user to the downloads page; [onIgnore] persistently silences this + * specific release tag so the check moves on to the next newer one. + */ +@Composable +fun AppUpdateDialog( + update: AvailableAppUpdate, + onDismiss: () -> Unit, + onIgnore: () -> Unit, + onUpdate: () -> Unit) { + MillenniumAlertDialog( + visible = true, + onDismissRequest = onDismiss, + title = { Text(stringResource(Res.string.app_update_dialog_title, update.tag)) }, + content = { + if (update.releaseNotesMarkdown.isNotBlank()) { + SelectionContainer { + Markdown(content = update.releaseNotesMarkdown) + } + } else { + Text(stringResource(Res.string.app_update_dialog_no_notes)) + } + }) { + MillenniumButton( + onClick = onIgnore, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.update_action_ignore)) + } + MillenniumButton(onClick = onUpdate) { + Text(stringResource(Res.string.update_action_update)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt new file mode 100644 index 00000000..0bbc8552 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.update + +import org.spotube.spotube.core.network.HttpClientFactory +import co.touchlab.kermit.Logger +import org.spotube.spotube.AppBuildInfo +import org.spotube.spotube.modules.settings.SettingsRepository +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import net.swiftzer.semver.SemVer +import org.koin.core.component.KoinComponent + +data class AvailableAppUpdate( + val tag: String, + val releaseNotesMarkdown: String) + +/** + * Checks GitHub for a newer app release once on startup and exposes it for the UI to + * surface via [org.spotube.spotube.modules.update.AppUpdateDialog]. This is an + * app-scoped singleton (not a ViewModel) since the check runs once for the whole + * process, independent of any screen's lifecycle. + */ +class AppUpdateService( + private val settingsRepository: SettingsRepository, + httpClientFactory: HttpClientFactory) : KoinComponent { + private val logger = Logger.withTag("AppUpdateService") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val httpClient = httpClientFactory.json + + private val _availableUpdate = MutableStateFlow(null) + val availableUpdate: StateFlow = _availableUpdate.asStateFlow() + + init { + scope.launch { + checkForUpdate() + } + } + + private suspend fun checkForUpdate() { + try { + // Read directly from DataStore so the check honors persisted preferences, + // rather than the repository StateFlow's default value during startup. + val settings = settingsRepository.getCurrentSettings() + if (!settings.autoCheckForUpdates) return + + val release = httpClient.get(AppBuildInfo.LATEST_RELEASE_API_URL) { + header(HttpHeaders.Accept, "application/vnd.github+json") + header(HttpHeaders.UserAgent, "Spotube/${AppBuildInfo.VERSION}") + }.body() + + val latestVersion = parseVersion(release.tagName.removePrefix("v")) + ?: return logger.w { "Ignoring release with invalid SemVer tag: ${release.tagName}" } + val currentVersion = parseVersion(AppBuildInfo.VERSION) + ?: return logger.w { "Ignoring update check for invalid app version: ${AppBuildInfo.VERSION}" } + + if (latestVersion > currentVersion && release.tagName != settings.ignoredAppUpdateVersion) { + _availableUpdate.value = AvailableAppUpdate( + tag = release.tagName, + releaseNotesMarkdown = release.body.orEmpty()) + } + } catch (e: Exception) { + logger.w(e) { "Failed to check for app updates" } + } + } + + fun ignoreUpdate() { + val update = _availableUpdate.value ?: return + _availableUpdate.value = null + scope.launch { + runCatching { + val settings = settingsRepository.getCurrentSettings() + settingsRepository.updateSettings(settings.copy(ignoredAppUpdateVersion = update.tag)) + }.onFailure { e -> + logger.e(e) { "Failed to persist ignored app version" } + } + } + } + + fun dismissUpdate() { + _availableUpdate.value = null + } + + private fun parseVersion(version: String): SemVer? = runCatching { + SemVer.parse(version) + }.getOrNull() +} + +@Serializable +private data class GitHubRelease( + @SerialName("tag_name") val tagName: String, + val body: String? = null) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt new file mode 100644 index 00000000..dbfd236f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.update + +import org.spotube.millennium.composables.MillenniumAlertDialog +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.text.selection.SelectionContainer +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.mikepenz.markdown.m3.Markdown +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_update_dialog_current_version +import spotube.composeapp.generated.resources.plugin_update_dialog_no_notes +import spotube.composeapp.generated.resources.plugin_update_dialog_title +import spotube.composeapp.generated.resources.update_action_ignore +import spotube.composeapp.generated.resources.update_action_update + +/** + * Prompts the user that a newer version of one of their installed plugins is + * available. [onUpdate] should download and install the update; [onIgnore] + * persistently silences this specific plugin version so the check moves on to the + * next newer one (or another plugin). + */ +@Composable +fun PluginUpdateDialog( + update: AvailablePluginUpdate, + onDismiss: () -> Unit, + onIgnore: () -> Unit, + onUpdate: () -> Unit) { + MillenniumAlertDialog( + visible = true, + onDismissRequest = onDismiss, + modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing), + title = { + Text( + stringResource( + Res.string.plugin_update_dialog_title, + update.pluginName, + update.info.latestVersion)) + }, + description = { + Text(stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion)) + }, + content = { + if (update.info.releaseNotes.isNotBlank()) { + SelectionContainer { + Markdown(content = update.info.releaseNotes) + } + } else { + Text(stringResource(Res.string.plugin_update_dialog_no_notes)) + } + }) { + MillenniumButton( + onClick = onIgnore, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.update_action_ignore)) + } + MillenniumButton(onClick = onUpdate) { + Text(stringResource(Res.string.update_action_update)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt new file mode 100644 index 00000000..6c09dbeb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.update + +import co.touchlab.kermit.Logger +import org.spotube.plugin_interfaces.plugin_apis.core.PluginUpdateInfo +import org.spotube.spotube.core.zipline.PluginService +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.modules.plugin.PluginManager +import org.spotube.spotube.modules.plugin.PluginManagerStates +import org.spotube.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch +import net.swiftzer.semver.SemVer +import org.koin.core.component.KoinComponent + +data class AvailablePluginUpdate( + val pluginId: String, + val pluginName: String, + val currentVersion: String, + val info: PluginUpdateInfo) + +/** + * Checks the currently active plugins for updates through their own [PluginService.use] + * -> `coreAPI.checkPluginUpdates` API whenever the selected plugin set changes, and + * exposes at most one pending update at a time for the UI to surface via + * [org.spotube.spotube.modules.update.PluginUpdateDialog]. This is an app-scoped + * singleton (not a ViewModel) since plugin runtimes are owned by [PluginManager] + * independent of any screen's lifecycle. + */ +class PluginUpdateService( + private val pluginManager: PluginManager, + private val settingsRepository: SettingsRepository) : KoinComponent { + private val logger = Logger.withTag("PluginUpdateService") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _availableUpdate = MutableStateFlow(null) + val availableUpdate: StateFlow = _availableUpdate.asStateFlow() + + init { + scope.launch { + pluginManager.ziplineServices.filterNotNull().collect { services -> + checkForUpdates(services.values.filterNotNull()) + } + } + } + + private suspend fun checkForUpdates(services: List) { + // Only surface one plugin update prompt at a time; re-checks happen the + // next time the selected plugin set changes. + if (_availableUpdate.value != null) return + + val settings = settingsRepository.getCurrentSettings() + if (!settings.autoCheckForUpdates) return + + for (service in services.distinctBy { it.pluginId }) { + val pluginEntry = findPluginEntry(service.pluginId) ?: continue + val currentVersion = parseVersion(pluginEntry.version) ?: continue + + try { + val updateInfo = service.use { coreAPI.checkPluginUpdates(currentVersion) } ?: continue + val latestVersion = parseVersion(updateInfo.latestVersion) + if (latestVersion == null) { + logger.w { "Ignoring plugin update with invalid SemVer: ${updateInfo.latestVersion}" } + continue + } + + val ignoredVersion = settings.ignoredPluginUpdateVersions[pluginEntry.id] + if (latestVersion > currentVersion && updateInfo.latestVersion != ignoredVersion) { + _availableUpdate.value = AvailablePluginUpdate( + pluginId = pluginEntry.id, + pluginName = pluginEntry.name, + currentVersion = pluginEntry.version, + info = updateInfo) + return + } + } catch (e: Exception) { + logger.w(e) { "Failed to check updates for plugin ${pluginEntry.name}" } + } + } + } + + private fun findPluginEntry(pluginId: String): PluginEntry? { + val state = pluginManager.state.value as? PluginManagerStates.Data ?: return null + return state.plugins.find { it.id == pluginId } + } + + fun ignoreUpdate() { + val update = _availableUpdate.value ?: return + _availableUpdate.value = null + scope.launch { + runCatching { + val settings = settingsRepository.getCurrentSettings() + settingsRepository.updateSettings( + settings.copy( + ignoredPluginUpdateVersions = settings.ignoredPluginUpdateVersions + + (update.pluginId to update.info.latestVersion) + ) + ) + }.onFailure { e -> + logger.e(e) { "Failed to persist ignored plugin update version" } + } + } + } + + fun installUpdate() { + val update = _availableUpdate.value ?: return + _availableUpdate.value = null + scope.launch { + runCatching { + pluginManager.updatePluginFromURL(update.info.directDownloadUrl) + }.onFailure { e -> + logger.e(e) { "Failed to install update for plugin ${update.pluginName}" } + } + } + } + + fun dismissUpdate() { + _availableUpdate.value = null + } + + private fun parseVersion(version: String): SemVer? = runCatching { + SemVer.parse(version) + }.getOrNull() +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/webview/WebViewScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/webview/WebViewScreen.kt new file mode 100644 index 00000000..45e3e982 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/webview/WebViewScreen.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.webview + +import androidx.compose.runtime.Composable +import org.spotube.spotube.core.di.rememberLogger +import org.spotube.spotube.core.webview.PlatformWebViewScreen +import org.spotube.spotube.core.webview.WebViewController + +internal object WebViewScreen +@Composable +fun WebViewScreen(controller: WebViewController) { + val logger = rememberLogger() + logger.d("WebViewScreen Opened") + return PlatformWebViewScreen(webViewController = controller) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeContent.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeContent.kt new file mode 100644 index 00000000..938035f9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeContent.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.welcome + +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.welcome_get_started +import spotube.composeapp.generated.resources.welcome_lets_go +import spotube.composeapp.generated.resources.welcome_next +import spotube.composeapp.generated.resources.welcome_step_guide +import spotube.composeapp.generated.resources.welcome_step_plugins +import spotube.composeapp.generated.resources.welcome_step_support +import spotube.composeapp.generated.resources.welcome_step_welcome + +/** Order matches the 4 onboarding pages. */ +internal val welcomePrimaryLabels: List = listOf( + Res.string.welcome_lets_go, + Res.string.welcome_next, + Res.string.welcome_next, + Res.string.welcome_get_started) + +/** Short labels for the step switcher. Order matches the 4 onboarding pages. */ +internal val welcomeStepLabels: List = listOf( + Res.string.welcome_step_welcome, + Res.string.welcome_step_support, + Res.string.welcome_step_plugins, + Res.string.welcome_step_guide) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeModels.kt new file mode 100644 index 00000000..f42c1e66 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeModels.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.welcome + +import org.spotube.spotube.modules.support.DonationOption +import org.jetbrains.compose.resources.StringResource + +data class WelcomeUiState( + val currentPageIndex: Int, + val pageCount: Int, + val isLastPage: Boolean, + val primaryLabel: StringResource, + val donations: List) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeRepository.kt new file mode 100644 index 00000000..14014419 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeRepository.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.welcome + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import org.spotube.spotube.core.DebugConfig +import org.spotube.spotube.core.db.Database +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Owns whether the Welcome/Onboarding flow should be the app's start destination. + * + * This lives in its own preference rather than [org.spotube.spotube.modules.settings.UserSettings] + * so the very first launch can be distinguished from "settings not read yet". It is a Koin + * singleton so the app-level start route and the routed [WelcomeScreen] observe the same state. + */ +class WelcomeRepository(private val database: Database) { + companion object { + private val ONBOARDING_COMPLETED_KEY = booleanPreferencesKey("onboarding_completed") + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** Set when onboarding is finished for this process only; does not persist (debug forcing). */ + private val sessionDismissed = MutableStateFlow(false) + + /** Synchronous signal, available before the persisted flag loads; true on upgrades. */ + val isExistingInstall: Boolean get() = database.isExistingInstall + + private val onboardingCompleted: StateFlow = database.settingsDataStore.data + .map { prefs -> prefs[ONBOARDING_COMPLETED_KEY] ?: database.isExistingInstall } + .stateIn(scope, SharingStarted.Eagerly, null) + + /** + * Whether the app should start on the Welcome/Onboarding route instead of the main shell. + * + * - `true` on a fresh install, or whenever [DebugConfig.FORCE_SHOW_ONBOARDING] is enabled. + * - `false` on an existing install (upgrade) or once onboarding is completed or skipped, + * including within a forced session. + * + * The initial value is derived from [isExistingInstall] so the start route can be chosen on + * the very first frame without flashing the wrong screen. + */ + val shouldShowOnboarding: StateFlow = combine( + onboardingCompleted, + sessionDismissed) { completed, dismissed -> + when { + dismissed -> false + DebugConfig.FORCE_SHOW_ONBOARDING -> true + else -> !(completed ?: database.isExistingInstall) + } + }.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = if (DebugConfig.FORCE_SHOW_ONBOARDING) true else !database.isExistingInstall) + + init { + // One-time migration. When the flag was never written this is either a brand-new install + // (show onboarding) or an upgrade of an existing install (skip it). Persisting the + // decision also stops the fresh install's own data writes from being mistaken for an + // upgrade on the next launch. + scope.launch { + database.settingsDataStore.edit { prefs -> + if (ONBOARDING_COMPLETED_KEY !in prefs) { + prefs[ONBOARDING_COMPLETED_KEY] = database.isExistingInstall + } + } + } + } + + suspend fun completeOnboarding() { + sessionDismissed.value = true + database.settingsDataStore.edit { prefs -> + prefs[ONBOARDING_COMPLETED_KEY] = true + } + } + + /** Development helper to surface the welcome screen again on the next launch. */ + suspend fun resetOnboarding() { + database.settingsDataStore.edit { prefs -> + prefs[ONBOARDING_COMPLETED_KEY] = false + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeScreen.kt new file mode 100644 index 00000000..d87384cc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeScreen.kt @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.welcome + + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTabGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.welcome.pages.BrandingPage +import org.spotube.spotube.modules.welcome.pages.DonationPage +import org.spotube.spotube.modules.welcome.pages.HowItWorksPage +import org.spotube.spotube.modules.welcome.pages.PluginsPage +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.welcome_skip + +/** + * First-run Welcome/Onboarding screen. It is a route + * ([org.spotube.spotube.core.navigation.Routes.Welcome]) rendered inside the app shell. + * + * The steps are exposed through a Millennium tab group rather than ad-hoc dots or a footer + * walk-through, so the screen reads as one deliberate panel on every window size. + * Each step owns its content and the footer only carries the forward action. + */ +@Composable +fun WelcomeScreen(viewModel: WelcomeViewModel) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + WelcomeTheme { + Box(Modifier.fillMaxSize()) { + WelcomeBackground() + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { + WelcomeWordmark( + fontSize = AppTheme.typography.titleLarge.fontSize) + }, + backButton = false, + transparent = true, + actions = { + if (!state.isLastPage) { + MillenniumButton( + onClick = viewModel::completeOnboarding, + style = MillenniumButtonStyle.Quiet) { + Text( + text = stringResource(Res.string.welcome_skip), + color = AppTheme.colors.onSurfaceVariant) + } + } + }) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + contentAlignment = Alignment.Center) { + MillenniumCard( + modifier = Modifier + .widthIn(max = 1040.dp) + .fillMaxWidth() + .fillMaxHeight(), + contentPadding = PaddingValues(0.dp)) { + Column(Modifier.fillMaxSize()) { + WelcomeStepSwitcher( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + state = state, + viewModel = viewModel) + MillenniumSeparator() + WelcomeActionBar( + state = state, + viewModel = viewModel) + } + } + } + } + } + } +} + +@Composable +private fun WelcomeStepSwitcher( + state: WelcomeUiState, + viewModel: WelcomeViewModel, + modifier: Modifier = Modifier) { + val labels = welcomeStepLabels.map { stringResource(it) } + MillenniumTabGroup( + selectedTab = state.currentPageIndex, + onSelectedTabChange = viewModel::goToPage, + tabs = labels.indices.toList(), + modifier = modifier, + tabLabel = { labels[it] }) { index -> + when (index) { + 0 -> BrandingPage() + + 1 -> DonationPage( + donations = state.donations, + onDonate = viewModel::openDonation) + + 2 -> PluginsPage() + + else -> HowItWorksPage() + } + } +} + +@Composable +private fun WelcomeActionBar( + state: WelcomeUiState, + viewModel: WelcomeViewModel) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.weight(1f)) + MillenniumButton( + onClick = viewModel::onPrimary, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = stringResource(state.primaryLabel), + fontWeight = FontWeight.SemiBold) + MillenniumIcon( + imageVector = if (state.isLastPage) { + Iconsax.IconsaxCheckCircle + } else { + Iconsax.IconsaxNext + }, + contentDescription = null, + modifier = Modifier.size(16.dp)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeViewModel.kt new file mode 100644 index 00000000..0320a529 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeViewModel.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.welcome + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.modules.support.donationOptions +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class WelcomeViewModel( + private val repository: WelcomeRepository) : ViewModel() { + private val currentPageIndex = MutableStateFlow(0) + + val uiState: StateFlow = + currentPageIndex.map { index -> + val pageCount = welcomePrimaryLabels.size + WelcomeUiState( + currentPageIndex = index, + pageCount = pageCount, + isLastPage = index == pageCount - 1, + primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)], + donations = donationOptions) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = WelcomeUiState( + currentPageIndex = 0, + pageCount = welcomePrimaryLabels.size, + isLastPage = welcomePrimaryLabels.size == 1, + primaryLabel = welcomePrimaryLabels.first(), + donations = donationOptions)) + + fun goToPage(index: Int) { + currentPageIndex.update { index.coerceIn(0, welcomePrimaryLabels.lastIndex) } + } + + fun onPrimary() { + if (currentPageIndex.value == welcomePrimaryLabels.lastIndex) { + completeOnboarding() + } else { + goToPage(currentPageIndex.value + 1) + } + } + + fun openDonation(url: String) { + // Only destinations owned by our static funding list can be opened here. + if (donationOptions.none { it.url == url }) return + runCatching { org.spotube.spotube.openUrlInBrowser(url) } + } + + fun completeOnboarding() { + viewModelScope.launch { + repository.completeOnboarding() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeVisuals.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeVisuals.kt new file mode 100644 index 00000000..ae732f68 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeVisuals.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.welcome + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.TextUnit +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.theming.AppTheme +import org.jetbrains.compose.resources.Font +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.theme.MillenniumTheme +import spotube.composeapp.generated.resources.* + +/** + * Reapplies the Millennium theme for the onboarding pages using the app's active configuration, + * so onboarding matches the palette and appearance used by the rest of the app. + */ +@Composable +fun WelcomeTheme(content: @Composable () -> Unit) { + val dark = AppTheme.colors.surface.luminance() < 0.5f + MillenniumTheme( + dark = dark, + config = MillenniumTheme.config, + content = content) +} + +@Composable +fun WelcomeBackground(modifier: Modifier = Modifier) { + val scheme = AppTheme.colors + Box( + modifier.fillMaxSize().background( + Brush.verticalGradient(listOf(scheme.surfaceContainer, scheme.surface))) + ) +} + +@Composable +fun WelcomeWordmark( + modifier: Modifier = Modifier, + fontSize: TextUnit = AppTheme.typography.displaySmall.fontSize +) { + Text( + stringResource(Res.string.welcome_app_name), + modifier = modifier, + fontFamily = FontFamily(Font(Res.font.cookie_regular)), + fontSize = fontSize, + color = AppTheme.colors.onSurface) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/BrandingPage.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/BrandingPage.kt new file mode 100644 index 00000000..9df108f0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/BrandingPage.kt @@ -0,0 +1,43 @@ +package org.spotube.spotube.modules.welcome.pages + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.spotube.spotube.modules.welcome.WelcomeWordmark +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun BrandingPage(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + Image(painterResource(Res.drawable.spotube_logo), null, Modifier.size(88.dp)) + Spacer(Modifier.height(24.dp)) + WelcomeWordmark(fontSize = 48.sp) + Spacer(Modifier.height(24.dp)) + Text( + stringResource(Res.string.welcome_brand_freedom), + style = AppTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif), + textAlign = TextAlign.Center) + Spacer(Modifier.height(16.dp)) + Text( + stringResource(Res.string.welcome_brand_quiet_description), + modifier = Modifier.widthIn(max = 440.dp), + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/DonationPage.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/DonationPage.kt new file mode 100644 index 00000000..6217e7b6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/DonationPage.kt @@ -0,0 +1,72 @@ +package org.spotube.spotube.modules.welcome.pages + + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.spotube.spotube.modules.support.DonationOption +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DonationPage( + donations: List, + onDonate: (String) -> Unit, + modifier: Modifier = Modifier) { + Column( + modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + Text( + stringResource(Res.string.welcome_donate_quiet_title), + style = AppTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif), + textAlign = TextAlign.Center) + Spacer(Modifier.height(20.dp)) + Text( + stringResource(Res.string.welcome_donate_letter), + modifier = Modifier.widthIn(max = 540.dp), + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + Spacer(Modifier.height(32.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + donations.forEach { option -> + MillenniumButton( + onClick = { onDonate(option.url) }, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + option.icon, null, Modifier.size(24.dp), + tint = if (option.id == "patreon") AppTheme.colors.onSurface else Color.Unspecified) + Text(stringResource(option.title)) + } + } + } + } + Spacer(Modifier.height(24.dp)) + Text( + stringResource(Res.string.welcome_donate_optional), + modifier = Modifier.widthIn(max = 500.dp), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/HowItWorksPage.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/HowItWorksPage.kt new file mode 100644 index 00000000..e8764fa5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/HowItWorksPage.kt @@ -0,0 +1,98 @@ +package org.spotube.spotube.modules.welcome.pages + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumListTile +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDocumentDownload +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.resources.iconsax.IconsaxSearch +import org.spotube.spotube.resources.iconsax.User +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.welcome_guide_lyrics_desc +import spotube.composeapp.generated.resources.welcome_guide_lyrics_title +import spotube.composeapp.generated.resources.welcome_guide_offline_desc +import spotube.composeapp.generated.resources.welcome_guide_offline_title +import spotube.composeapp.generated.resources.welcome_guide_search_desc +import spotube.composeapp.generated.resources.welcome_guide_search_title +import spotube.composeapp.generated.resources.welcome_guide_signin_desc +import spotube.composeapp.generated.resources.welcome_guide_signin_title +import spotube.composeapp.generated.resources.welcome_guide_subtitle +import spotube.composeapp.generated.resources.welcome_guide_title + +/** + * The onboarding guide: a short, concrete answer to "how do I actually use this app?". + * No marketing prose — just the four things a new user needs to do. + */ +@Composable +fun HowItWorksPage(modifier: Modifier = Modifier) { + Column( + modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(28.dp), + verticalArrangement = Arrangement.spacedBy(20.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(Res.string.welcome_guide_title), + style = AppTheme.typography.headlineMedium.copy(fontFamily = FontFamily.Serif)) + Text( + stringResource(Res.string.welcome_guide_subtitle), + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant) + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + WelcomeGuideStep( + icon = Iconsax.User, + title = Res.string.welcome_guide_signin_title, + description = Res.string.welcome_guide_signin_desc) + WelcomeGuideStep( + icon = Iconsax.IconsaxSearch, + title = Res.string.welcome_guide_search_title, + description = Res.string.welcome_guide_search_desc) + WelcomeGuideStep( + icon = Iconsax.IconsaxDocumentDownload, + title = Res.string.welcome_guide_offline_title, + description = Res.string.welcome_guide_offline_desc) + WelcomeGuideStep( + icon = Iconsax.IconsaxMusic, + title = Res.string.welcome_guide_lyrics_title, + description = Res.string.welcome_guide_lyrics_desc) + } + } +} + +@Composable +private fun WelcomeGuideStep( + icon: ImageVector, + title: StringResource, + description: StringResource, +) { + MillenniumListTile( + leading = { + MillenniumIcon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = AppTheme.colors.primary) + }, + title = { Text(stringResource(title)) }, + subtitle = { Text(stringResource(description)) }) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/PluginsPage.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/PluginsPage.kt new file mode 100644 index 00000000..3bbcb709 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/PluginsPage.kt @@ -0,0 +1,10 @@ +package org.spotube.spotube.modules.welcome.pages + +import androidx.compose.runtime.Composable +import org.spotube.spotube.modules.plugin.PluginScreen + +/** The same installation, discovery, permissions and sign-in flow as Settings, in-place. */ +@Composable +fun PluginsPage() { + PluginScreen(onboarding = true) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/ArrowLeft3.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/ArrowLeft3.kt new file mode 100644 index 00000000..573512aa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/ArrowLeft3.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.ArrowLeft3: ImageVector + get() { + if (_ArrowLeft3 != null) { + return _ArrowLeft3!! + } + _ArrowLeft3 = ImageVector.Builder( + name = "ArrowLeft3", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(11.19f, 7.94f) + lineTo(8.57f, 10.56f) + curveTo(7.8f, 11.33f, 7.8f, 12.59f, 8.57f, 13.36f) + lineTo(15.09f, 19.88f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(15.09f, 4.04f) + lineTo(14.05f, 5.08f) + } + }.build() + + return _ArrowLeft3!! + } + +@Suppress("ObjectPropertyName") +private var _ArrowLeft3: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandBuyMeACoffee.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandBuyMeACoffee.kt new file mode 100644 index 00000000..4b70c390 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandBuyMeACoffee.kt @@ -0,0 +1,264 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.BrandBuyMeACoffee: ImageVector + get() { + if (_BrandBuyMeACoffee != null) { + return _BrandBuyMeACoffee!! + } + _BrandBuyMeACoffee = ImageVector.Builder( + name = "BrandBuyMeACoffee", + defaultWidth = 512.dp, + defaultHeight = 512.dp, + viewportWidth = 512f, + viewportHeight = 512f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(512f) + verticalLineToRelative(512f) + horizontalLineTo(0f) + close() + } + ) { + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256.17f, 256.45f) + lineToRelative(-0.35f, -0.21f) + lineTo(255f, 256f) + arcToRelative(1.93f, 1.93f, 0f, isMoreThanHalf = false, isPositiveArc = false, 1.16f, 0.45f) + close() + moveTo(256.15f, 256.04f) + arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -0.15f, -0.04f) + arcToRelative(0.54f, 0.54f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0f, 0.1f) + arcToRelative(0.3f, 0.3f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0.15f, -0.06f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256f, 256.03f) + horizontalLineToRelative(0.05f) + verticalLineTo(256f) + lineToRelative(-0.05f, 0.03f) + close() + moveTo(255f, 256.67f) + lineToRelative(0.59f, -0.34f) + lineToRelative(0.22f, -0.12f) + lineToRelative(0.2f, -0.21f) + arcToRelative(3.39f, 3.39f, 0f, isMoreThanHalf = false, isPositiveArc = false, -1.01f, 0.68f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(255.97f, 256.77f) + lineToRelative(-0.58f, -0.55f) + lineTo(255f, 256f) + curveToRelative(0.21f, 0.37f, 0.56f, 0.65f, 0.97f, 0.76f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256.18f, 256f) + curveToRelative(-0.46f, 0.2f, -0.87f, 0.51f, -1.18f, 0.91f) + lineToRelative(0.37f, -0.23f) + curveToRelative(0.25f, -0.23f, 0.6f, -0.5f, 0.81f, -0.68f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256.2f, 255.27f) + curveToRelative(0f, -0.52f, -0.25f, -0.43f, -0.19f, 1.43f) + curveToRelative(0f, -0.15f, 0.06f, -0.3f, 0.09f, -0.44f) + curveToRelative(0.04f, -0.33f, 0.06f, -0.66f, 0.1f, -0.99f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256.18f, 256f) + arcToRelative(3.02f, 3.02f, 0f, isMoreThanHalf = false, isPositiveArc = false, -1.18f, 0.91f) + lineToRelative(0.37f, -0.23f) + curveToRelative(0.25f, -0.23f, 0.6f, -0.5f, 0.81f, -0.68f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256.24f, 256.58f) + arcTo(2.52f, 2.52f, 0f, isMoreThanHalf = false, isPositiveArc = false, 255f, 256f) + curveToRelative(0.37f, 0.18f, 0.74f, 0.36f, 0.99f, 0.5f) + lineToRelative(0.25f, 0.08f) + close() + moveTo(256.49f, 256.54f) + arcTo(3.96f, 3.96f, 0f, isMoreThanHalf = false, isPositiveArc = false, 256f, 255f) + curveToRelative(0.19f, 0.49f, 0.35f, 1f, 0.47f, 1.51f) + lineToRelative(0.01f, 0.03f) + close() + } + path(fill = SolidColor(Color(0xFFFFDD00))) { + moveTo(267.82f, 145.08f) + curveToRelative(-18.4f, 7.87f, -39.28f, 16.8f, -66.33f, 16.8f) + arcTo(125.64f, 125.64f, 0f, isMoreThanHalf = false, isPositiveArc = true, 168f, 157.26f) + lineToRelative(18.71f, 192.07f) + arcToRelative(32.09f, 32.09f, 0f, isMoreThanHalf = false, isPositiveArc = false, 32f, 29.45f) + reflectiveCurveToRelative(26.53f, 1.38f, 35.39f, 1.38f) + curveToRelative(9.53f, 0f, 38.1f, -1.38f, 38.1f, -1.38f) + arcToRelative(32.1f, 32.1f, 0f, isMoreThanHalf = false, isPositiveArc = false, 31.99f, -29.45f) + lineToRelative(20.04f, -212.25f) + curveToRelative(-8.96f, -3.06f, -18f, -5.09f, -28.19f, -5.09f) + curveToRelative(-17.63f, -0.01f, -31.83f, 6.06f, -48.23f, 13.08f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256f, 256f) + lineToRelative(0.32f, 0.3f) + lineToRelative(0.21f, 0.12f) + arcTo(3.21f, 3.21f, 0f, isMoreThanHalf = false, isPositiveArc = false, 256f, 256f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(431.23f, 136.85f) + lineToRelative(-2.82f, -14.21f) + curveToRelative(-2.53f, -12.75f, -8.27f, -24.8f, -21.36f, -29.41f) + curveToRelative(-4.2f, -1.47f, -8.96f, -2.11f, -12.18f, -5.16f) + curveToRelative(-3.22f, -3.05f, -4.17f, -7.79f, -4.91f, -12.18f) + curveToRelative(-1.38f, -8.07f, -2.67f, -16.14f, -4.09f, -24.19f) + curveToRelative(-1.22f, -6.92f, -2.18f, -14.7f, -5.36f, -21.05f) + curveToRelative(-4.13f, -8.53f, -12.71f, -13.51f, -21.24f, -16.81f) + arcToRelative(122.38f, 122.38f, 0f, isMoreThanHalf = false, isPositiveArc = false, -13.36f, -4.13f) + curveToRelative(-21.31f, -5.62f, -43.72f, -7.69f, -65.64f, -8.86f) + arcToRelative(551.32f, 551.32f, 0f, isMoreThanHalf = false, isPositiveArc = false, -78.96f, 1.31f) + curveToRelative(-19.54f, 1.78f, -40.12f, 3.93f, -58.69f, 10.68f) + curveToRelative(-6.79f, 2.47f, -13.78f, 5.44f, -18.94f, 10.68f) + curveToRelative(-6.33f, 6.44f, -8.4f, 16.4f, -3.78f, 24.43f) + curveToRelative(3.29f, 5.7f, 8.85f, 9.73f, 14.76f, 12.4f) + arcToRelative(119.72f, 119.72f, 0f, isMoreThanHalf = false, isPositiveArc = false, 23.97f, 7.8f) + curveToRelative(22.94f, 5.07f, 46.71f, 7.06f, 70.15f, 7.91f) + arcToRelative(534.38f, 534.38f, 0f, isMoreThanHalf = false, isPositiveArc = false, 77.86f, -2.54f) + arcToRelative(443.55f, 443.55f, 0f, isMoreThanHalf = false, isPositiveArc = false, 19.15f, -2.53f) + curveToRelative(7.5f, -1.15f, 12.32f, -10.96f, 10.11f, -17.79f) + curveToRelative(-2.65f, -8.17f, -9.76f, -11.34f, -17.8f, -10.1f) + curveToRelative(-1.18f, 0.19f, -2.36f, 0.36f, -3.55f, 0.53f) + lineToRelative(-0.85f, 0.12f) + curveToRelative(-2.72f, 0.34f, -5.45f, 0.67f, -8.17f, 0.96f) + arcToRelative(433.76f, 433.76f, 0f, isMoreThanHalf = false, isPositiveArc = true, -16.92f, 1.49f) + curveToRelative(-12.66f, 0.88f, -25.36f, 1.29f, -38.06f, 1.31f) + curveToRelative(-12.47f, 0f, -24.95f, -0.35f, -37.39f, -1.17f) + arcToRelative(475.74f, 475.74f, 0f, isMoreThanHalf = false, isPositiveArc = true, -16.99f, -1.42f) + curveToRelative(-2.57f, -0.27f, -5.13f, -0.55f, -7.7f, -0.87f) + lineToRelative(-2.44f, -0.31f) + lineToRelative(-0.53f, -0.08f) + lineToRelative(-2.53f, -0.37f) + curveToRelative(-5.17f, -0.78f, -10.34f, -1.67f, -15.45f, -2.76f) + arcToRelative(2.32f, 2.32f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0f, -4.53f) + horizontalLineToRelative(0.1f) + arcToRelative(250.82f, 250.82f, 0f, isMoreThanHalf = false, isPositiveArc = true, 13.37f, -2.45f) + curveToRelative(1.49f, -0.23f, 2.99f, -0.46f, 4.49f, -0.69f) + horizontalLineToRelative(0.04f) + curveToRelative(2.81f, -0.19f, 5.62f, -0.69f, 8.41f, -1.02f) + arcToRelative(535.29f, 535.29f, 0f, isMoreThanHalf = false, isPositiveArc = true, 73.1f, -2.58f) + curveToRelative(11.84f, 0.34f, 23.68f, 1.04f, 35.47f, 2.24f) + arcToRelative(391.9f, 391.9f, 0f, isMoreThanHalf = false, isPositiveArc = true, 7.58f, 0.85f) + curveToRelative(0.96f, 0.12f, 1.94f, 0.25f, 2.91f, 0.37f) + lineToRelative(1.96f, 0.28f) + curveToRelative(5.7f, 0.85f, 11.38f, 1.88f, 17.03f, 3.09f) + curveToRelative(8.36f, 1.82f, 19.11f, 2.41f, 22.83f, 11.57f) + curveToRelative(1.18f, 2.91f, 1.72f, 6.14f, 2.38f, 9.19f) + lineToRelative(0.83f, 3.89f) + curveToRelative(0.02f, 0.07f, 0.04f, 0.14f, 0.05f, 0.21f) + curveToRelative(1.97f, 9.18f, 3.94f, 18.37f, 5.92f, 27.55f) + arcToRelative(5.05f, 5.05f, 0f, isMoreThanHalf = false, isPositiveArc = true, -4.24f, 6.05f) + horizontalLineToRelative(-0.05f) + lineToRelative(-1.21f, 0.17f) + lineToRelative(-1.19f, 0.16f) + curveToRelative(-3.78f, 0.49f, -7.56f, 0.95f, -11.34f, 1.38f) + arcToRelative(732.52f, 732.52f, 0f, isMoreThanHalf = false, isPositiveArc = true, -22.4f, 2.2f) + arcToRelative(784.15f, 784.15f, 0f, isMoreThanHalf = false, isPositiveArc = true, -44.67f, 2.43f) + arcToRelative(802.63f, 802.63f, 0f, isMoreThanHalf = false, isPositiveArc = true, -22.8f, 0.28f) + arcToRelative(791.3f, 791.3f, 0f, isMoreThanHalf = false, isPositiveArc = true, -90.5f, -5.26f) + arcToRelative(944.16f, 944.16f, 0f, isMoreThanHalf = false, isPositiveArc = true, -9.76f, -1.22f) + curveToRelative(2.52f, 0.32f, -1.83f, -0.25f, -2.71f, -0.37f) + curveToRelative(-2.07f, -0.29f, -4.13f, -0.59f, -6.2f, -0.9f) + curveToRelative(-6.94f, -1.04f, -13.84f, -2.32f, -20.76f, -3.44f) + curveToRelative(-8.37f, -1.38f, -16.38f, -0.69f, -23.95f, 3.44f) + arcToRelative(34.83f, 34.83f, 0f, isMoreThanHalf = false, isPositiveArc = false, -14.42f, 14.95f) + curveToRelative(-3.27f, 6.75f, -4.24f, 14.1f, -5.7f, 21.35f) + curveToRelative(-1.46f, 7.25f, -3.73f, 15.06f, -2.87f, 22.5f) + curveToRelative(1.85f, 16.07f, 13.09f, 29.13f, 29.26f, 32.05f) + curveToRelative(15.21f, 2.76f, 30.5f, 4.99f, 45.83f, 6.89f) + arcToRelative(847.2f, 847.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 196.22f, 1.13f) + arcToRelative(10.34f, 10.34f, 0f, isMoreThanHalf = false, isPositiveArc = true, 11.41f, 11.32f) + lineToRelative(-1.53f, 14.86f) + lineToRelative(-9.25f, 90.1f) + arcToRelative(70942.69f, 70942.69f, 0f, isMoreThanHalf = false, isPositiveArc = true, -9.71f, 94.6f) + lineToRelative(-2.76f, 26.64f) + curveToRelative(-0.88f, 8.74f, -1.01f, 17.76f, -2.67f, 26.4f) + curveToRelative(-2.62f, 13.58f, -11.82f, 21.93f, -25.24f, 24.98f) + arcToRelative(175.93f, 175.93f, 0f, isMoreThanHalf = false, isPositiveArc = true, -37.47f, 4.38f) + curveToRelative(-13.98f, 0.08f, -27.95f, -0.54f, -41.94f, -0.47f) + curveToRelative(-14.93f, 0.08f, -33.2f, -1.29f, -44.72f, -12.4f) + curveToRelative(-10.12f, -9.75f, -11.52f, -25.02f, -12.9f, -38.23f) + arcToRelative(29492.9f, 29492.9f, 0f, isMoreThanHalf = false, isPositiveArc = true, -5.46f, -52.44f) + lineToRelative(-10.13f, -97.18f) + lineToRelative(-6.55f, -62.88f) + curveToRelative(-0.11f, -1.04f, -0.22f, -2.07f, -0.32f, -3.11f) + curveToRelative(-0.79f, -7.5f, -6.1f, -14.84f, -14.47f, -14.46f) + curveToRelative(-7.17f, 0.32f, -15.31f, 6.41f, -14.47f, 14.46f) + lineToRelative(4.86f, 46.62f) + lineToRelative(10.05f, 96.43f) + curveToRelative(2.86f, 27.39f, 5.72f, 54.79f, 8.56f, 82.19f) + curveToRelative(0.55f, 5.25f, 1.07f, 10.51f, 1.65f, 15.76f) + curveToRelative(3.15f, 28.68f, 25.06f, 44.14f, 52.19f, 48.49f) + curveToRelative(15.85f, 2.55f, 32.08f, 3.08f, 48.16f, 3.34f) + curveToRelative(20.62f, 0.33f, 41.44f, 1.13f, 61.72f, -2.61f) + curveToRelative(30.05f, -5.51f, 52.6f, -25.57f, 55.81f, -56.68f) + lineToRelative(2.76f, -26.95f) + curveToRelative(3.05f, -29.72f, 6.11f, -59.44f, 9.15f, -89.17f) + lineToRelative(9.96f, -97.12f) + lineToRelative(4.57f, -44.51f) + arcToRelative(10.34f, 10.34f, 0f, isMoreThanHalf = false, isPositiveArc = true, 8.32f, -9.09f) + curveToRelative(8.59f, -1.67f, 16.81f, -4.53f, 22.92f, -11.07f) + curveToRelative(9.73f, -10.41f, 11.66f, -23.98f, 8.23f, -37.66f) + close() + moveTo(108.01f, 146.45f) + curveToRelative(0.13f, -0.06f, -0.11f, 1.06f, -0.21f, 1.58f) + curveToRelative(-0.02f, -0.79f, 0.02f, -1.49f, 0.21f, -1.58f) + close() + moveTo(108.84f, 152.9f) + curveToRelative(0.07f, -0.05f, 0.28f, 0.23f, 0.49f, 0.56f) + curveToRelative(-0.32f, -0.3f, -0.53f, -0.53f, -0.5f, -0.56f) + horizontalLineToRelative(0.01f) + close() + moveTo(109.66f, 153.98f) + curveToRelative(0.45f, 0.82f, 0.3f, 0.5f, 0f, 0f) + close() + moveTo(111.31f, 155.31f) + horizontalLineToRelative(0.04f) + curveToRelative(0f, 0.05f, 0.08f, 0.1f, 0.1f, 0.14f) + arcToRelative(1.04f, 1.04f, 0f, isMoreThanHalf = false, isPositiveArc = false, -0.15f, -0.14f) + horizontalLineToRelative(0.01f) + close() + moveTo(399.68f, 153.32f) + curveToRelative(-3.09f, 2.93f, -7.74f, 4.3f, -12.33f, 4.98f) + curveToRelative(-51.54f, 7.64f, -103.83f, 11.52f, -155.94f, 9.81f) + curveToRelative(-37.29f, -1.27f, -74.19f, -5.41f, -111.11f, -10.63f) + curveToRelative(-3.62f, -0.51f, -7.54f, -1.17f, -10.02f, -3.84f) + curveToRelative(-4.69f, -5.03f, -2.38f, -15.15f, -1.16f, -21.23f) + curveToRelative(1.12f, -5.57f, 3.25f, -12.98f, 9.87f, -13.78f) + curveToRelative(10.34f, -1.21f, 22.34f, 3.15f, 32.56f, 4.7f) + arcToRelative(616.95f, 616.95f, 0f, isMoreThanHalf = false, isPositiveArc = false, 37.07f, 4.51f) + curveToRelative(52.93f, 4.82f, 106.75f, 4.07f, 159.45f, -2.98f) + arcToRelative(666.2f, 666.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 28.71f, -4.5f) + curveToRelative(8.5f, -1.52f, 17.92f, -4.38f, 23.05f, 4.41f) + curveToRelative(3.52f, 5.99f, 3.99f, 14.01f, 3.44f, 20.78f) + arcToRelative(11.59f, 11.59f, 0f, isMoreThanHalf = false, isPositiveArc = true, -3.6f, 7.76f) + horizontalLineToRelative(0.01f) + close() + } + } + }.build() + + return _BrandBuyMeACoffee!! + } + +@Suppress("ObjectPropertyName") +private var _BrandBuyMeACoffee: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandOpencollective.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandOpencollective.kt new file mode 100644 index 00000000..8f7a9291 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandOpencollective.kt @@ -0,0 +1,58 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.BrandOpencollective: ImageVector + get() { + if (_BrandOpencollective != null) { + return _BrandOpencollective!! + } + _BrandOpencollective = ImageVector.Builder( + name = "BrandOpencollective", + defaultWidth = 64.dp, + defaultHeight = 64.dp, + viewportWidth = 64f, + viewportHeight = 64f + ).apply { + path( + fill = SolidColor(Color(0xFFB8D3F4)), + pathFillType = PathFillType.EvenOdd + ) { + moveTo(52.402f, 31.916f) + curveToRelative(0f, 4.03f, -1.17f, 7.895f, -3.178f, 11.087f) + lineToRelative(8.196f, 8.23f) + curveToRelative(4.014f, -5.375f, 6.523f, -12.094f, 6.523f, -19.318f) + reflectiveCurveToRelative(-2.51f, -13.942f, -6.523f, -19.318f) + lineToRelative(-8.196f, 8.23f) + curveToRelative(2.007f, 3.192f, 3.178f, 6.887f, 3.178f, 11.087f) + close() + } + path( + fill = SolidColor(Color(0xFF3385FF)), + pathFillType = PathFillType.EvenOdd + ) { + moveTo(32.004f, 52.41f) + curveToRelative(-11.207f, 0f, -20.406f, -9.24f, -20.406f, -20.493f) + reflectiveCurveToRelative(9.2f, -20.493f, 20.406f, -20.493f) + curveToRelative(4.182f, 0f, 7.86f, 1.176f, 11.04f, 3.36f) + lineToRelative(8.196f, -8.23f) + curveTo(45.887f, 2.52f, 39.197f, 0f, 32.004f, 0f) + curveTo(14.44f, 0f, 0.057f, 14.278f, 0.057f, 32.084f) + reflectiveCurveTo(14.44f, 64f, 32.004f, 64f) + curveToRelative(7.36f, 0f, 14.05f, -2.52f, 19.403f, -6.55f) + lineToRelative(-8.196f, -8.23f) + curveToRelative(-3.178f, 2.016f, -7.025f, 3.192f, -11.207f, 3.192f) + close() + } + }.build() + + return _BrandOpencollective!! + } + +@Suppress("ObjectPropertyName") +private var _BrandOpencollective: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandPatreon.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandPatreon.kt new file mode 100644 index 00000000..85c5b7a9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandPatreon.kt @@ -0,0 +1,54 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.BrandPatreon: ImageVector + get() { + if (_BrandPatreon != null) { + return _BrandPatreon!! + } + _BrandPatreon = ImageVector.Builder( + name = "BrandPatreon", + defaultWidth = 512.dp, + defaultHeight = 512.dp, + viewportWidth = 512f, + viewportHeight = 512f + ).apply { + group( + clipPathData = PathData { + moveTo(0.38f, 0.42f) + lineToRelative(512f, 0f) + lineToRelative(0f, 512f) + lineTo(0.38f, 512.42f) + close() + } + ) { + path( + fill = SolidColor(Color.Black), + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(490.12f, 154.23f) + curveToRelative(-0.09f, -65.37f, -51.01f, -118.95f, -110.74f, -138.29f) + curveToRelative(-74.18f, -24.01f, -172.02f, -20.53f, -242.86f, 12.89f) + curveToRelative(-85.85f, 40.51f, -112.82f, 129.26f, -113.83f, 217.77f) + curveToRelative(-0.82f, 72.77f, 6.44f, 264.43f, 114.54f, 265.8f) + curveToRelative(80.33f, 1.02f, 92.29f, -102.48f, 129.45f, -152.33f) + curveToRelative(26.44f, -35.47f, 60.49f, -45.48f, 102.4f, -55.85f) + curveToRelative(72.03f, -17.83f, 121.13f, -74.68f, 121.03f, -149.99f) + close() + } + } + }.build() + + return _BrandPatreon!! + } + +@Suppress("ObjectPropertyName") +private var _BrandPatreon: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CarbonGithubLogo.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CarbonGithubLogo.kt new file mode 100644 index 00000000..969d05df --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CarbonGithubLogo.kt @@ -0,0 +1,57 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.CarbonGithubLogo: ImageVector + get() { + if (_CarbonGithubLogo != null) { + return _CarbonGithubLogo!! + } + _CarbonGithubLogo = ImageVector.Builder( + name = "CarbonGithubLogo", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 32f, + viewportHeight = 32f + ).apply { + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1f + ) { + moveTo(16f, 2f) + arcToRelative(14f, 14f, 0f, isMoreThanHalf = false, isPositiveArc = false, -4.43f, 27.28f) + curveToRelative(0.7f, 0.13f, 1f, -0.3f, 1f, -0.67f) + reflectiveCurveToRelative(0f, -1.21f, 0f, -2.38f) + curveToRelative(-3.89f, 0.84f, -4.71f, -1.88f, -4.71f, -1.88f) + arcTo(3.71f, 3.71f, 0f, isMoreThanHalf = false, isPositiveArc = false, 6.24f, 22.3f) + curveToRelative(-1.27f, -0.86f, 0.1f, -0.85f, 0.1f, -0.85f) + arcTo(2.94f, 2.94f, 0f, isMoreThanHalf = false, isPositiveArc = true, 8.48f, 22.9f) + arcToRelative(3f, 3f, 0f, isMoreThanHalf = false, isPositiveArc = false, 4.08f, 1.16f) + arcToRelative(2.93f, 2.93f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.88f, -1.87f) + curveToRelative(-3.1f, -0.36f, -6.37f, -1.56f, -6.37f, -6.92f) + arcToRelative(5.4f, 5.4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 1.44f, -3.76f) + arcToRelative(5f, 5f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.14f, -3.7f) + reflectiveCurveToRelative(1.17f, -0.38f, 3.85f, 1.43f) + arcToRelative(13.3f, 13.3f, 0f, isMoreThanHalf = false, isPositiveArc = true, 7f, 0f) + curveToRelative(2.67f, -1.81f, 3.84f, -1.43f, 3.84f, -1.43f) + arcToRelative(5f, 5f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.14f, 3.7f) + arcToRelative(5.4f, 5.4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 1.44f, 3.76f) + curveToRelative(0f, 5.38f, -3.27f, 6.56f, -6.39f, 6.91f) + arcToRelative(3.33f, 3.33f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.95f, 2.59f) + curveToRelative(0f, 1.87f, 0f, 3.38f, 0f, 3.84f) + reflectiveCurveToRelative(0.25f, 0.81f, 1f, 0.67f) + arcTo(14f, 14f, 0f, isMoreThanHalf = false, isPositiveArc = false, 16f, 2f) + close() + } + }.build() + + return _CarbonGithubLogo!! + } + +@Suppress("ObjectPropertyName") +private var _CarbonGithubLogo: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CustomServer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CustomServer.kt new file mode 100644 index 00000000..cbcaa164 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CustomServer.kt @@ -0,0 +1,150 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.CustomServer: ImageVector + get() { + if (_CustomServer != null) { + return _CustomServer!! + } + _CustomServer = ImageVector.Builder( + name = "CustomServer", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + // Layer 1: The solid, bright Status Check Badge (bottom right) + path(fill = SolidColor(Color.White)) { + moveTo(18.5f, 11f) + curveTo(14.91f, 11f, 12f, 13.91f, 12f, 17.5f) + curveTo(12f, 21.09f, 14.91f, 24f, 18.5f, 24f) + curveTo(22.09f, 24f, 25f, 21.09f, 25f, 17.5f) + curveTo(25f, 13.91f, 22.09f, 11f, 18.5f, 11f) + close() + // The cutout shape of the checkmark + moveTo(17.2f, 20.3f) + lineTo(14.7f, 17.8f) + curveTo(14.31f, 17.41f, 14.31f, 16.78f, 14.7f, 16.39f) + curveTo(15.09f, 16f, 15.72f, 16f, 16.11f, 16.39f) + lineTo(17.9f, 18.18f) + lineTo(21.39f, 14.69f) + curveTo(21.78f, 14.3f, 22.41f, 14.3f, 22.8f, 14.69f) + curveTo(23.19f, 15.08f, 23.19f, 15.71f, 22.8f, 16.1f) + lineTo(18.6f, 20.3f) + curveTo(18.21f, 20.69f, 17.59f, 20.69f, 17.2f, 20.3f) + close() + } + + // Layer 2: Main Server Rack Chassis (Top & Bottom shells with 40% opacity) + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + // Outer border of the double-slot server rack structure + moveTo(20.5f, 8.5f) + verticalLineTo(7f) + curveTo(20.5f, 3.5f, 18f, 1.5f, 13f, 1.5f) + horizontalLineTo(7f) + curveTo(3f, 1.5f, 1.5f, 3.5f, 1.5f, 7f) + verticalLineTo(9.5f) + curveTo(1.5f, 11.5f, 3f, 12f, 4.5f, 12f) + curveTo(3f, 12f, 1.5f, 12.5f, 1.5f, 14.5f) + verticalLineTo(17f) + curveTo(1.5f, 20.5f, 3f, 22.5f, 7f, 22.5f) + horizontalLineTo(10f) + curveTo(10.55f, 22.5f, 11f, 22.05f, 11f, 21.5f) + curveTo(11f, 20.95f, 10.55f, 20.5f, 10f, 20.5f) + horizontalLineTo(7f) + curveTo(4.5f, 20.5f, 3.5f, 19.5f, 3.5f, 17f) + verticalLineTo(14.5f) + curveTo(3.5f, 13f, 4.5f, 13f, 6f, 13f) + curveTo(6.55f, 13f, 7f, 12.55f, 7f, 12f) + curveTo(7f, 11.45f, 6.55f, 11f, 6f, 11f) + curveTo(4.5f, 11f, 3.5f, 11f, 3.5f, 9.5f) + verticalLineTo(7f) + curveTo(3.5f, 4.5f, 4.5f, 3.5f, 7f, 3.5f) + horizontalLineTo(13f) + curveTo(16.5f, 3.5f, 18.5f, 4.5f, 18.5f, 7f) + verticalLineTo(8.5f) + curveTo(18.5f, 9.05f, 18.95f, 9.5f, 19.5f, 9.5f) + curveTo(20.05f, 9.5f, 20.5f, 9.05f, 20.5f, 8.5f) + close() + } + + // Layer 3: Server Front Panel Slots, Buttons, and LEDs (100% white) + path(fill = SolidColor(Color.White)) { + // Top Slot LED 1 + moveTo(5.5f, 5.5f) + curveTo(6.05f, 5.5f, 6.5f, 5.95f, 6.5f, 6.5f) + verticalLineTo(7.5f) + curveTo(6.5f, 8.05f, 6.05f, 8.5f, 5.5f, 8.5f) + curveTo(4.95f, 8.5f, 4.5f, 8.05f, 4.5f, 7.5f) + verticalLineTo(6.5f) + curveTo(4.5f, 5.95f, 4.95f, 5.5f, 5.5f, 5.5f) + close() + + // Top Slot LED 2 + moveTo(8.5f, 5.5f) + curveTo(9.05f, 5.5f, 9.5f, 5.95f, 9.5f, 6.5f) + verticalLineTo(7.5f) + curveTo(9.5f, 8.05f, 9.05f, 8.5f, 8.5f, 8.5f) + curveTo(7.95f, 8.5f, 7.5f, 8.05f, 7.5f, 7.5f) + verticalLineTo(6.5f) + curveTo(7.5f, 5.95f, 7.95f, 5.5f, 8.5f, 5.5f) + close() + + // Top Slot Button Strip + moveTo(11.5f, 6f) + horizontalLineTo(16.5f) + curveTo(17.05f, 6f, 17.5f, 6.45f, 17.5f, 7f) + curveTo(17.5f, 7.55f, 17.05f, 8f, 16.5f, 8f) + horizontalLineTo(11.5f) + curveTo(10.95f, 8f, 10.5f, 7.55f, 10.5f, 7f) + curveTo(10.5f, 6.45f, 10.95f, 6f, 11.5f, 6f) + close() + + // Bottom Slot LED 1 + moveTo(5.5f, 14.5f) + curveTo(6.05f, 14.5f, 6.5f, 14.95f, 6.5f, 15.5f) + verticalLineTo(16.5f) + curveTo(6.5f, 17.05f, 6.05f, 17.5f, 5.5f, 17.5f) + curveTo(4.95f, 17.5f, 4.5f, 17.05f, 4.5f, 16.5f) + verticalLineTo(15.5f) + curveTo(4.5f, 14.95f, 4.95f, 14.5f, 5.5f, 14.5f) + close() + + // Bottom Slot LED 2 + moveTo(8.5f, 14.5f) + curveTo(9.05f, 14.5f, 9.5f, 14.95f, 9.5f, 15.5f) + verticalLineTo(16.5f) + curveTo(9.5f, 17.05f, 9.05f, 17.5f, 8.5f, 17.5f) + curveTo(7.95f, 17.5f, 7.5f, 17.05f, 7.5f, 16.5f) + verticalLineTo(15.5f) + curveTo(7.5f, 14.95f, 7.95f, 14.5f, 8.5f, 14.5f) + close() + } + } + }.build() + + return _CustomServer!! + } + +@Suppress("ObjectPropertyName") +private var _CustomServer: ImageVector? = null \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentDismiss.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentDismiss.kt new file mode 100644 index 00000000..58738d9f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentDismiss.kt @@ -0,0 +1,51 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.FluentDismiss: ImageVector + get() { + if (_FluentDismiss != null) { + return _FluentDismiss!! + } + _FluentDismiss = ImageVector.Builder( + name = "FluentDismiss", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color(0xFF212121))) { + moveToRelative(4.397f, 4.554f) + lineToRelative(0.073f, -0.084f) + arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.976f, -0.073f) + lineToRelative(0.084f, 0.073f) + lineTo(12f, 10.939f) + lineToRelative(6.47f, -6.47f) + arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = true, isPositiveArc = true, 1.06f, 1.061f) + lineTo(13.061f, 12f) + lineToRelative(6.47f, 6.47f) + arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.072f, 0.976f) + lineToRelative(-0.073f, 0.084f) + arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -0.976f, 0.073f) + lineToRelative(-0.084f, -0.073f) + lineTo(12f, 13.061f) + lineToRelative(-6.47f, 6.47f) + arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -1.06f, -1.061f) + lineTo(10.939f, 12f) + lineToRelative(-6.47f, -6.47f) + arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -0.072f, -0.976f) + lineToRelative(0.073f, -0.084f) + lineToRelative(-0.073f, 0.084f) + close() + } + }.build() + + return _FluentDismiss!! + } + +@Suppress("ObjectPropertyName") +private var _FluentDismiss: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMaximize.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMaximize.kt new file mode 100644 index 00000000..04a5cfd9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMaximize.kt @@ -0,0 +1,49 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.FluentMaximize: ImageVector + get() { + if (_FluentMaximize != null) { + return _FluentMaximize!! + } + _FluentMaximize = ImageVector.Builder( + name = "FluentMaximize", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color(0xFF212121))) { + moveTo(5.75f, 3f) + horizontalLineToRelative(12.5f) + arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 21f, 5.75f) + verticalLineToRelative(12.5f) + arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 18.25f, 21f) + lineTo(5.75f, 21f) + arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 3f, 18.25f) + lineTo(3f, 5.75f) + arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 5.75f, 3f) + close() + moveTo(5.75f, 4.5f) + curveToRelative(-0.69f, 0f, -1.25f, 0.56f, -1.25f, 1.25f) + verticalLineToRelative(12.5f) + curveToRelative(0f, 0.69f, 0.56f, 1.25f, 1.25f, 1.25f) + horizontalLineToRelative(12.5f) + curveToRelative(0.69f, 0f, 1.25f, -0.56f, 1.25f, -1.25f) + lineTo(19.5f, 5.75f) + curveToRelative(0f, -0.69f, -0.56f, -1.25f, -1.25f, -1.25f) + lineTo(5.75f, 4.5f) + close() + } + }.build() + + return _FluentMaximize!! + } + +@Suppress("ObjectPropertyName") +private var _FluentMaximize: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMinus.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMinus.kt new file mode 100644 index 00000000..e4fc342d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMinus.kt @@ -0,0 +1,35 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.FluentMinus: ImageVector + get() { + if (_FluentMinus != null) { + return _FluentMinus!! + } + _FluentMinus = ImageVector.Builder( + name = "FluentMinus", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color(0xFF212121))) { + moveTo(3.755f, 12.5f) + horizontalLineToRelative(16.492f) + arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0f, -1.5f) + horizontalLineTo(3.755f) + arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0f, 1.5f) + close() + } + }.build() + + return _FluentMinus!! + } + +@Suppress("ObjectPropertyName") +private var _FluentMinus: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentSquareMultiple.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentSquareMultiple.kt new file mode 100644 index 00000000..9693d2cf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentSquareMultiple.kt @@ -0,0 +1,63 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.FluentSquareMultiple: ImageVector + get() { + if (_FluentSquareMultiple != null) { + return _FluentSquareMultiple!! + } + _FluentSquareMultiple = ImageVector.Builder( + name = "FluentSquareMultiple", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color(0xFF212121))) { + moveTo(7.518f, 5f) + horizontalLineTo(6.009f) + arcToRelative(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = true, 3.24f, -3f) + horizontalLineToRelative(8.001f) + arcTo(4.75f, 4.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 22f, 6.75f) + verticalLineToRelative(8f) + arcToRelative(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = true, -3f, 3.24f) + verticalLineToRelative(-1.508f) + arcToRelative(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 1.5f, -1.732f) + verticalLineToRelative(-8f) + arcToRelative(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, -3.25f, -3.25f) + horizontalLineToRelative(-8f) + arcTo(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 7.518f, 5f) + close() + moveTo(5.25f, 6f) + arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 2f, 9.25f) + verticalLineToRelative(9.5f) + arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 5.25f, 22f) + horizontalLineToRelative(9.5f) + arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 18f, 18.75f) + verticalLineToRelative(-9.5f) + arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 14.75f, 6f) + horizontalLineToRelative(-9.5f) + close() + moveTo(3.5f, 9.25f) + curveToRelative(0f, -0.966f, 0.784f, -1.75f, 1.75f, -1.75f) + horizontalLineToRelative(9.5f) + curveToRelative(0.967f, 0f, 1.75f, 0.784f, 1.75f, 1.75f) + verticalLineToRelative(9.5f) + arcToRelative(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -1.75f, 1.75f) + horizontalLineToRelative(-9.5f) + arcToRelative(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -1.75f, -1.75f) + verticalLineToRelative(-9.5f) + close() + } + }.build() + + return _FluentSquareMultiple!! + } + +@Suppress("ObjectPropertyName") +private var _FluentSquareMultiple: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax.kt new file mode 100644 index 00000000..9ad18022 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax.kt @@ -0,0 +1,3 @@ +package org.spotube.spotube.resources.iconsax + +object Iconsax diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax3DotsMore.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax3DotsMore.kt new file mode 100644 index 00000000..cf4ec346 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax3DotsMore.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.Iconsax3DotsMore: ImageVector + get() { + if (_Iconsax3DotsMore != null) { + return _Iconsax3DotsMore!! + } + _Iconsax3DotsMore = ImageVector.Builder( + name = "Iconsax3DotsMore", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f + ) { + moveTo(5f, 10f) + curveTo(3.9f, 10f, 3f, 10.9f, 3f, 12f) + curveTo(3f, 13.1f, 3.9f, 14f, 5f, 14f) + curveTo(6.1f, 14f, 7f, 13.1f, 7f, 12f) + curveTo(7f, 10.9f, 6.1f, 10f, 5f, 10f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f + ) { + moveTo(19f, 10f) + curveTo(17.9f, 10f, 17f, 10.9f, 17f, 12f) + curveTo(17f, 13.1f, 17.9f, 14f, 19f, 14f) + curveTo(20.1f, 14f, 21f, 13.1f, 21f, 12f) + curveTo(21f, 10.9f, 20.1f, 10f, 19f, 10f) + close() + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f + ) { + moveTo(12f, 10f) + curveTo(10.9f, 10f, 10f, 10.9f, 10f, 12f) + curveTo(10f, 13.1f, 10.9f, 14f, 12f, 14f) + curveTo(13.1f, 14f, 14f, 13.1f, 14f, 12f) + curveTo(14f, 10.9f, 13.1f, 10f, 12f, 10f) + close() + } + } + }.build() + + return _Iconsax3DotsMore!! + } + +@Suppress("ObjectPropertyName") +private var _Iconsax3DotsMore: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAdd.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAdd.kt new file mode 100644 index 00000000..e7477dbe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAdd.kt @@ -0,0 +1,61 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxAdd: ImageVector + get() { + if (_IncosaxAdd != null) { + return _IncosaxAdd!! + } + _IncosaxAdd = ImageVector.Builder( + name = "IncosaxAdd", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(6f, 12f) + horizontalLineTo(18f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(12f, 18f) + verticalLineTo(6f) + } + } + }.build() + + return _IncosaxAdd!! + } + +@Suppress("ObjectPropertyName") +private var _IncosaxAdd: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAddSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAddSquare.kt new file mode 100644 index 00000000..82fd4fce --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAddSquare.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxAddSquare: ImageVector + get() { + if (_IconsaxAddSquare != null) { + return _IconsaxAddSquare!! + } + _IconsaxAddSquare = ImageVector.Builder( + name = "IconsaxAddSquare", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(14.99f, 12f) + horizontalLineTo(16f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(8f, 12f) + horizontalLineTo(11.81f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(12f, 16f) + verticalLineTo(8f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(2f, 13.04f) + verticalLineTo(15f) + curveTo(2f, 20f, 4f, 22f, 9f, 22f) + horizontalLineTo(15f) + curveTo(20f, 22f, 22f, 20f, 22f, 15f) + verticalLineTo(9f) + curveTo(22f, 4f, 20f, 2f, 15f, 2f) + horizontalLineTo(9f) + curveTo(4f, 2f, 2f, 4f, 2f, 9f) + } + } + }.build() + + return _IconsaxAddSquare!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxAddSquare: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowDown4.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowDown4.kt new file mode 100644 index 00000000..f022bea8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowDown4.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxArrowDown4: ImageVector + get() { + if (_IconsaxArrowDown4 != null) { + return _IconsaxArrowDown4!! + } + _IconsaxArrowDown4 = ImageVector.Builder( + name = "IconsaxArrowDown4", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(16.01f, 12.85f) + lineTo(13.39f, 15.47f) + curveTo(12.62f, 16.24f, 11.36f, 16.24f, 10.59f, 15.47f) + lineTo(4.08f, 8.95f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(19.92f, 8.95f) + lineTo(18.88f, 9.99f) + } + }.build() + + return _IconsaxArrowDown4!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxArrowDown4: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowSquareUp.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowSquareUp.kt new file mode 100644 index 00000000..5a6df3f6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowSquareUp.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxArrowSquareUp: ImageVector + get() { + if (_IconsaxArrowSquareUp != null) { + return _IconsaxArrowSquareUp!! + } + _IconsaxArrowSquareUp = ImageVector.Builder( + name = "IconsaxArrowSquareUp", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color.White)) { + moveTo(15f, 22.75f) + horizontalLineTo(9f) + curveTo(3.57f, 22.75f, 1.25f, 20.43f, 1.25f, 15f) + verticalLineTo(9f) + curveTo(1.25f, 3.57f, 3.57f, 1.25f, 9f, 1.25f) + horizontalLineTo(15f) + curveTo(20.43f, 1.25f, 22.75f, 3.57f, 22.75f, 9f) + verticalLineTo(15f) + curveTo(22.75f, 20.43f, 20.43f, 22.75f, 15f, 22.75f) + close() + moveTo(9f, 2.75f) + curveTo(4.39f, 2.75f, 2.75f, 4.39f, 2.75f, 9f) + verticalLineTo(15f) + curveTo(2.75f, 19.61f, 4.39f, 21.25f, 9f, 21.25f) + horizontalLineTo(15f) + curveTo(19.61f, 21.25f, 21.25f, 19.61f, 21.25f, 15f) + verticalLineTo(9f) + curveTo(21.25f, 4.39f, 19.61f, 2.75f, 15f, 2.75f) + horizontalLineTo(9f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(15.53f, 14.21f) + curveTo(15.34f, 14.21f, 15.15f, 14.14f, 15f, 13.99f) + lineTo(12f, 10.99f) + lineTo(9f, 13.99f) + curveTo(8.71f, 14.28f, 8.23f, 14.28f, 7.94f, 13.99f) + curveTo(7.65f, 13.7f, 7.65f, 13.22f, 7.94f, 12.93f) + lineTo(11.47f, 9.4f) + curveTo(11.76f, 9.11f, 12.24f, 9.11f, 12.53f, 9.4f) + lineTo(16.06f, 12.93f) + curveTo(16.35f, 13.22f, 16.35f, 13.7f, 16.06f, 13.99f) + curveTo(15.91f, 14.14f, 15.72f, 14.21f, 15.53f, 14.21f) + close() + } + }.build() + + return _IconsaxArrowSquareUp!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxArrowSquareUp: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBox.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBox.kt new file mode 100644 index 00000000..7b29531e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBox.kt @@ -0,0 +1,120 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxBox: ImageVector + get() { + if (_IconsaxBox != null) { + return _IconsaxBox!! + } + _IconsaxBox = ImageVector.Builder( + name = "IconsaxBox", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(20.1f, 6.94f) + curveTo(20.1f, 7.48f, 19.81f, 7.97f, 19.35f, 8.22f) + lineTo(17.61f, 9.16f) + lineTo(16.13f, 9.95f) + lineTo(13.06f, 11.61f) + curveTo(12.73f, 11.79f, 12.37f, 11.88f, 12f, 11.88f) + curveTo(11.63f, 11.88f, 11.27f, 11.79f, 10.94f, 11.61f) + lineTo(4.65f, 8.22f) + curveTo(4.19f, 7.97f, 3.9f, 7.48f, 3.9f, 6.94f) + curveTo(3.9f, 6.4f, 4.19f, 5.91f, 4.65f, 5.66f) + lineTo(6.62f, 4.6f) + lineTo(8.19f, 3.75f) + lineTo(10.94f, 2.27f) + curveTo(11.6f, 1.91f, 12.4f, 1.91f, 13.06f, 2.27f) + lineTo(19.35f, 5.66f) + curveTo(19.81f, 5.91f, 20.1f, 6.4f, 20.1f, 6.94f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(9.9f, 12.79f) + lineTo(4.05f, 9.86f) + curveTo(3.6f, 9.63f, 3.08f, 9.66f, 2.65f, 9.92f) + curveTo(2.22f, 10.18f, 1.97f, 10.64f, 1.97f, 11.14f) + verticalLineTo(16.67f) + curveTo(1.97f, 17.63f, 2.5f, 18.49f, 3.36f, 18.92f) + lineTo(9.21f, 21.84f) + curveTo(9.41f, 21.94f, 9.63f, 21.99f, 9.85f, 21.99f) + curveTo(10.11f, 21.99f, 10.37f, 21.92f, 10.6f, 21.77f) + curveTo(11.03f, 21.51f, 11.28f, 21.05f, 11.28f, 20.55f) + verticalLineTo(15.02f) + curveTo(11.29f, 14.08f, 10.76f, 13.22f, 9.9f, 12.79f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(22.03f, 11.15f) + verticalLineTo(16.68f) + curveTo(22.03f, 17.63f, 21.5f, 18.49f, 20.64f, 18.92f) + lineTo(14.79f, 21.85f) + curveTo(14.59f, 21.95f, 14.37f, 22f, 14.15f, 22f) + curveTo(13.89f, 22f, 13.63f, 21.93f, 13.39f, 21.78f) + curveTo(12.97f, 21.52f, 12.71f, 21.06f, 12.71f, 20.56f) + verticalLineTo(15.04f) + curveTo(12.71f, 14.08f, 13.24f, 13.22f, 14.1f, 12.79f) + lineTo(16.25f, 11.72f) + lineTo(17.75f, 10.97f) + lineTo(19.95f, 9.87f) + curveTo(20.4f, 9.64f, 20.92f, 9.66f, 21.35f, 9.93f) + curveTo(21.77f, 10.19f, 22.03f, 10.65f, 22.03f, 11.15f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(17.61f, 9.16f) + lineTo(16.13f, 9.95f) + lineTo(6.62f, 4.6f) + lineTo(8.19f, 3.75f) + lineTo(17.37f, 8.93f) + curveTo(17.47f, 8.99f, 17.55f, 9.07f, 17.61f, 9.16f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(17.75f, 10.971f) + verticalLineTo(13.241f) + curveTo(17.75f, 13.651f, 17.41f, 13.991f, 17f, 13.991f) + curveTo(16.59f, 13.991f, 16.25f, 13.651f, 16.25f, 13.241f) + verticalLineTo(11.721f) + lineTo(17.75f, 10.971f) + close() + } + } + }.build() + + return _IconsaxBox!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxBox: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBoxAdd.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBoxAdd.kt new file mode 100644 index 00000000..ddf2a7c5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBoxAdd.kt @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxBoxAdd: ImageVector + get() { + if (_IconsaxBoxAdd != null) { + return _IconsaxBoxAdd!! + } + _IconsaxBoxAdd = ImageVector.Builder( + name = "IconsaxBoxAdd", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(22f, 15.7f) + curveTo(22f, 15.69f, 21.99f, 15.68f, 21.98f, 15.67f) + curveTo(21.94f, 15.61f, 21.89f, 15.55f, 21.84f, 15.5f) + curveTo(21.83f, 15.49f, 21.82f, 15.47f, 21.81f, 15.46f) + curveTo(21f, 14.56f, 19.81f, 14f, 18.5f, 14f) + curveTo(17.24f, 14f, 16.09f, 14.52f, 15.27f, 15.36f) + curveTo(14.48f, 16.17f, 14f, 17.28f, 14f, 18.5f) + curveTo(14f, 19.34f, 14.24f, 20.14f, 14.65f, 20.82f) + curveTo(14.87f, 21.19f, 15.15f, 21.53f, 15.47f, 21.81f) + curveTo(15.49f, 21.82f, 15.5f, 21.83f, 15.51f, 21.84f) + curveTo(15.56f, 21.89f, 15.61f, 21.93f, 15.67f, 21.98f) + curveTo(15.67f, 21.98f, 15.67f, 21.98f, 15.68f, 21.98f) + curveTo(15.69f, 21.99f, 15.7f, 22f, 15.71f, 22f) + curveTo(16.46f, 22.63f, 17.43f, 23f, 18.5f, 23f) + curveTo(20.14f, 23f, 21.57f, 22.12f, 22.35f, 20.82f) + curveTo(22.58f, 20.43f, 22.76f, 20f, 22.87f, 19.55f) + curveTo(22.96f, 19.21f, 23f, 18.86f, 23f, 18.5f) + curveTo(23f, 17.44f, 22.63f, 16.46f, 22f, 15.7f) + close() + moveTo(20.18f, 19.23f) + horizontalLineTo(19.25f) + verticalLineTo(20.2f) + curveTo(19.25f, 20.61f, 18.91f, 20.95f, 18.5f, 20.95f) + curveTo(18.09f, 20.95f, 17.75f, 20.61f, 17.75f, 20.2f) + verticalLineTo(19.23f) + horizontalLineTo(16.82f) + curveTo(16.41f, 19.23f, 16.07f, 18.89f, 16.07f, 18.48f) + curveTo(16.07f, 18.07f, 16.41f, 17.73f, 16.82f, 17.73f) + horizontalLineTo(17.75f) + verticalLineTo(16.84f) + curveTo(17.75f, 16.43f, 18.09f, 16.09f, 18.5f, 16.09f) + curveTo(18.91f, 16.09f, 19.25f, 16.43f, 19.25f, 16.84f) + verticalLineTo(17.73f) + horizontalLineTo(20.18f) + curveTo(20.59f, 17.73f, 20.93f, 18.07f, 20.93f, 18.48f) + curveTo(20.93f, 18.89f, 20.6f, 19.23f, 20.18f, 19.23f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(19.35f, 5.66f) + lineTo(13.06f, 2.27f) + curveTo(12.4f, 1.91f, 11.6f, 1.91f, 10.93f, 2.27f) + lineTo(4.64f, 5.66f) + curveTo(4.18f, 5.91f, 3.9f, 6.4f, 3.9f, 6.94f) + curveTo(3.9f, 7.48f, 4.18f, 7.97f, 4.64f, 8.22f) + lineTo(10.93f, 11.61f) + curveTo(11.26f, 11.79f, 11.63f, 11.88f, 11.99f, 11.88f) + curveTo(12.35f, 11.88f, 12.72f, 11.79f, 13.05f, 11.61f) + lineTo(19.34f, 8.22f) + curveTo(19.8f, 7.97f, 20.08f, 7.48f, 20.08f, 6.94f) + curveTo(20.1f, 6.4f, 19.81f, 5.91f, 19.35f, 5.66f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(9.9f, 12.79f) + lineTo(4.05f, 9.86f) + curveTo(3.6f, 9.63f, 3.08f, 9.66f, 2.65f, 9.92f) + curveTo(2.22f, 10.18f, 1.97f, 10.64f, 1.97f, 11.14f) + verticalLineTo(16.67f) + curveTo(1.97f, 17.63f, 2.5f, 18.49f, 3.36f, 18.92f) + lineTo(9.21f, 21.84f) + curveTo(9.41f, 21.94f, 9.63f, 21.99f, 9.85f, 21.99f) + curveTo(10.11f, 21.99f, 10.37f, 21.92f, 10.6f, 21.77f) + curveTo(11.03f, 21.51f, 11.28f, 21.05f, 11.28f, 20.55f) + verticalLineTo(15.02f) + curveTo(11.29f, 14.08f, 10.76f, 13.22f, 9.9f, 12.79f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(22.03f, 11.15f) + verticalLineTo(15.74f) + curveTo(22.02f, 15.73f, 22.01f, 15.71f, 22f, 15.7f) + curveTo(22f, 15.69f, 21.99f, 15.68f, 21.98f, 15.67f) + curveTo(21.94f, 15.61f, 21.89f, 15.55f, 21.84f, 15.5f) + curveTo(21.83f, 15.49f, 21.82f, 15.47f, 21.81f, 15.46f) + curveTo(21f, 14.56f, 19.81f, 14f, 18.5f, 14f) + curveTo(17.24f, 14f, 16.09f, 14.52f, 15.27f, 15.36f) + curveTo(14.48f, 16.17f, 14f, 17.28f, 14f, 18.5f) + curveTo(14f, 19.34f, 14.24f, 20.14f, 14.65f, 20.82f) + curveTo(14.82f, 21.11f, 15.03f, 21.37f, 15.26f, 21.61f) + lineTo(14.79f, 21.85f) + curveTo(14.59f, 21.95f, 14.37f, 22f, 14.15f, 22f) + curveTo(13.89f, 22f, 13.63f, 21.93f, 13.39f, 21.78f) + curveTo(12.97f, 21.52f, 12.71f, 21.06f, 12.71f, 20.56f) + verticalLineTo(15.04f) + curveTo(12.71f, 14.08f, 13.24f, 13.22f, 14.1f, 12.79f) + lineTo(19.95f, 9.87f) + curveTo(20.4f, 9.64f, 20.92f, 9.66f, 21.35f, 9.93f) + curveTo(21.77f, 10.19f, 22.03f, 10.65f, 22.03f, 11.15f) + close() + } + } + }.build() + + return _IconsaxBoxAdd!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxBoxAdd: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCd.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCd.kt new file mode 100644 index 00000000..ae61ef25 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCd.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxCd: ImageVector + get() { + if (_IconsaxCd != null) { + return _IconsaxCd!! + } + _IconsaxCd = ImageVector.Builder( + name = "IconsaxCd", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 20f, + viewportHeight = 20f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(20f) + verticalLineToRelative(20f) + horizontalLineToRelative(-20f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(10f, 20f) + curveTo(15.523f, 20f, 20f, 15.523f, 20f, 10f) + curveTo(20f, 4.477f, 15.523f, 0f, 10f, 0f) + curveTo(4.477f, 0f, 0f, 4.477f, 0f, 10f) + curveTo(0f, 15.523f, 4.477f, 20f, 10f, 20f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(10f, 12.5f) + curveTo(11.381f, 12.5f, 12.5f, 11.381f, 12.5f, 10f) + curveTo(12.5f, 8.619f, 11.381f, 7.5f, 10f, 7.5f) + curveTo(8.619f, 7.5f, 7.5f, 8.619f, 7.5f, 10f) + curveTo(7.5f, 11.381f, 8.619f, 12.5f, 10f, 12.5f) + close() + } + } + }.build() + + return _IconsaxCd!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxCd: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckCircle.kt new file mode 100644 index 00000000..a7964c0e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckCircle.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxCheckCircle: ImageVector + get() { + if (_IconsaxCheckCircle != null) { + return _IconsaxCheckCircle!! + } + _IconsaxCheckCircle = ImageVector.Builder( + name = "IconsaxCheckCircle", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(12f, 22f) + curveTo(17.5f, 22f, 22f, 17.5f, 22f, 12f) + curveTo(22f, 6.5f, 17.5f, 2f, 12f, 2f) + curveTo(6.5f, 2f, 2f, 6.5f, 2f, 12f) + curveTo(2f, 17.5f, 6.5f, 22f, 12f, 22f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(7.75f, 12f) + lineTo(10.58f, 14.83f) + lineTo(16.25f, 9.17f) + } + } + }.build() + + return _IconsaxCheckCircle!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxCheckCircle: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckSquare.kt new file mode 100644 index 00000000..8085b005 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckSquare.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxCheckSquare: ImageVector + get() { + if (_IconsaxCheckSquare != null) { + return _IconsaxCheckSquare!! + } + _IconsaxCheckSquare = ImageVector.Builder( + name = "IconsaxCheckSquare", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(16.19f, 2f) + horizontalLineTo(7.81f) + curveTo(4.17f, 2f, 2f, 4.17f, 2f, 7.81f) + verticalLineTo(16.18f) + curveTo(2f, 19.83f, 4.17f, 22f, 7.81f, 22f) + horizontalLineTo(16.18f) + curveTo(19.82f, 22f, 21.99f, 19.83f, 21.99f, 16.19f) + verticalLineTo(7.81f) + curveTo(22f, 4.17f, 19.83f, 2f, 16.19f, 2f) + close() + moveTo(16.78f, 9.7f) + lineTo(11.11f, 15.37f) + curveTo(10.97f, 15.51f, 10.78f, 15.59f, 10.58f, 15.59f) + curveTo(10.38f, 15.59f, 10.19f, 15.51f, 10.05f, 15.37f) + lineTo(7.22f, 12.54f) + curveTo(6.93f, 12.25f, 6.93f, 11.77f, 7.22f, 11.48f) + curveTo(7.51f, 11.19f, 7.99f, 11.19f, 8.28f, 11.48f) + lineTo(10.58f, 13.78f) + lineTo(15.72f, 8.64f) + curveTo(16.01f, 8.35f, 16.49f, 8.35f, 16.78f, 8.64f) + curveTo(17.07f, 8.93f, 17.07f, 9.4f, 16.78f, 9.7f) + close() + } + } + }.build() + + return _IconsaxCheckSquare!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxCheckSquare: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCloseSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCloseSquare.kt new file mode 100644 index 00000000..9fed5ef1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCloseSquare.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxCloseSquare: ImageVector + get() { + if (_IconsaxCloseSquare != null) { + return _IconsaxCloseSquare!! + } + _IconsaxCloseSquare = ImageVector.Builder( + name = "IconsaxCloseSquare", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16.19f, 2f) + horizontalLineTo(7.81f) + curveTo(4.17f, 2f, 2f, 4.17f, 2f, 7.81f) + verticalLineTo(16.18f) + curveTo(2f, 19.83f, 4.17f, 22f, 7.81f, 22f) + horizontalLineTo(16.18f) + curveTo(19.82f, 22f, 21.99f, 19.83f, 21.99f, 16.19f) + verticalLineTo(7.81f) + curveTo(22f, 4.17f, 19.83f, 2f, 16.19f, 2f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(13.06f, 11.999f) + lineTo(15.36f, 9.699f) + curveTo(15.65f, 9.409f, 15.65f, 8.929f, 15.36f, 8.639f) + curveTo(15.07f, 8.349f, 14.59f, 8.349f, 14.3f, 8.639f) + lineTo(12f, 10.939f) + lineTo(9.7f, 8.639f) + curveTo(9.41f, 8.349f, 8.93f, 8.349f, 8.64f, 8.639f) + curveTo(8.35f, 8.929f, 8.35f, 9.409f, 8.64f, 9.699f) + lineTo(10.94f, 11.999f) + lineTo(8.64f, 14.299f) + curveTo(8.35f, 14.589f, 8.35f, 15.069f, 8.64f, 15.359f) + curveTo(8.79f, 15.509f, 8.98f, 15.579f, 9.17f, 15.579f) + curveTo(9.36f, 15.579f, 9.55f, 15.509f, 9.7f, 15.359f) + lineTo(12f, 13.059f) + lineTo(14.3f, 15.359f) + curveTo(14.45f, 15.509f, 14.64f, 15.579f, 14.83f, 15.579f) + curveTo(15.02f, 15.579f, 15.21f, 15.509f, 15.36f, 15.359f) + curveTo(15.65f, 15.069f, 15.65f, 14.589f, 15.36f, 14.299f) + lineTo(13.06f, 11.999f) + close() + } + } + }.build() + + return _IconsaxCloseSquare!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxCloseSquare: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorSwatch.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorSwatch.kt new file mode 100644 index 00000000..6884928c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorSwatch.kt @@ -0,0 +1,109 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxColorSwatch: ImageVector + get() { + if (_IconsaxColorSwatch != null) { + return _IconsaxColorSwatch!! + } + _IconsaxColorSwatch = ImageVector.Builder( + name = "IconsaxColorSwatch", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(22f, 16.5f) + verticalLineTo(19.5f) + curveTo(22f, 21f, 21f, 22f, 19.5f, 22f) + horizontalLineTo(6f) + curveTo(6.41f, 22f, 6.83f, 21.94f, 7.22f, 21.81f) + curveTo(7.33f, 21.77f, 7.44f, 21.73f, 7.55f, 21.68f) + curveTo(7.9f, 21.54f, 8.24f, 21.34f, 8.54f, 21.08f) + curveTo(8.63f, 21.01f, 8.73f, 20.92f, 8.82f, 20.83f) + lineTo(8.86f, 20.79f) + lineTo(15.66f, 14f) + horizontalLineTo(19.5f) + curveTo(21f, 14f, 22f, 15f, 22f, 16.5f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.6f, + strokeAlpha = 0.6f + ) { + moveTo(18.37f, 11.291f) + lineTo(15.66f, 14.001f) + lineTo(8.86f, 20.791f) + curveTo(9.56f, 20.071f, 10f, 19.081f, 10f, 18.001f) + verticalLineTo(8.341f) + lineTo(12.71f, 5.631f) + curveTo(13.77f, 4.571f, 15.19f, 4.571f, 16.25f, 5.631f) + lineTo(18.37f, 7.751f) + curveTo(19.43f, 8.811f, 19.43f, 10.231f, 18.37f, 11.291f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(7.5f, 2f) + horizontalLineTo(4.5f) + curveTo(3f, 2f, 2f, 3f, 2f, 4.5f) + verticalLineTo(18f) + curveTo(2f, 18.27f, 2.03f, 18.54f, 2.08f, 18.8f) + curveTo(2.11f, 18.93f, 2.14f, 19.06f, 2.18f, 19.19f) + curveTo(2.23f, 19.34f, 2.28f, 19.49f, 2.34f, 19.63f) + curveTo(2.35f, 19.64f, 2.35f, 19.65f, 2.35f, 19.65f) + curveTo(2.36f, 19.65f, 2.36f, 19.65f, 2.35f, 19.66f) + curveTo(2.49f, 19.94f, 2.65f, 20.21f, 2.84f, 20.46f) + curveTo(2.95f, 20.59f, 3.06f, 20.71f, 3.17f, 20.83f) + curveTo(3.28f, 20.95f, 3.4f, 21.05f, 3.53f, 21.15f) + lineTo(3.54f, 21.16f) + curveTo(3.79f, 21.35f, 4.06f, 21.51f, 4.34f, 21.65f) + curveTo(4.35f, 21.64f, 4.35f, 21.64f, 4.35f, 21.65f) + curveTo(4.5f, 21.72f, 4.65f, 21.77f, 4.81f, 21.82f) + curveTo(4.94f, 21.86f, 5.07f, 21.89f, 5.2f, 21.92f) + curveTo(5.46f, 21.97f, 5.73f, 22f, 6f, 22f) + curveTo(6.41f, 22f, 6.83f, 21.94f, 7.22f, 21.81f) + curveTo(7.33f, 21.77f, 7.44f, 21.73f, 7.55f, 21.68f) + curveTo(7.9f, 21.54f, 8.24f, 21.34f, 8.54f, 21.08f) + curveTo(8.63f, 21.01f, 8.73f, 20.92f, 8.82f, 20.83f) + lineTo(8.86f, 20.79f) + curveTo(9.56f, 20.07f, 10f, 19.08f, 10f, 18f) + verticalLineTo(4.5f) + curveTo(10f, 3f, 9f, 2f, 7.5f, 2f) + close() + moveTo(6f, 19.5f) + curveTo(5.17f, 19.5f, 4.5f, 18.83f, 4.5f, 18f) + curveTo(4.5f, 17.17f, 5.17f, 16.5f, 6f, 16.5f) + curveTo(6.83f, 16.5f, 7.5f, 17.17f, 7.5f, 18f) + curveTo(7.5f, 18.83f, 6.83f, 19.5f, 6f, 19.5f) + close() + } + } + }.build() + + return _IconsaxColorSwatch!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxColorSwatch: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorsSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorsSquare.kt new file mode 100644 index 00000000..f29df961 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorsSquare.kt @@ -0,0 +1,97 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxColorsSquare: ImageVector + get() { + if (_IconsaxColorsSquare != null) { + return _IconsaxColorsSquare!! + } + _IconsaxColorsSquare = ImageVector.Builder( + name = "IconsaxColorsSquare", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16.19f, 2f) + horizontalLineTo(7.82f) + curveTo(4.17f, 2f, 2f, 4.17f, 2f, 7.81f) + verticalLineTo(16.18f) + curveTo(2f, 19.82f, 4.17f, 21.99f, 7.81f, 21.99f) + horizontalLineTo(16.18f) + curveTo(19.82f, 21.99f, 21.99f, 19.82f, 21.99f, 16.18f) + verticalLineTo(7.81f) + curveTo(22f, 4.17f, 19.83f, 2f, 16.19f, 2f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.6f, + strokeAlpha = 0.6f + ) { + moveTo(13.2f, 14.4f) + curveTo(13.2f, 15.46f, 12.74f, 16.42f, 12f, 17.08f) + curveTo(11.36f, 17.66f, 10.52f, 18f, 9.6f, 18f) + curveTo(7.61f, 18f, 6f, 16.39f, 6f, 14.4f) + curveTo(6f, 12.74f, 7.13f, 11.34f, 8.65f, 10.93f) + curveTo(9.06f, 11.97f, 9.95f, 12.78f, 11.05f, 13.08f) + curveTo(11.35f, 13.16f, 11.67f, 13.21f, 12f, 13.21f) + curveTo(12.33f, 13.21f, 12.65f, 13.17f, 12.95f, 13.08f) + curveTo(13.11f, 13.48f, 13.2f, 13.93f, 13.2f, 14.4f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(15.6f, 9.6f) + curveTo(15.6f, 10.07f, 15.51f, 10.52f, 15.35f, 10.93f) + curveTo(14.94f, 11.97f, 14.05f, 12.78f, 12.95f, 13.08f) + curveTo(12.65f, 13.16f, 12.33f, 13.21f, 12f, 13.21f) + curveTo(11.67f, 13.21f, 11.35f, 13.17f, 11.05f, 13.08f) + curveTo(9.95f, 12.78f, 9.06f, 11.98f, 8.65f, 10.93f) + curveTo(8.49f, 10.52f, 8.4f, 10.07f, 8.4f, 9.6f) + curveTo(8.4f, 7.61f, 10.01f, 6f, 12f, 6f) + curveTo(13.99f, 6f, 15.6f, 7.61f, 15.6f, 9.6f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(18f, 14.4f) + curveTo(18f, 16.39f, 16.39f, 18f, 14.4f, 18f) + curveTo(13.48f, 18f, 12.64f, 17.65f, 12f, 17.08f) + curveTo(12.74f, 16.43f, 13.2f, 15.47f, 13.2f, 14.4f) + curveTo(13.2f, 13.93f, 13.11f, 13.48f, 12.95f, 13.07f) + curveTo(14.05f, 12.77f, 14.94f, 11.97f, 15.35f, 10.92f) + curveTo(16.87f, 11.34f, 18f, 12.74f, 18f, 14.4f) + close() + } + } + }.build() + + return _IconsaxColorsSquare!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxColorsSquare: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDirectboxReceive.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDirectboxReceive.kt new file mode 100644 index 00000000..e1b7caa6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDirectboxReceive.kt @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxDirectboxReceive: ImageVector + get() { + if (_IconsaxDirectboxReceive != null) { + return _IconsaxDirectboxReceive!! + } + _IconsaxDirectboxReceive = ImageVector.Builder( + name = "IconsaxDirectboxReceive", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(14.79f, 4f) + horizontalLineTo(9.21f) + curveTo(4.79f, 4f, 4.79f, 6.35f, 4.79f, 8.42f) + verticalLineTo(12.21f) + curveTo(4.79f, 12.43f, 4.89f, 12.63f, 5.06f, 12.76f) + curveTo(5.23f, 12.89f, 5.46f, 12.94f, 5.67f, 12.88f) + curveTo(6.12f, 12.76f, 6.68f, 12.7f, 7.35f, 12.7f) + curveTo(8.02f, 12.7f, 8.16f, 12.78f, 8.56f, 13.08f) + lineTo(9.47f, 14.04f) + curveTo(10.12f, 14.74f, 11.05f, 15.14f, 12.01f, 15.14f) + curveTo(12.97f, 15.14f, 13.89f, 14.74f, 14.55f, 14.04f) + lineTo(15.46f, 13.08f) + curveTo(15.86f, 12.78f, 16f, 12.7f, 16.67f, 12.7f) + curveTo(17.34f, 12.7f, 17.9f, 12.76f, 18.35f, 12.88f) + curveTo(18.56f, 12.94f, 18.78f, 12.89f, 18.96f, 12.76f) + curveTo(19.13f, 12.63f, 19.23f, 12.42f, 19.23f, 12.21f) + verticalLineTo(8.42f) + curveTo(19.21f, 6.35f, 19.21f, 4f, 14.79f, 4f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(14.275f, 6.8f) + curveTo(14.015f, 6.54f, 13.585f, 6.54f, 13.325f, 6.8f) + lineTo(12.675f, 7.45f) + verticalLineTo(2.67f) + curveTo(12.675f, 2.3f, 12.365f, 2f, 11.995f, 2f) + curveTo(11.625f, 2f, 11.315f, 2.3f, 11.315f, 2.67f) + verticalLineTo(7.44f) + lineTo(10.675f, 6.8f) + curveTo(10.415f, 6.54f, 9.985f, 6.54f, 9.725f, 6.8f) + curveTo(9.465f, 7.06f, 9.465f, 7.49f, 9.725f, 7.75f) + lineTo(11.525f, 9.55f) + curveTo(11.535f, 9.56f, 11.535f, 9.56f, 11.545f, 9.56f) + curveTo(11.605f, 9.61f, 11.665f, 9.66f, 11.745f, 9.69f) + curveTo(11.825f, 9.72f, 11.915f, 9.74f, 12.005f, 9.74f) + curveTo(12.095f, 9.74f, 12.175f, 9.72f, 12.265f, 9.69f) + curveTo(12.345f, 9.66f, 12.425f, 9.61f, 12.485f, 9.54f) + lineTo(14.285f, 7.74f) + curveTo(14.535f, 7.49f, 14.535f, 7.06f, 14.275f, 6.8f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(18.69f, 11.531f) + curveTo(18.12f, 11.381f, 17.45f, 11.301f, 16.65f, 11.301f) + curveTo(15.54f, 11.301f, 15.13f, 11.571f, 14.56f, 12.001f) + curveTo(14.53f, 12.021f, 14.5f, 12.051f, 14.47f, 12.081f) + lineTo(13.52f, 13.091f) + curveTo(12.72f, 13.931f, 11.28f, 13.941f, 10.48f, 13.081f) + lineTo(9.53f, 12.081f) + curveTo(9.5f, 12.051f, 9.47f, 12.021f, 9.44f, 12.001f) + curveTo(8.87f, 11.571f, 8.46f, 11.301f, 7.35f, 11.301f) + curveTo(6.55f, 11.301f, 5.88f, 11.381f, 5.31f, 11.531f) + curveTo(2.93f, 12.171f, 2.93f, 14.061f, 2.93f, 15.721f) + verticalLineTo(16.651f) + curveTo(2.93f, 19.161f, 2.93f, 22.001f, 8.28f, 22.001f) + horizontalLineTo(15.72f) + curveTo(19.27f, 22.001f, 21.07f, 20.201f, 21.07f, 16.651f) + verticalLineTo(15.721f) + curveTo(21.07f, 14.061f, 21.07f, 12.171f, 18.69f, 11.531f) + close() + moveTo(14.33f, 18.401f) + horizontalLineTo(9.67f) + curveTo(9.29f, 18.401f, 8.98f, 18.091f, 8.98f, 17.701f) + curveTo(8.98f, 17.311f, 9.29f, 17.001f, 9.67f, 17.001f) + horizontalLineTo(14.33f) + curveTo(14.71f, 17.001f, 15.02f, 17.311f, 15.02f, 17.701f) + curveTo(15.02f, 18.091f, 14.71f, 18.401f, 14.33f, 18.401f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(15.02f, 17.7f) + curveTo(15.02f, 18.09f, 14.71f, 18.4f, 14.33f, 18.4f) + horizontalLineTo(9.67f) + curveTo(9.29f, 18.4f, 8.98f, 18.09f, 8.98f, 17.7f) + curveTo(8.98f, 17.31f, 9.29f, 17f, 9.67f, 17f) + horizontalLineTo(14.33f) + curveTo(14.71f, 17f, 15.02f, 17.31f, 15.02f, 17.7f) + close() + } + } + }.build() + + return _IconsaxDirectboxReceive!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxDirectboxReceive: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentDownload.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentDownload.kt new file mode 100644 index 00000000..aa30f2f3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentDownload.kt @@ -0,0 +1,90 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxDocumentDownload: ImageVector + get() { + if (_IconsaxDocumentDownload != null) { + return _IconsaxDocumentDownload!! + } + _IconsaxDocumentDownload = ImageVector.Builder( + name = "IconsaxDocumentDownload", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(20.5f, 10.19f) + horizontalLineTo(17.61f) + curveTo(15.24f, 10.19f, 13.31f, 8.26f, 13.31f, 5.89f) + verticalLineTo(3f) + curveTo(13.31f, 2.45f, 12.86f, 2f, 12.31f, 2f) + horizontalLineTo(8.07f) + curveTo(4.99f, 2f, 2.5f, 4f, 2.5f, 7.57f) + verticalLineTo(16.43f) + curveTo(2.5f, 20f, 4.99f, 22f, 8.07f, 22f) + horizontalLineTo(15.93f) + curveTo(19.01f, 22f, 21.5f, 20f, 21.5f, 16.43f) + verticalLineTo(11.19f) + curveTo(21.5f, 10.64f, 21.05f, 10.19f, 20.5f, 10.19f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(15.8f, 2.21f) + curveTo(15.39f, 1.8f, 14.68f, 2.08f, 14.68f, 2.65f) + verticalLineTo(6.14f) + curveTo(14.68f, 7.6f, 15.92f, 8.81f, 17.43f, 8.81f) + curveTo(18.38f, 8.82f, 19.7f, 8.82f, 20.83f, 8.82f) + curveTo(21.4f, 8.82f, 21.7f, 8.15f, 21.3f, 7.75f) + curveTo(19.86f, 6.3f, 17.28f, 3.69f, 15.8f, 2.21f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12.28f, 14.72f) + curveTo(11.99f, 14.43f, 11.51f, 14.43f, 11.22f, 14.72f) + lineTo(10.5f, 15.44f) + verticalLineTo(11.25f) + curveTo(10.5f, 10.84f, 10.16f, 10.5f, 9.75f, 10.5f) + curveTo(9.34f, 10.5f, 9f, 10.84f, 9f, 11.25f) + verticalLineTo(15.44f) + lineTo(8.28f, 14.72f) + curveTo(7.99f, 14.43f, 7.51f, 14.43f, 7.22f, 14.72f) + curveTo(6.93f, 15.01f, 6.93f, 15.49f, 7.22f, 15.78f) + lineTo(9.22f, 17.78f) + curveTo(9.23f, 17.79f, 9.24f, 17.79f, 9.24f, 17.8f) + curveTo(9.3f, 17.86f, 9.38f, 17.91f, 9.46f, 17.95f) + curveTo(9.56f, 17.98f, 9.65f, 18f, 9.75f, 18f) + curveTo(9.85f, 18f, 9.94f, 17.98f, 10.03f, 17.94f) + curveTo(10.12f, 17.9f, 10.2f, 17.85f, 10.28f, 17.78f) + lineTo(12.28f, 15.78f) + curveTo(12.57f, 15.49f, 12.57f, 15.01f, 12.28f, 14.72f) + close() + } + } + }.build() + + return _IconsaxDocumentDownload!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxDocumentDownload: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentText.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentText.kt new file mode 100644 index 00000000..fae03e12 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentText.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxDocumentText: ImageVector + get() { + if (_IconsaxDocumentText != null) { + return _IconsaxDocumentText!! + } + _IconsaxDocumentText = ImageVector.Builder( + name = "IconsaxDocumentText", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(20.5f, 10.19f) + horizontalLineTo(17.61f) + curveTo(15.24f, 10.19f, 13.31f, 8.26f, 13.31f, 5.89f) + verticalLineTo(3f) + curveTo(13.31f, 2.45f, 12.86f, 2f, 12.31f, 2f) + horizontalLineTo(8.07f) + curveTo(4.99f, 2f, 2.5f, 4f, 2.5f, 7.57f) + verticalLineTo(16.43f) + curveTo(2.5f, 20f, 4.99f, 22f, 8.07f, 22f) + horizontalLineTo(15.93f) + curveTo(19.01f, 22f, 21.5f, 20f, 21.5f, 16.43f) + verticalLineTo(11.19f) + curveTo(21.5f, 10.64f, 21.05f, 10.19f, 20.5f, 10.19f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(15.8f, 2.21f) + curveTo(15.39f, 1.8f, 14.68f, 2.08f, 14.68f, 2.65f) + verticalLineTo(6.14f) + curveTo(14.68f, 7.6f, 15.92f, 8.81f, 17.43f, 8.81f) + curveTo(18.38f, 8.82f, 19.7f, 8.82f, 20.83f, 8.82f) + curveTo(21.4f, 8.82f, 21.7f, 8.15f, 21.3f, 7.75f) + curveTo(19.86f, 6.3f, 17.28f, 3.69f, 15.8f, 2.21f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(13.5f, 13.75f) + horizontalLineTo(7.5f) + curveTo(7.09f, 13.75f, 6.75f, 13.41f, 6.75f, 13f) + curveTo(6.75f, 12.59f, 7.09f, 12.25f, 7.5f, 12.25f) + horizontalLineTo(13.5f) + curveTo(13.91f, 12.25f, 14.25f, 12.59f, 14.25f, 13f) + curveTo(14.25f, 13.41f, 13.91f, 13.75f, 13.5f, 13.75f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(11.5f, 17.75f) + horizontalLineTo(7.5f) + curveTo(7.09f, 17.75f, 6.75f, 17.41f, 6.75f, 17f) + curveTo(6.75f, 16.59f, 7.09f, 16.25f, 7.5f, 16.25f) + horizontalLineTo(11.5f) + curveTo(11.91f, 16.25f, 12.25f, 16.59f, 12.25f, 17f) + curveTo(12.25f, 17.41f, 11.91f, 17.75f, 11.5f, 17.75f) + close() + } + } + }.build() + + return _IconsaxDocumentText!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxDocumentText: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDragHandle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDragHandle.kt new file mode 100644 index 00000000..fccc551e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDragHandle.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxDragHandle: ImageVector + get() { + if (_IconsaxDragHandle != null) { + return _IconsaxDragHandle!! + } + _IconsaxDragHandle = ImageVector.Builder( + name = "IconsaxDragHandle", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(8f, 4f) + curveTo(6.9f, 4f, 6f, 4.9f, 6f, 6f) + curveTo(6f, 7.1f, 6.9f, 8f, 8f, 8f) + curveTo(9.1f, 8f, 10f, 7.1f, 10f, 6f) + curveTo(10f, 4.9f, 9.1f, 4f, 8f, 4f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16f, 4f) + curveTo(14.9f, 4f, 14f, 4.9f, 14f, 6f) + curveTo(14f, 7.1f, 14.9f, 8f, 16f, 8f) + curveTo(17.1f, 8f, 18f, 7.1f, 18f, 6f) + curveTo(18f, 4.9f, 17.1f, 4f, 16f, 4f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(8f, 10f) + curveTo(6.9f, 10f, 6f, 10.9f, 6f, 12f) + curveTo(6f, 13.1f, 6.9f, 14f, 8f, 14f) + curveTo(9.1f, 14f, 10f, 13.1f, 10f, 12f) + curveTo(10f, 10.9f, 9.1f, 10f, 8f, 10f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16f, 10f) + curveTo(14.9f, 10f, 14f, 10.9f, 14f, 12f) + curveTo(14f, 13.1f, 14.9f, 14f, 16f, 14f) + curveTo(17.1f, 14f, 18f, 13.1f, 18f, 12f) + curveTo(18f, 10.9f, 17.1f, 10f, 16f, 10f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(8f, 16f) + curveTo(6.9f, 16f, 6f, 16.9f, 6f, 18f) + curveTo(6f, 19.1f, 6.9f, 20f, 8f, 20f) + curveTo(9.1f, 20f, 10f, 19.1f, 10f, 18f) + curveTo(10f, 16.9f, 9.1f, 16f, 8f, 16f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16f, 16f) + curveTo(14.9f, 16f, 14f, 16.9f, 14f, 18f) + curveTo(14f, 19.1f, 14.9f, 20f, 16f, 20f) + curveTo(17.1f, 20f, 18f, 19.1f, 18f, 18f) + curveTo(18f, 16.9f, 17.1f, 16f, 16f, 16f) + close() + } + } + }.build() + + return _IconsaxDragHandle!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxDragHandle: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEdit.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEdit.kt new file mode 100644 index 00000000..9bfd13c8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEdit.kt @@ -0,0 +1,92 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxEdit: ImageVector + get() { + if (_IconsaxEdit != null) { + return _IconsaxEdit!! + } + _IconsaxEdit = ImageVector.Builder( + name = "IconsaxEdit", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(15.48f, 3f) + horizontalLineTo(7.52f) + curveTo(4.07f, 3f, 2f, 5.06f, 2f, 8.52f) + verticalLineTo(16.47f) + curveTo(2f, 19.94f, 4.07f, 22f, 7.52f, 22f) + horizontalLineTo(15.47f) + curveTo(18.93f, 22f, 20.99f, 19.94f, 20.99f, 16.48f) + verticalLineTo(8.52f) + curveTo(21f, 5.06f, 18.93f, 3f, 15.48f, 3f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(21.02f, 2.98f) + curveTo(19.23f, 1.18f, 17.48f, 1.14f, 15.64f, 2.98f) + lineTo(14.51f, 4.1f) + curveTo(14.41f, 4.2f, 14.38f, 4.34f, 14.42f, 4.47f) + curveTo(15.12f, 6.92f, 17.08f, 8.88f, 19.53f, 9.58f) + curveTo(19.56f, 9.59f, 19.61f, 9.59f, 19.64f, 9.59f) + curveTo(19.74f, 9.59f, 19.84f, 9.55f, 19.91f, 9.48f) + lineTo(21.02f, 8.36f) + curveTo(21.93f, 7.45f, 22.38f, 6.58f, 22.38f, 5.69f) + curveTo(22.38f, 4.79f, 21.93f, 3.9f, 21.02f, 2.98f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(17.86f, 10.42f) + curveTo(17.59f, 10.29f, 17.33f, 10.16f, 17.09f, 10.01f) + curveTo(16.89f, 9.89f, 16.69f, 9.76f, 16.5f, 9.62f) + curveTo(16.34f, 9.52f, 16.16f, 9.37f, 15.98f, 9.22f) + curveTo(15.96f, 9.21f, 15.9f, 9.16f, 15.82f, 9.08f) + curveTo(15.51f, 8.83f, 15.18f, 8.49f, 14.87f, 8.12f) + curveTo(14.85f, 8.1f, 14.79f, 8.04f, 14.74f, 7.95f) + curveTo(14.64f, 7.84f, 14.49f, 7.65f, 14.36f, 7.44f) + curveTo(14.25f, 7.3f, 14.12f, 7.1f, 14f, 6.89f) + curveTo(13.85f, 6.64f, 13.72f, 6.39f, 13.6f, 6.13f) + curveTo(13.47f, 5.85f, 13.37f, 5.59f, 13.28f, 5.34f) + lineTo(7.9f, 10.72f) + curveTo(7.55f, 11.07f, 7.21f, 11.73f, 7.14f, 12.22f) + lineTo(6.71f, 15.2f) + curveTo(6.62f, 15.83f, 6.79f, 16.42f, 7.18f, 16.81f) + curveTo(7.51f, 17.14f, 7.96f, 17.31f, 8.46f, 17.31f) + curveTo(8.57f, 17.31f, 8.68f, 17.3f, 8.79f, 17.29f) + lineTo(11.76f, 16.87f) + curveTo(12.25f, 16.8f, 12.91f, 16.47f, 13.26f, 16.11f) + lineTo(18.64f, 10.73f) + curveTo(18.39f, 10.65f, 18.14f, 10.54f, 17.86f, 10.42f) + close() + } + } + }.build() + + return _IconsaxEdit!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxEdit: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxExportArrowBulk.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxExportArrowBulk.kt new file mode 100644 index 00000000..6871aae6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxExportArrowBulk.kt @@ -0,0 +1,61 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxExportArrowBulk: ImageVector + get() { + if (_IconsaxExportArrowBulk != null) { + return _IconsaxExportArrowBulk!! + } + _IconsaxExportArrowBulk = ImageVector.Builder( + name = "IconsaxExportArrowBulk", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16.8f, 9f) + horizontalLineTo(7.2f) + curveTo(4f, 9f, 2f, 11f, 2f, 14.2f) + verticalLineTo(16.79f) + curveTo(2f, 20f, 4f, 22f, 7.2f, 22f) + horizontalLineTo(16.79f) + curveTo(19.99f, 22f, 21.99f, 20f, 21.99f, 16.8f) + verticalLineTo(14.2f) + curveTo(22f, 11f, 20f, 9f, 16.8f, 9f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(15.88f, 5.57f) + lineTo(12.53f, 2.22f) + curveTo(12.24f, 1.93f, 11.76f, 1.93f, 11.47f, 2.22f) + lineTo(8.12f, 5.57f) + curveTo(7.83f, 5.86f, 7.83f, 6.34f, 8.12f, 6.63f) + curveTo(8.41f, 6.92f, 8.89f, 6.92f, 9.18f, 6.63f) + lineTo(11.25f, 4.56f) + verticalLineTo(15.25f) + curveTo(11.25f, 15.66f, 11.59f, 16f, 12f, 16f) + curveTo(12.41f, 16f, 12.75f, 15.66f, 12.75f, 15.25f) + verticalLineTo(4.56f) + lineTo(14.82f, 6.63f) + curveTo(14.97f, 6.78f, 15.16f, 6.85f, 15.35f, 6.85f) + curveTo(15.54f, 6.85f, 15.73f, 6.78f, 15.88f, 6.63f) + curveTo(16.18f, 6.34f, 16.18f, 5.87f, 15.88f, 5.57f) + close() + } + }.build() + + return _IconsaxExportArrowBulk!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxExportArrowBulk: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEye.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEye.kt new file mode 100644 index 00000000..49d58458 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEye.kt @@ -0,0 +1,69 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxEye: ImageVector + get() { + if (_IconsaxEye != null) { + return _IconsaxEye!! + } + _IconsaxEye = ImageVector.Builder( + name = "IconsaxEye", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(21.25f, 9.15f) + curveTo(18.94f, 5.52f, 15.56f, 3.43f, 12f, 3.43f) + curveTo(10.22f, 3.43f, 8.49f, 3.95f, 6.91f, 4.92f) + curveTo(5.33f, 5.9f, 3.91f, 7.33f, 2.75f, 9.15f) + curveTo(1.75f, 10.72f, 1.75f, 13.27f, 2.75f, 14.84f) + curveTo(5.06f, 18.48f, 8.44f, 20.56f, 12f, 20.56f) + curveTo(13.78f, 20.56f, 15.51f, 20.04f, 17.09f, 19.07f) + curveTo(18.67f, 18.09f, 20.09f, 16.66f, 21.25f, 14.84f) + curveTo(22.25f, 13.28f, 22.25f, 10.72f, 21.25f, 9.15f) + close() + moveTo(12f, 16.04f) + curveTo(9.76f, 16.04f, 7.96f, 14.23f, 7.96f, 12f) + curveTo(7.96f, 9.77f, 9.76f, 7.96f, 12f, 7.96f) + curveTo(14.24f, 7.96f, 16.04f, 9.77f, 16.04f, 12f) + curveTo(16.04f, 14.23f, 14.24f, 16.04f, 12f, 16.04f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 9.141f) + curveTo(10.43f, 9.141f, 9.15f, 10.421f, 9.15f, 12.001f) + curveTo(9.15f, 13.571f, 10.43f, 14.851f, 12f, 14.851f) + curveTo(13.57f, 14.851f, 14.86f, 13.571f, 14.86f, 12.001f) + curveTo(14.86f, 10.431f, 13.57f, 9.141f, 12f, 9.141f) + close() + } + } + }.build() + + return _IconsaxEye!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxEye: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFilterSearch.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFilterSearch.kt new file mode 100644 index 00000000..8176e8e3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFilterSearch.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxFilterSearch: ImageVector + get() { + if (_IconsaxFilterSearch != null) { + return _IconsaxFilterSearch!! + } + _IconsaxFilterSearch = ImageVector.Builder( + name = "IconsaxFilterSearch", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(19.75f, 15.41f) + lineTo(18.9f, 14.56f) + curveTo(19.34f, 13.89f, 19.6f, 13.1f, 19.6f, 12.24f) + curveTo(19.6f, 9.9f, 17.7f, 8f, 15.36f, 8f) + curveTo(13.02f, 8f, 11.12f, 9.9f, 11.12f, 12.24f) + curveTo(11.12f, 14.58f, 13.02f, 16.48f, 15.36f, 16.48f) + curveTo(16.22f, 16.48f, 17.02f, 16.22f, 17.68f, 15.78f) + lineTo(18.53f, 16.63f) + curveTo(18.7f, 16.8f, 18.92f, 16.88f, 19.14f, 16.88f) + curveTo(19.36f, 16.88f, 19.58f, 16.8f, 19.75f, 16.63f) + curveTo(20.08f, 16.29f, 20.08f, 15.74f, 19.75f, 15.41f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(5.41f, 2f) + horizontalLineTo(18.58f) + curveTo(19.68f, 2f, 20.58f, 2.91f, 20.58f, 4.02f) + verticalLineTo(6.24f) + curveTo(20.58f, 7.05f, 20.08f, 8.06f, 19.58f, 8.56f) + lineTo(15.29f, 12.4f) + curveTo(14.69f, 12.91f, 14.29f, 13.92f, 14.29f, 14.72f) + verticalLineTo(19.06f) + curveTo(14.29f, 19.67f, 13.89f, 20.47f, 13.39f, 20.78f) + lineTo(11.99f, 21.69f) + curveTo(10.69f, 22.5f, 8.9f, 21.59f, 8.9f, 19.97f) + verticalLineTo(14.62f) + curveTo(8.9f, 13.91f, 8.5f, 13f, 8.1f, 12.5f) + lineTo(4.31f, 8.46f) + curveTo(3.81f, 7.95f, 3.41f, 7.05f, 3.41f, 6.44f) + verticalLineTo(4.12f) + curveTo(3.42f, 2.91f, 4.32f, 2f, 5.41f, 2f) + close() + } + } + }.build() + + return _IconsaxFilterSearch!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxFilterSearch: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolder.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolder.kt new file mode 100644 index 00000000..6b665f45 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolder.kt @@ -0,0 +1,68 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxFolder: ImageVector + get() { + if (_IconsaxFolder != null) { + return _IconsaxFolder!! + } + _IconsaxFolder = ImageVector.Builder( + name = "IconsaxFolder", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(22f, 11.07f) + verticalLineTo(16.65f) + curveTo(22f, 19.6f, 19.6f, 22f, 16.65f, 22f) + horizontalLineTo(7.35f) + curveTo(4.4f, 22f, 2f, 19.6f, 2f, 16.65f) + verticalLineTo(9.44f) + horizontalLineTo(21.74f) + curveTo(21.89f, 9.89f, 21.97f, 10.35f, 21.99f, 10.84f) + curveTo(22f, 10.91f, 22f, 11f, 22f, 11.07f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(21.74f, 9.44f) + horizontalLineTo(2f) + verticalLineTo(6.42f) + curveTo(2f, 3.98f, 3.98f, 2f, 6.42f, 2f) + horizontalLineTo(8.75f) + curveTo(10.38f, 2f, 10.89f, 2.53f, 11.54f, 3.4f) + lineTo(12.94f, 5.26f) + curveTo(13.25f, 5.67f, 13.29f, 5.73f, 13.87f, 5.73f) + horizontalLineTo(16.66f) + curveTo(19.03f, 5.72f, 21.05f, 7.28f, 21.74f, 9.44f) + close() + } + } + }.build() + + return _IconsaxFolder!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxFolder: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderAdd.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderAdd.kt new file mode 100644 index 00000000..07f8b64c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderAdd.kt @@ -0,0 +1,86 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxFolderAdd: ImageVector + get() { + if (_IconsaxFolderAdd != null) { + return _IconsaxFolderAdd!! + } + _IconsaxFolderAdd = ImageVector.Builder( + name = "IconsaxFolderAdd", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(21.74f, 9.44f) + horizontalLineTo(2f) + verticalLineTo(6.42f) + curveTo(2f, 3.98f, 3.98f, 2f, 6.42f, 2f) + horizontalLineTo(8.75f) + curveTo(10.38f, 2f, 10.89f, 2.53f, 11.54f, 3.4f) + lineTo(12.94f, 5.26f) + curveTo(13.25f, 5.67f, 13.29f, 5.73f, 13.87f, 5.73f) + horizontalLineTo(16.66f) + curveTo(19.03f, 5.72f, 21.05f, 7.28f, 21.74f, 9.44f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(21.99f, 10.84f) + curveTo(21.97f, 10.35f, 21.89f, 9.89f, 21.74f, 9.44f) + horizontalLineTo(2f) + verticalLineTo(16.65f) + curveTo(2f, 19.6f, 4.4f, 22f, 7.35f, 22f) + horizontalLineTo(16.65f) + curveTo(19.6f, 22f, 22f, 19.6f, 22f, 16.65f) + verticalLineTo(11.07f) + curveTo(22f, 11f, 22f, 10.91f, 21.99f, 10.84f) + close() + moveTo(14.5f, 16.25f) + horizontalLineTo(12.81f) + verticalLineTo(18f) + curveTo(12.81f, 18.41f, 12.47f, 18.75f, 12.06f, 18.75f) + curveTo(11.65f, 18.75f, 11.31f, 18.41f, 11.31f, 18f) + verticalLineTo(16.25f) + horizontalLineTo(9.5f) + curveTo(9.09f, 16.25f, 8.75f, 15.91f, 8.75f, 15.5f) + curveTo(8.75f, 15.09f, 9.09f, 14.75f, 9.5f, 14.75f) + horizontalLineTo(11.31f) + verticalLineTo(13f) + curveTo(11.31f, 12.59f, 11.65f, 12.25f, 12.06f, 12.25f) + curveTo(12.47f, 12.25f, 12.81f, 12.59f, 12.81f, 13f) + verticalLineTo(14.75f) + horizontalLineTo(14.5f) + curveTo(14.91f, 14.75f, 15.25f, 15.09f, 15.25f, 15.5f) + curveTo(15.25f, 15.91f, 14.91f, 16.25f, 14.5f, 16.25f) + close() + } + } + }.build() + + return _IconsaxFolderAdd!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxFolderAdd: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderOpen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderOpen.kt new file mode 100644 index 00000000..2c01af64 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderOpen.kt @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxFolderOpen: ImageVector + get() { + if (_IconsaxFolderOpen != null) { + return _IconsaxFolderOpen!! + } + _IconsaxFolderOpen = ImageVector.Builder( + name = "IconsaxFolderOpen", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f + ) { + moveTo(21.67f, 14.3f) + lineTo(21.27f, 19.3f) + curveTo(21.12f, 20.83f, 21f, 22f, 18.29f, 22f) + horizontalLineTo(5.71f) + curveTo(3f, 22f, 2.88f, 20.83f, 2.73f, 19.3f) + lineTo(2.33f, 14.3f) + curveTo(2.25f, 13.47f, 2.51f, 12.7f, 2.98f, 12.11f) + curveTo(2.99f, 12.1f, 2.99f, 12.1f, 3f, 12.09f) + curveTo(3.55f, 11.42f, 4.38f, 11f, 5.31f, 11f) + horizontalLineTo(18.69f) + curveTo(19.62f, 11f, 20.44f, 11.42f, 20.98f, 12.07f) + curveTo(20.99f, 12.08f, 21f, 12.09f, 21f, 12.1f) + curveTo(21.49f, 12.69f, 21.76f, 13.46f, 21.67f, 14.3f) + close() + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(3.5f, 11.43f) + verticalLineTo(6.28f) + curveTo(3.5f, 2.88f, 4.35f, 2.03f, 7.75f, 2.03f) + horizontalLineTo(9.02f) + curveTo(10.29f, 2.03f, 10.58f, 2.41f, 11.06f, 3.05f) + lineTo(12.33f, 4.75f) + curveTo(12.65f, 5.17f, 12.84f, 5.43f, 13.69f, 5.43f) + horizontalLineTo(16.24f) + curveTo(19.64f, 5.43f, 20.49f, 6.28f, 20.49f, 9.68f) + verticalLineTo(11.47f) + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(9.43f, 17f) + horizontalLineTo(14.57f) + } + } + }.build() + + return _IconsaxFolderOpen!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxFolderOpen: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxForbidden.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxForbidden.kt new file mode 100644 index 00000000..832ba1db --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxForbidden.kt @@ -0,0 +1,74 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxForbidden: ImageVector + get() { + if (_IconsaxForbidden != null) { + return _IconsaxForbidden!! + } + _IconsaxForbidden = ImageVector.Builder( + name = "IconsaxForbidden", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(14.9f, 2f) + horizontalLineTo(9.1f) + curveTo(8.42f, 2f, 7.46f, 2.4f, 6.98f, 2.88f) + lineTo(2.88f, 6.98f) + curveTo(2.4f, 7.46f, 2f, 8.42f, 2f, 9.1f) + verticalLineTo(14.9f) + curveTo(2f, 15.58f, 2.4f, 16.54f, 2.88f, 17.02f) + lineTo(6.98f, 21.12f) + curveTo(7.46f, 21.6f, 8.42f, 22f, 9.1f, 22f) + horizontalLineTo(14.9f) + curveTo(15.58f, 22f, 16.54f, 21.6f, 17.02f, 21.12f) + lineTo(21.12f, 17.02f) + curveTo(21.6f, 16.54f, 22f, 15.58f, 22f, 14.9f) + verticalLineTo(9.1f) + curveTo(22f, 8.42f, 21.6f, 7.46f, 21.12f, 6.98f) + lineTo(17.02f, 2.88f) + curveTo(16.54f, 2.4f, 15.58f, 2f, 14.9f, 2f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(3.02f, 21.751f) + curveTo(2.83f, 21.751f, 2.64f, 21.681f, 2.49f, 21.531f) + curveTo(2.2f, 21.241f, 2.2f, 20.761f, 2.49f, 20.471f) + lineTo(20.47f, 2.491f) + curveTo(20.76f, 2.201f, 21.24f, 2.201f, 21.53f, 2.491f) + curveTo(21.82f, 2.781f, 21.82f, 3.261f, 21.53f, 3.551f) + lineTo(3.55f, 21.531f) + curveTo(3.41f, 21.681f, 3.22f, 21.751f, 3.02f, 21.751f) + close() + } + } + }.build() + + return _IconsaxForbidden!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxForbidden: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFormatCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFormatCircle.kt new file mode 100644 index 00000000..b9070c2c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFormatCircle.kt @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxFormatCircle: ImageVector + get() { + if (_IconsaxFormatCircle != null) { + return _IconsaxFormatCircle!! + } + _IconsaxFormatCircle = ImageVector.Builder( + name = "IconsaxFormatCircle", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(21.5f, 5.35f) + curveTo(21.5f, 6.26f, 21.07f, 7.07f, 20.41f, 7.59f) + curveTo(19.93f, 7.97f, 19.32f, 8.2f, 18.65f, 8.2f) + curveTo(17.07f, 8.2f, 15.8f, 6.93f, 15.8f, 5.35f) + curveTo(15.8f, 4.68f, 16.03f, 4.08f, 16.41f, 3.59f) + horizontalLineTo(16.42f) + curveTo(16.93f, 2.93f, 17.74f, 2.5f, 18.65f, 2.5f) + curveTo(20.23f, 2.5f, 21.5f, 3.77f, 21.5f, 5.35f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(8.2f, 5.35f) + curveTo(8.2f, 6.93f, 6.93f, 8.2f, 5.35f, 8.2f) + curveTo(4.68f, 8.2f, 4.08f, 7.97f, 3.59f, 7.59f) + curveTo(2.93f, 7.07f, 2.5f, 6.26f, 2.5f, 5.35f) + curveTo(2.5f, 3.77f, 3.77f, 2.5f, 5.35f, 2.5f) + curveTo(6.26f, 2.5f, 7.07f, 2.93f, 7.59f, 3.59f) + curveTo(7.97f, 4.08f, 8.2f, 4.68f, 8.2f, 5.35f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(21.5f, 18.651f) + curveTo(21.5f, 20.231f, 20.23f, 21.501f, 18.65f, 21.501f) + curveTo(17.74f, 21.501f, 16.93f, 21.071f, 16.42f, 20.411f) + horizontalLineTo(16.41f) + curveTo(16.03f, 19.931f, 15.8f, 19.321f, 15.8f, 18.651f) + curveTo(15.8f, 17.071f, 17.07f, 15.801f, 18.65f, 15.801f) + curveTo(19.32f, 15.801f, 19.92f, 16.031f, 20.41f, 16.411f) + verticalLineTo(16.421f) + curveTo(21.07f, 16.931f, 21.5f, 17.741f, 21.5f, 18.651f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(8.2f, 18.651f) + curveTo(8.2f, 19.321f, 7.97f, 19.921f, 7.59f, 20.411f) + curveTo(7.07f, 21.081f, 6.26f, 21.501f, 5.35f, 21.501f) + curveTo(3.77f, 21.501f, 2.5f, 20.231f, 2.5f, 18.651f) + curveTo(2.5f, 17.741f, 2.93f, 16.931f, 3.59f, 16.421f) + verticalLineTo(16.411f) + curveTo(4.07f, 16.031f, 4.68f, 15.801f, 5.35f, 15.801f) + curveTo(6.93f, 15.801f, 8.2f, 17.071f, 8.2f, 18.651f) + close() + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(21.5f, 12f) + curveTo(21.5f, 13.6f, 21.11f, 15.09f, 20.41f, 16.41f) + curveTo(19.93f, 16.03f, 19.32f, 15.8f, 18.65f, 15.8f) + curveTo(17.07f, 15.8f, 15.8f, 17.07f, 15.8f, 18.65f) + curveTo(15.8f, 19.32f, 16.03f, 19.92f, 16.41f, 20.41f) + curveTo(15.09f, 21.11f, 13.6f, 21.5f, 12f, 21.5f) + curveTo(10.41f, 21.5f, 8.91f, 21.11f, 7.59f, 20.41f) + curveTo(7.97f, 19.93f, 8.2f, 19.32f, 8.2f, 18.65f) + curveTo(8.2f, 17.07f, 6.93f, 15.8f, 5.35f, 15.8f) + curveTo(4.68f, 15.8f, 4.08f, 16.03f, 3.59f, 16.41f) + curveTo(2.89f, 15.09f, 2.5f, 13.6f, 2.5f, 12f) + curveTo(2.5f, 10.41f, 2.89f, 8.91f, 3.59f, 7.59f) + curveTo(4.08f, 7.97f, 4.68f, 8.2f, 5.35f, 8.2f) + curveTo(6.93f, 8.2f, 8.2f, 6.93f, 8.2f, 5.35f) + curveTo(8.2f, 4.68f, 7.97f, 4.08f, 7.59f, 3.59f) + curveTo(8.91f, 2.89f, 10.41f, 2.5f, 12f, 2.5f) + curveTo(13.6f, 2.5f, 15.09f, 2.89f, 16.41f, 3.59f) + curveTo(16.03f, 4.07f, 15.8f, 4.68f, 15.8f, 5.35f) + curveTo(15.8f, 6.93f, 17.07f, 8.2f, 18.65f, 8.2f) + curveTo(19.32f, 8.2f, 19.92f, 7.97f, 20.41f, 7.59f) + curveTo(21.11f, 8.91f, 21.5f, 10.41f, 21.5f, 12f) + close() + } + } + }.build() + + return _IconsaxFormatCircle!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxFormatCircle: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxGlobe.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxGlobe.kt new file mode 100644 index 00000000..2e05c51d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxGlobe.kt @@ -0,0 +1,140 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxGlobe: ImageVector + get() { + if (_IconsaxGlobe != null) { + return _IconsaxGlobe!! + } + _IconsaxGlobe = ImageVector.Builder( + name = "IconsaxGlobe", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(7.65f, 20.91f) + curveTo(7.62f, 20.91f, 7.58f, 20.93f, 7.55f, 20.93f) + curveTo(5.61f, 19.97f, 4.03f, 18.38f, 3.06f, 16.44f) + curveTo(3.06f, 16.41f, 3.08f, 16.37f, 3.08f, 16.34f) + curveTo(4.3f, 16.7f, 5.56f, 16.97f, 6.81f, 17.18f) + curveTo(7.03f, 18.44f, 7.29f, 19.69f, 7.65f, 20.91f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(20.94f, 16.45f) + curveTo(19.95f, 18.44f, 18.3f, 20.05f, 16.29f, 21.02f) + curveTo(16.67f, 19.75f, 16.99f, 18.47f, 17.2f, 17.18f) + curveTo(18.46f, 16.97f, 19.7f, 16.7f, 20.92f, 16.34f) + curveTo(20.91f, 16.38f, 20.94f, 16.42f, 20.94f, 16.45f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(21.02f, 7.71f) + curveTo(19.76f, 7.33f, 18.49f, 7.02f, 17.2f, 6.8f) + curveTo(16.99f, 5.51f, 16.68f, 4.23f, 16.29f, 2.98f) + curveTo(18.36f, 3.97f, 20.03f, 5.64f, 21.02f, 7.71f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(7.65f, 3.091f) + curveTo(7.29f, 4.311f, 7.03f, 5.551f, 6.82f, 6.811f) + curveTo(5.53f, 7.011f, 4.25f, 7.331f, 2.98f, 7.711f) + curveTo(3.95f, 5.701f, 5.56f, 4.051f, 7.55f, 3.061f) + curveTo(7.58f, 3.061f, 7.62f, 3.091f, 7.65f, 3.091f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(15.49f, 6.59f) + curveTo(13.17f, 6.33f, 10.83f, 6.33f, 8.51f, 6.59f) + curveTo(8.76f, 5.22f, 9.08f, 3.85f, 9.53f, 2.53f) + curveTo(9.55f, 2.45f, 9.54f, 2.39f, 9.55f, 2.31f) + curveTo(10.34f, 2.12f, 11.15f, 2f, 12f, 2f) + curveTo(12.84f, 2f, 13.66f, 2.12f, 14.44f, 2.31f) + curveTo(14.45f, 2.39f, 14.45f, 2.45f, 14.47f, 2.53f) + curveTo(14.92f, 3.86f, 15.24f, 5.22f, 15.49f, 6.59f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(6.59f, 15.49f) + curveTo(5.21f, 15.24f, 3.85f, 14.92f, 2.53f, 14.47f) + curveTo(2.45f, 14.45f, 2.39f, 14.46f, 2.31f, 14.45f) + curveTo(2.12f, 13.66f, 2f, 12.85f, 2f, 12f) + curveTo(2f, 11.16f, 2.12f, 10.34f, 2.31f, 9.56f) + curveTo(2.39f, 9.55f, 2.45f, 9.55f, 2.53f, 9.53f) + curveTo(3.86f, 9.09f, 5.21f, 8.76f, 6.59f, 8.51f) + curveTo(6.34f, 10.83f, 6.34f, 13.17f, 6.59f, 15.49f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(22f, 12f) + curveTo(22f, 12.85f, 21.88f, 13.66f, 21.69f, 14.45f) + curveTo(21.61f, 14.46f, 21.55f, 14.45f, 21.47f, 14.47f) + curveTo(20.14f, 14.91f, 18.78f, 15.24f, 17.41f, 15.49f) + curveTo(17.67f, 13.17f, 17.67f, 10.83f, 17.41f, 8.51f) + curveTo(18.78f, 8.76f, 20.15f, 9.08f, 21.47f, 9.53f) + curveTo(21.55f, 9.55f, 21.61f, 9.56f, 21.69f, 9.56f) + curveTo(21.88f, 10.35f, 22f, 11.16f, 22f, 12f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(15.49f, 17.41f) + curveTo(15.24f, 18.79f, 14.92f, 20.15f, 14.47f, 21.47f) + curveTo(14.45f, 21.55f, 14.45f, 21.61f, 14.44f, 21.69f) + curveTo(13.66f, 21.88f, 12.84f, 22f, 12f, 22f) + curveTo(11.15f, 22f, 10.34f, 21.88f, 9.55f, 21.69f) + curveTo(9.54f, 21.61f, 9.55f, 21.55f, 9.53f, 21.47f) + curveTo(9.09f, 20.14f, 8.76f, 18.79f, 8.51f, 17.41f) + curveTo(9.67f, 17.54f, 10.83f, 17.63f, 12f, 17.63f) + curveTo(13.17f, 17.63f, 14.34f, 17.54f, 15.49f, 17.41f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(15.763f, 15.763f) + curveTo(13.262f, 16.079f, 10.738f, 16.079f, 8.237f, 15.763f) + curveTo(7.921f, 13.262f, 7.921f, 10.738f, 8.237f, 8.237f) + curveTo(10.738f, 7.921f, 13.262f, 7.921f, 15.763f, 8.237f) + curveTo(16.079f, 10.738f, 16.079f, 13.262f, 15.763f, 15.763f) + close() + } + } + }.build() + + return _IconsaxGlobe!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxGlobe: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart.kt new file mode 100644 index 00000000..440b220b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxHeart: ImageVector + get() { + if (_IconsaxHeart != null) { + return _IconsaxHeart!! + } + _IconsaxHeart = ImageVector.Builder( + name = "IconsaxHeart", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(20.59f, 4.97f) + curveTo(21.47f, 5.96f, 22f, 7.26f, 22f, 8.69f) + curveTo(22f, 15.69f, 15.52f, 19.82f, 12.62f, 20.82f) + curveTo(12.28f, 20.94f, 11.72f, 20.94f, 11.38f, 20.82f) + curveTo(8.48f, 19.82f, 2f, 15.69f, 2f, 8.69f) + curveTo(2f, 5.6f, 4.49f, 3.1f, 7.56f, 3.1f) + curveTo(9.38f, 3.1f, 10.99f, 3.98f, 12f, 5.34f) + curveTo(13.01f, 3.98f, 14.63f, 3.1f, 16.44f, 3.1f) + } + } + }.build() + + return _IconsaxHeart!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxHeart: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart2.kt new file mode 100644 index 00000000..943657b1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart2.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxHeart2: ImageVector + get() { + if (_IconsaxHeart2 != null) { + return _IconsaxHeart2!! + } + _IconsaxHeart2 = ImageVector.Builder( + name = "IconsaxHeart2", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(16.44f, 3.1f) + curveTo(14.63f, 3.1f, 13.01f, 3.98f, 12f, 5.33f) + curveTo(10.99f, 3.98f, 9.37f, 3.1f, 7.56f, 3.1f) + curveTo(4.49f, 3.1f, 2f, 5.6f, 2f, 8.69f) + curveTo(2f, 9.88f, 2.19f, 10.98f, 2.52f, 12f) + curveTo(4.1f, 17f, 8.97f, 19.99f, 11.38f, 20.81f) + curveTo(11.72f, 20.93f, 12.28f, 20.93f, 12.62f, 20.81f) + curveTo(15.03f, 19.99f, 19.9f, 17f, 21.48f, 12f) + curveTo(21.81f, 10.98f, 22f, 9.88f, 22f, 8.69f) + curveTo(22f, 5.6f, 19.51f, 3.1f, 16.44f, 3.1f) + close() + } + } + }.build() + + return _IconsaxHeart2!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxHeart2: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHome.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHome.kt new file mode 100644 index 00000000..a802d9ba --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHome.kt @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxHome: ImageVector + get() { + if (_IconsaxHome != null) { + return _IconsaxHome!! + } + _IconsaxHome = ImageVector.Builder( + name = "IconsaxHome", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(20.83f, 8.01f) + lineTo(14.28f, 2.77f) + curveTo(13f, 1.75f, 11f, 1.74f, 9.73f, 2.76f) + lineTo(3.18f, 8.01f) + curveTo(2.24f, 8.76f, 1.67f, 10.26f, 1.87f, 11.44f) + lineTo(3.13f, 18.98f) + curveTo(3.42f, 20.67f, 4.99f, 22f, 6.7f, 22f) + horizontalLineTo(17.3f) + curveTo(18.99f, 22f, 20.59f, 20.64f, 20.88f, 18.97f) + lineTo(22.14f, 11.43f) + curveTo(22.32f, 10.26f, 21.75f, 8.76f, 20.83f, 8.01f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 18.75f) + curveTo(11.59f, 18.75f, 11.25f, 18.41f, 11.25f, 18f) + verticalLineTo(15f) + curveTo(11.25f, 14.59f, 11.59f, 14.25f, 12f, 14.25f) + curveTo(12.41f, 14.25f, 12.75f, 14.59f, 12.75f, 15f) + verticalLineTo(18f) + curveTo(12.75f, 18.41f, 12.41f, 18.75f, 12f, 18.75f) + close() + } + } + }.build() + + return _IconsaxHome!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxHome: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHomeBroken.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHomeBroken.kt new file mode 100644 index 00000000..1cf29a51 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHomeBroken.kt @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxHomeBroken: ImageVector + get() { + if (_IconsaxHomeBroken != null) { + return _IconsaxHomeBroken!! + } + _IconsaxHomeBroken = ImageVector.Builder( + name = "IconsaxHomeBroken", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(12f, 18f) + verticalLineTo(15f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(20.64f, 19.24f) + curveTo(20.4f, 20.65f, 19.03f, 21.81f, 17.6f, 21.81f) + horizontalLineTo(6.4f) + curveTo(4.96f, 21.81f, 3.6f, 20.66f, 3.36f, 19.24f) + lineTo(2.03f, 11.28f) + curveTo(1.86f, 10.3f, 2.36f, 8.99f, 3.14f, 8.37f) + lineTo(10.07f, 2.82f) + curveTo(11.13f, 1.97f, 12.86f, 1.97f, 13.93f, 2.83f) + lineTo(20.86f, 8.37f) + curveTo(21.63f, 8.99f, 22.13f, 10.3f, 21.97f, 11.28f) + lineTo(21.35f, 15f) + } + } + }.build() + + return _IconsaxHomeBroken!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxHomeBroken: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2.kt new file mode 100644 index 00000000..4c6be580 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2.kt @@ -0,0 +1,67 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxImportArrow2: ImageVector + get() { + if (_IconsaxImportArrow2 != null) { + return _IconsaxImportArrow2!! + } + _IconsaxImportArrow2 = ImageVector.Builder( + name = "IconsaxImportArrow2", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(22.001f, 2f) + lineTo(13.801f, 10.2f) + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(13f, 6.17f) + verticalLineTo(11f) + horizontalLineTo(17.83f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(11f, 2f) + horizontalLineTo(9f) + curveTo(4f, 2f, 2f, 4f, 2f, 9f) + verticalLineTo(15f) + curveTo(2f, 20f, 4f, 22f, 9f, 22f) + horizontalLineTo(15f) + curveTo(20f, 22f, 22f, 20f, 22f, 15f) + verticalLineTo(13f) + } + }.build() + + return _IconsaxImportArrow2!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxImportArrow2: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt new file mode 100644 index 00000000..4255e24e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt @@ -0,0 +1,61 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxImportArrow2Bulk: ImageVector + get() { + if (_IconsaxImportArrow2Bulk != null) { + return _IconsaxImportArrow2Bulk!! + } + _IconsaxImportArrow2Bulk = ImageVector.Builder( + name = "IconsaxImportArrow2Bulk", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16.8f, 9f) + horizontalLineTo(7.2f) + curveTo(4f, 9f, 2f, 11f, 2f, 14.2f) + verticalLineTo(16.79f) + curveTo(2f, 20f, 4f, 22f, 7.2f, 22f) + horizontalLineTo(16.79f) + curveTo(19.99f, 22f, 21.99f, 20f, 21.99f, 16.8f) + verticalLineTo(14.2f) + curveTo(22f, 11f, 20f, 9f, 16.8f, 9f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(15.88f, 12.43f) + lineTo(12.53f, 15.78f) + curveTo(12.24f, 16.07f, 11.76f, 16.07f, 11.47f, 15.78f) + lineTo(8.12f, 12.43f) + curveTo(7.83f, 12.14f, 7.83f, 11.66f, 8.12f, 11.37f) + curveTo(8.41f, 11.08f, 8.89f, 11.08f, 9.18f, 11.37f) + lineTo(11.25f, 13.44f) + verticalLineTo(2.75f) + curveTo(11.25f, 2.34f, 11.59f, 2f, 12f, 2f) + curveTo(12.41f, 2f, 12.75f, 2.34f, 12.75f, 2.75f) + verticalLineTo(13.44f) + lineTo(14.82f, 11.37f) + curveTo(14.97f, 11.22f, 15.16f, 11.15f, 15.35f, 11.15f) + curveTo(15.54f, 11.15f, 15.73f, 11.22f, 15.88f, 11.37f) + curveTo(16.18f, 11.66f, 16.18f, 12.13f, 15.88f, 12.43f) + close() + } + }.build() + + return _IconsaxImportArrow2Bulk!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxImportArrow2Bulk: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxInformation.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxInformation.kt new file mode 100644 index 00000000..cbbc16e0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxInformation.kt @@ -0,0 +1,97 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxInformation: ImageVector + get() { + if (_IconsaxInformation != null) { + return _IconsaxInformation!! + } + _IconsaxInformation = ImageVector.Builder( + name = "IconsaxInformation", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(10.75f, 2.45f) + curveTo(11.45f, 1.86f, 12.58f, 1.86f, 13.26f, 2.45f) + lineTo(14.84f, 3.8f) + curveTo(15.14f, 4.05f, 15.71f, 4.26f, 16.11f, 4.26f) + horizontalLineTo(17.81f) + curveTo(18.87f, 4.26f, 19.74f, 5.13f, 19.74f, 6.19f) + verticalLineTo(7.89f) + curveTo(19.74f, 8.29f, 19.95f, 8.85f, 20.2f, 9.15f) + lineTo(21.55f, 10.73f) + curveTo(22.14f, 11.43f, 22.14f, 12.56f, 21.55f, 13.24f) + lineTo(20.2f, 14.82f) + curveTo(19.95f, 15.12f, 19.74f, 15.68f, 19.74f, 16.08f) + verticalLineTo(17.78f) + curveTo(19.74f, 18.84f, 18.87f, 19.71f, 17.81f, 19.71f) + horizontalLineTo(16.11f) + curveTo(15.71f, 19.71f, 15.15f, 19.92f, 14.85f, 20.17f) + lineTo(13.27f, 21.52f) + curveTo(12.57f, 22.11f, 11.44f, 22.11f, 10.76f, 21.52f) + lineTo(9.18f, 20.17f) + curveTo(8.88f, 19.92f, 8.31f, 19.71f, 7.92f, 19.71f) + horizontalLineTo(6.17f) + curveTo(5.11f, 19.71f, 4.24f, 18.84f, 4.24f, 17.78f) + verticalLineTo(16.07f) + curveTo(4.24f, 15.68f, 4.04f, 15.11f, 3.79f, 14.82f) + lineTo(2.44f, 13.23f) + curveTo(1.86f, 12.54f, 1.86f, 11.42f, 2.44f, 10.73f) + lineTo(3.79f, 9.14f) + curveTo(4.04f, 8.84f, 4.24f, 8.28f, 4.24f, 7.89f) + verticalLineTo(6.2f) + curveTo(4.24f, 5.14f, 5.11f, 4.27f, 6.17f, 4.27f) + horizontalLineTo(7.9f) + curveTo(8.3f, 4.27f, 8.86f, 4.06f, 9.16f, 3.81f) + lineTo(10.75f, 2.45f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 16.869f) + curveTo(11.45f, 16.869f, 11f, 16.419f, 11f, 15.869f) + curveTo(11f, 15.319f, 11.44f, 14.869f, 12f, 14.869f) + curveTo(12.55f, 14.869f, 13f, 15.319f, 13f, 15.869f) + curveTo(13f, 16.419f, 12.56f, 16.869f, 12f, 16.869f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 13.721f) + curveTo(11.59f, 13.721f, 11.25f, 13.381f, 11.25f, 12.971f) + verticalLineTo(8.131f) + curveTo(11.25f, 7.721f, 11.59f, 7.381f, 12f, 7.381f) + curveTo(12.41f, 7.381f, 12.75f, 7.721f, 12.75f, 8.131f) + verticalLineTo(12.961f) + curveTo(12.75f, 13.381f, 12.42f, 13.721f, 12f, 13.721f) + close() + } + } + }.build() + + return _IconsaxInformation!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxInformation: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLanguageSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLanguageSquare.kt new file mode 100644 index 00000000..a99cd979 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLanguageSquare.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxLanguageSquare: ImageVector + get() { + if (_IconsaxLanguageSquare != null) { + return _IconsaxLanguageSquare!! + } + _IconsaxLanguageSquare = ImageVector.Builder( + name = "IconsaxLanguageSquare", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(16.99f, 8.96f) + horizontalLineTo(7.01f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(12f, 7.28f) + verticalLineTo(8.96f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(14.5f, 8.94f) + curveTo(14.5f, 13.24f, 11.14f, 16.72f, 7f, 16.72f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(17f, 16.72f) + curveTo(15.2f, 16.72f, 13.6f, 15.76f, 12.45f, 14.25f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(9f, 22f) + horizontalLineTo(15f) + curveTo(20f, 22f, 22f, 20f, 22f, 15f) + verticalLineTo(9f) + curveTo(22f, 4f, 20f, 2f, 15f, 2f) + horizontalLineTo(9f) + curveTo(4f, 2f, 2f, 4f, 2f, 9f) + verticalLineTo(15f) + curveTo(2f, 20f, 4f, 22f, 9f, 22f) + close() + } + } + }.build() + + return _IconsaxLanguageSquare!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxLanguageSquare: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLink.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLink.kt new file mode 100644 index 00000000..fa46bdc6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLink.kt @@ -0,0 +1,65 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxLink: ImageVector + get() { + if (_IconsaxLink != null) { + return _IconsaxLink!! + } + _IconsaxLink = ImageVector.Builder( + name = "IconsaxLink", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(13.06f, 10.94f) + curveTo(15.31f, 13.19f, 15.31f, 16.83f, 13.06f, 19.07f) + curveTo(10.81f, 21.31f, 7.17f, 21.32f, 4.93f, 19.07f) + curveTo(2.69f, 16.82f, 2.68f, 13.18f, 4.93f, 10.94f) + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(10.59f, 13.41f) + curveTo(8.25f, 11.07f, 8.25f, 7.27f, 10.59f, 4.92f) + curveTo(12.93f, 2.57f, 16.73f, 2.58f, 19.08f, 4.92f) + curveTo(21.43f, 7.26f, 21.42f, 11.06f, 19.08f, 13.41f) + } + } + }.build() + + return _IconsaxLink!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxLink: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLocation.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLocation.kt new file mode 100644 index 00000000..cf01a743 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLocation.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxLocation: ImageVector + get() { + if (_IconsaxLocation != null) { + return _IconsaxLocation!! + } + _IconsaxLocation = ImageVector.Builder( + name = "IconsaxLocation", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(20.62f, 8.45f) + curveTo(19.57f, 3.83f, 15.54f, 1.75f, 12f, 1.75f) + curveTo(12f, 1.75f, 12f, 1.75f, 11.99f, 1.75f) + curveTo(8.46f, 1.75f, 4.42f, 3.82f, 3.37f, 8.44f) + curveTo(2.2f, 13.6f, 5.36f, 17.97f, 8.22f, 20.72f) + curveTo(9.28f, 21.74f, 10.64f, 22.25f, 12f, 22.25f) + curveTo(13.36f, 22.25f, 14.72f, 21.74f, 15.77f, 20.72f) + curveTo(18.63f, 17.97f, 21.79f, 13.61f, 20.62f, 8.45f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 13.46f) + curveTo(13.74f, 13.46f, 15.15f, 12.05f, 15.15f, 10.31f) + curveTo(15.15f, 8.57f, 13.74f, 7.16f, 12f, 7.16f) + curveTo(10.26f, 7.16f, 8.85f, 8.57f, 8.85f, 10.31f) + curveTo(8.85f, 12.05f, 10.26f, 13.46f, 12f, 13.46f) + close() + } + } + }.build() + + return _IconsaxLocation!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxLocation: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMagic.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMagic.kt new file mode 100644 index 00000000..a0d30b5e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMagic.kt @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMagic: ImageVector + get() { + if (_IconsaxMagic != null) { + return _IconsaxMagic!! + } + _IconsaxMagic = ImageVector.Builder( + name = "IconsaxMagic", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(7.999f, 16.11f) + lineTo(2.109f, 22f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(13.83f, 4.08f) + lineTo(15.55f, 4.77f) + curveTo(16f, 4.95f, 16f, 5.24f, 15.55f, 5.43f) + lineTo(13.83f, 6.12f) + lineTo(13.141f, 7.85f) + curveTo(12.96f, 8.28f, 12.66f, 8.28f, 12.481f, 7.85f) + lineTo(11.79f, 6.12f) + lineTo(10.071f, 5.43f) + curveTo(9.641f, 5.25f, 9.641f, 4.96f, 10.071f, 4.77f) + lineTo(11.79f, 4.08f) + lineTo(12.481f, 2.35f) + curveTo(12.66f, 1.89f, 12.96f, 1.89f, 13.141f, 2.35f) + lineTo(13.83f, 4.08f) + close() + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(20.34f, 9.15f) + lineTo(21.89f, 9.77f) + curveTo(22.291f, 9.93f, 22.291f, 10.2f, 21.89f, 10.36f) + lineTo(20.34f, 10.98f) + lineTo(19.721f, 12.53f) + curveTo(19.56f, 12.92f, 19.291f, 12.92f, 19.131f, 12.53f) + lineTo(18.51f, 10.98f) + lineTo(16.961f, 10.36f) + curveTo(16.57f, 10.2f, 16.57f, 9.93f, 16.961f, 9.77f) + lineTo(18.51f, 9.15f) + lineTo(19.131f, 7.6f) + curveTo(19.291f, 7.19f, 19.56f, 7.19f, 19.721f, 7.6f) + lineTo(20.34f, 9.15f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(12.38f, 11.85f) + lineTo(13.71f, 12.38f) + curveTo(14.06f, 12.52f, 14.06f, 12.75f, 13.71f, 12.89f) + lineTo(12.38f, 13.42f) + lineTo(11.85f, 14.75f) + curveTo(11.71f, 15.09f, 11.48f, 15.09f, 11.34f, 14.75f) + lineTo(10.81f, 13.42f) + lineTo(9.48f, 12.89f) + curveTo(9.15f, 12.75f, 9.15f, 12.52f, 9.48f, 12.38f) + lineTo(10.81f, 11.85f) + lineTo(11.34f, 10.52f) + curveTo(11.48f, 10.17f, 11.71f, 10.17f, 11.85f, 10.52f) + lineTo(12.38f, 11.85f) + close() + } + } + }.build() + + return _IconsaxMagic!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMagic: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMinusSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMinusSquare.kt new file mode 100644 index 00000000..88a8f975 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMinusSquare.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMinusSquare: ImageVector + get() { + if (_IconsaxMinusSquare != null) { + return _IconsaxMinusSquare!! + } + _IconsaxMinusSquare = ImageVector.Builder( + name = "IconsaxMinusSquare", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16.19f, 2f) + horizontalLineTo(7.81f) + curveTo(4.17f, 2f, 2f, 4.17f, 2f, 7.81f) + verticalLineTo(16.18f) + curveTo(2f, 19.83f, 4.17f, 22f, 7.81f, 22f) + horizontalLineTo(16.18f) + curveTo(19.82f, 22f, 21.99f, 19.83f, 21.99f, 16.19f) + verticalLineTo(7.81f) + curveTo(22f, 4.17f, 19.83f, 2f, 16.19f, 2f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(16f, 12.75f) + horizontalLineTo(8f) + curveTo(7.59f, 12.75f, 7.25f, 12.41f, 7.25f, 12f) + curveTo(7.25f, 11.59f, 7.59f, 11.25f, 8f, 11.25f) + horizontalLineTo(16f) + curveTo(16.41f, 11.25f, 16.75f, 11.59f, 16.75f, 12f) + curveTo(16.75f, 12.41f, 16.41f, 12.75f, 16f, 12.75f) + close() + } + } + }.build() + + return _IconsaxMinusSquare!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMinusSquare: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt new file mode 100644 index 00000000..b0459e66 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt @@ -0,0 +1,89 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMirrorScreenRegular: ImageVector + get() { + if (_IconsaxMirrorScreenRegular != null) { + return _IconsaxMirrorScreenRegular!! + } + _IconsaxMirrorScreenRegular = ImageVector.Builder( + name = "IconsaxMirrorScreenRegular", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(2f, 9f) + verticalLineTo(8f) + curveTo(2f, 5f, 4f, 3f, 7f, 3f) + horizontalLineTo(17f) + curveTo(20f, 3f, 22f, 5f, 22f, 8f) + verticalLineTo(16f) + curveTo(22f, 19f, 20f, 21f, 17f, 21f) + horizontalLineTo(16f) + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(3.69f, 11.711f) + curveTo(8.31f, 12.301f, 11.7f, 15.701f, 12.3f, 20.321f) + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(2.62f, 15.07f) + curveTo(6.01f, 15.5f, 8.5f, 18f, 8.94f, 21.39f) + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(1.98f, 18.859f) + curveTo(3.67f, 19.079f, 4.92f, 20.319f, 5.14f, 22.019f) + } + } + }.build() + + return _IconsaxMirrorScreenRegular!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMirrorScreenRegular: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirroringScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirroringScreen.kt new file mode 100644 index 00000000..4cfe7baf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirroringScreen.kt @@ -0,0 +1,90 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMirroringScreen: ImageVector + get() { + if (_IconsaxMirroringScreen != null) { + return _IconsaxMirroringScreen!! + } + _IconsaxMirroringScreen = ImageVector.Builder( + name = "IconsaxMirroringScreen", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(2f, 9f) + verticalLineTo(8f) + curveTo(2f, 5.24f, 4.24f, 3f, 7f, 3f) + horizontalLineTo(17f) + curveTo(19.76f, 3f, 22f, 5.24f, 22f, 8f) + verticalLineTo(16f) + curveTo(22f, 18.76f, 19.76f, 21f, 17f, 21f) + horizontalLineTo(16f) + horizontalLineTo(7f) + curveTo(4.24f, 21f, 2f, 18.76f, 2f, 16f) + verticalLineTo(9f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(16.14f, 15.439f) + curveTo(15.77f, 15.439f, 15.45f, 15.159f, 15.4f, 14.789f) + curveTo(14.97f, 11.409f, 12.51f, 8.949f, 9.13f, 8.519f) + curveTo(8.72f, 8.469f, 8.43f, 8.089f, 8.48f, 7.679f) + curveTo(8.53f, 7.269f, 8.91f, 6.979f, 9.32f, 7.029f) + curveTo(13.39f, 7.549f, 16.36f, 10.519f, 16.89f, 14.599f) + curveTo(16.94f, 15.009f, 16.65f, 15.389f, 16.24f, 15.439f) + curveTo(16.21f, 15.439f, 16.18f, 15.439f, 16.14f, 15.439f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(13.44f, 16.298f) + curveTo(13.07f, 16.298f, 12.75f, 16.018f, 12.7f, 15.648f) + curveTo(12.39f, 13.268f, 10.66f, 11.528f, 8.27f, 11.218f) + curveTo(7.86f, 11.168f, 7.57f, 10.788f, 7.62f, 10.378f) + curveTo(7.67f, 9.968f, 8.05f, 9.678f, 8.46f, 9.728f) + curveTo(11.54f, 10.128f, 13.79f, 12.368f, 14.19f, 15.458f) + curveTo(14.24f, 15.868f, 13.95f, 16.248f, 13.54f, 16.298f) + curveTo(13.51f, 16.298f, 13.47f, 16.298f, 13.44f, 16.298f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(10.39f, 16.809f) + curveTo(10.02f, 16.809f, 9.7f, 16.529f, 9.65f, 16.159f) + curveTo(9.52f, 15.139f, 8.78f, 14.399f, 7.76f, 14.269f) + curveTo(7.35f, 14.219f, 7.06f, 13.839f, 7.11f, 13.429f) + curveTo(7.16f, 13.019f, 7.54f, 12.729f, 7.95f, 12.779f) + curveTo(9.64f, 12.999f, 10.92f, 14.279f, 11.14f, 15.969f) + curveTo(11.19f, 16.379f, 10.9f, 16.759f, 10.49f, 16.809f) + curveTo(10.46f, 16.809f, 10.42f, 16.809f, 10.39f, 16.809f) + close() + } + } + }.build() + + return _IconsaxMirroringScreen!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMirroringScreen: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMoon.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMoon.kt new file mode 100644 index 00000000..5c47d773 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMoon.kt @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMoon: ImageVector + get() { + if (_IconsaxMoon != null) { + return _IconsaxMoon!! + } + _IconsaxMoon = ImageVector.Builder( + name = "IconsaxMoon", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(9f, 19f) + curveTo(9f, 19.84f, 9.13f, 20.66f, 9.37f, 21.42f) + curveTo(5.53f, 20.09f, 2.63f, 16.56f, 2.33f, 12.43f) + curveTo(2.03f, 8.04f, 4.56f, 3.94f, 8.65f, 2.22f) + curveTo(9.71f, 1.78f, 10.25f, 2.1f, 10.48f, 2.33f) + curveTo(10.7f, 2.55f, 11.01f, 3.08f, 10.57f, 4.09f) + curveTo(10.12f, 5.13f, 9.9f, 6.23f, 9.9f, 7.37f) + curveTo(9.91f, 9.41f, 10.71f, 11.3f, 12.01f, 12.75f) + curveTo(10.18f, 14.21f, 9f, 16.47f, 9f, 19f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(21.21f, 17.72f) + curveTo(19.23f, 20.41f, 16.09f, 21.99f, 12.74f, 21.99f) + curveTo(12.58f, 21.99f, 12.42f, 21.98f, 12.26f, 21.97f) + curveTo(11.26f, 21.93f, 10.29f, 21.74f, 9.37f, 21.42f) + curveTo(9.13f, 20.66f, 9f, 19.84f, 9f, 19f) + curveTo(9f, 16.47f, 10.18f, 14.21f, 12.01f, 12.75f) + curveTo(13.48f, 14.4f, 15.59f, 15.47f, 17.92f, 15.57f) + curveTo(18.55f, 15.6f, 19.18f, 15.55f, 19.8f, 15.44f) + curveTo(20.92f, 15.24f, 21.37f, 15.66f, 21.53f, 15.93f) + curveTo(21.7f, 16.2f, 21.88f, 16.79f, 21.21f, 17.72f) + close() + } + } + }.build() + + return _IconsaxMoon!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMoon: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusic.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusic.kt new file mode 100644 index 00000000..b404c2bc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusic.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMusic: ImageVector + get() { + if (_IconsaxMusic != null) { + return _IconsaxMusic!! + } + _IconsaxMusic = ImageVector.Builder( + name = "IconsaxMusic", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(10.29f, 10.34f) + verticalLineTo(18.41f) + curveTo(10.29f, 20.39f, 8.67f, 22f, 6.7f, 22f) + curveTo(4.72f, 22f, 3.11f, 20.39f, 3.11f, 18.41f) + curveTo(3.11f, 16.44f, 4.72f, 14.83f, 6.7f, 14.83f) + curveTo(7.53f, 14.83f, 8.28f, 15.12f, 8.89f, 15.59f) + verticalLineTo(10.74f) + lineTo(10.29f, 10.34f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(20.89f, 7.32f) + verticalLineTo(16.48f) + curveTo(20.89f, 18.46f, 19.28f, 20.07f, 17.3f, 20.07f) + curveTo(15.33f, 20.07f, 13.71f, 18.46f, 13.71f, 16.48f) + curveTo(13.71f, 14.51f, 15.33f, 12.9f, 17.3f, 12.9f) + curveTo(18.14f, 12.9f, 18.89f, 13.19f, 19.5f, 13.67f) + verticalLineTo(7.72f) + lineTo(20.89f, 7.32f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(20.89f, 5.18f) + verticalLineTo(7.32f) + lineTo(8.89f, 10.74f) + verticalLineTo(6.75f) + curveTo(8.89f, 5.28f, 9.78f, 4.14f, 11.19f, 3.76f) + lineTo(16.97f, 2.18f) + curveTo(18.14f, 1.86f, 19.13f, 1.97f, 19.83f, 2.51f) + curveTo(20.54f, 3.04f, 20.89f, 3.94f, 20.89f, 5.18f) + close() + } + } + }.build() + + return _IconsaxMusic!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMusic: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicCircle.kt new file mode 100644 index 00000000..14bd6927 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicCircle.kt @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMusicCircle: ImageVector + get() { + if (_IconsaxMusicCircle != null) { + return _IconsaxMusicCircle!! + } + _IconsaxMusicCircle = ImageVector.Builder( + name = "IconsaxMusicCircle", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(2.58f, 9.42f) + curveTo(2.5f, 9.42f, 2.41f, 9.41f, 2.33f, 9.38f) + curveTo(1.94f, 9.24f, 1.74f, 8.81f, 1.87f, 8.42f) + curveTo(2.54f, 6.54f, 3.7f, 4.89f, 5.25f, 3.64f) + curveTo(5.57f, 3.38f, 6.04f, 3.43f, 6.3f, 3.75f) + curveTo(6.56f, 4.07f, 6.51f, 4.54f, 6.19f, 4.81f) + curveTo(4.87f, 5.88f, 3.86f, 7.3f, 3.29f, 8.92f) + curveTo(3.18f, 9.23f, 2.89f, 9.42f, 2.58f, 9.42f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(2.58f, 16.08f) + curveTo(2.27f, 16.08f, 1.98f, 15.89f, 1.87f, 15.58f) + curveTo(1.46f, 14.42f, 1.25f, 13.21f, 1.25f, 12f) + curveTo(1.25f, 11.59f, 1.59f, 11.25f, 2f, 11.25f) + curveTo(2.41f, 11.25f, 2.75f, 11.59f, 2.75f, 12f) + curveTo(2.75f, 13.04f, 2.93f, 14.08f, 3.29f, 15.08f) + curveTo(3.43f, 15.47f, 3.22f, 15.9f, 2.83f, 16.04f) + curveTo(2.75f, 16.07f, 2.66f, 16.08f, 2.58f, 16.08f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 22.75f) + curveTo(10.94f, 22.75f, 9.89f, 22.59f, 8.87f, 22.28f) + curveTo(8.47f, 22.16f, 8.25f, 21.74f, 8.37f, 21.34f) + curveTo(8.49f, 20.94f, 8.91f, 20.72f, 9.31f, 20.84f) + curveTo(10.18f, 21.11f, 11.09f, 21.24f, 12f, 21.24f) + curveTo(17.1f, 21.24f, 21.25f, 17.09f, 21.25f, 11.99f) + curveTo(21.25f, 11.47f, 21.2f, 10.93f, 21.1f, 10.36f) + curveTo(21.03f, 9.95f, 21.3f, 9.56f, 21.71f, 9.49f) + curveTo(22.11f, 9.42f, 22.51f, 9.69f, 22.58f, 10.1f) + curveTo(22.7f, 10.76f, 22.76f, 11.38f, 22.76f, 12f) + curveTo(22.75f, 17.93f, 17.93f, 22.75f, 12f, 22.75f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(5.72f, 20.5f) + curveTo(5.55f, 20.5f, 5.39f, 20.45f, 5.25f, 20.33f) + curveTo(4.68f, 19.87f, 4.22f, 19.43f, 3.83f, 18.98f) + curveTo(3.56f, 18.67f, 3.6f, 18.19f, 3.91f, 17.92f) + curveTo(4.23f, 17.65f, 4.7f, 17.69f, 4.97f, 18f) + curveTo(5.3f, 18.38f, 5.7f, 18.76f, 6.19f, 19.16f) + curveTo(6.51f, 19.42f, 6.56f, 19.89f, 6.3f, 20.21f) + curveTo(6.16f, 20.4f, 5.94f, 20.5f, 5.72f, 20.5f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(20.24f, 7.09f) + curveTo(20f, 7.09f, 19.77f, 6.98f, 19.62f, 6.76f) + curveTo(17.9f, 4.25f, 15.04f, 2.75f, 12f, 2.75f) + curveTo(11.09f, 2.75f, 10.18f, 2.88f, 9.31f, 3.15f) + curveTo(8.92f, 3.27f, 8.5f, 3.05f, 8.37f, 2.65f) + curveTo(8.24f, 2.25f, 8.47f, 1.83f, 8.87f, 1.71f) + curveTo(9.89f, 1.41f, 10.94f, 1.25f, 12f, 1.25f) + curveTo(15.54f, 1.25f, 18.85f, 3f, 20.86f, 5.92f) + curveTo(21.09f, 6.26f, 21.01f, 6.73f, 20.67f, 6.96f) + curveTo(20.54f, 7.05f, 20.39f, 7.09f, 20.24f, 7.09f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(16.03f, 6.5f) + curveTo(15.7f, 6.25f, 15.1f, 6f, 14.14f, 6.26f) + lineTo(10.95f, 7.12f) + curveTo(10.03f, 7.38f, 9.43f, 8.16f, 9.43f, 9.12f) + verticalLineTo(10.76f) + verticalLineTo(13.34f) + curveTo(9.17f, 13.241f, 8.89f, 13.181f, 8.59f, 13.181f) + curveTo(7.3f, 13.181f, 6.25f, 14.231f, 6.25f, 15.521f) + curveTo(6.25f, 16.81f, 7.3f, 17.861f, 8.59f, 17.861f) + curveTo(9.87f, 17.861f, 10.9f, 16.83f, 10.92f, 15.561f) + curveTo(10.92f, 15.55f, 10.93f, 15.54f, 10.93f, 15.521f) + verticalLineTo(11.33f) + lineTo(15.25f, 10.151f) + verticalLineTo(12.281f) + curveTo(14.99f, 12.181f, 14.71f, 12.12f, 14.41f, 12.12f) + curveTo(13.12f, 12.12f, 12.07f, 13.17f, 12.07f, 14.46f) + curveTo(12.07f, 15.75f, 13.12f, 16.801f, 14.41f, 16.801f) + curveTo(15.7f, 16.801f, 16.75f, 15.75f, 16.75f, 14.46f) + verticalLineTo(9.17f) + verticalLineTo(8.25f) + curveTo(16.75f, 7.45f, 16.51f, 6.86f, 16.03f, 6.5f) + close() + moveTo(8.59f, 16.361f) + curveTo(8.13f, 16.361f, 7.75f, 15.981f, 7.75f, 15.521f) + curveTo(7.75f, 15.061f, 8.13f, 14.681f, 8.59f, 14.681f) + curveTo(9.05f, 14.681f, 9.43f, 15.061f, 9.43f, 15.521f) + curveTo(9.43f, 15.981f, 9.05f, 16.361f, 8.59f, 16.361f) + close() + moveTo(14.41f, 15.3f) + curveTo(13.95f, 15.3f, 13.57f, 14.92f, 13.57f, 14.46f) + curveTo(13.57f, 14f, 13.95f, 13.62f, 14.41f, 13.62f) + curveTo(14.87f, 13.62f, 15.25f, 14f, 15.25f, 14.46f) + curveTo(15.25f, 14.92f, 14.87f, 15.3f, 14.41f, 15.3f) + close() + } + } + }.build() + + return _IconsaxMusicCircle!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMusicCircle: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicDashboard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicDashboard.kt new file mode 100644 index 00000000..c38c9ece --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicDashboard.kt @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMusicDashboard: ImageVector + get() { + if (_IconsaxMusicDashboard != null) { + return _IconsaxMusicDashboard!! + } + _IconsaxMusicDashboard = ImageVector.Builder( + name = "IconsaxMusicDashboard", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(7f, 2.05f) + verticalLineTo(21.95f) + curveTo(3.85f, 21.66f, 2f, 19.55f, 2f, 16.19f) + verticalLineTo(7.81f) + curveTo(2f, 4.45f, 3.85f, 2.34f, 7f, 2.05f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(22f, 7.81f) + verticalLineTo(16.19f) + curveTo(22f, 19.83f, 19.83f, 22f, 16.19f, 22f) + horizontalLineTo(7.81f) + curveTo(7.53f, 22f, 7.26f, 21.99f, 7f, 21.95f) + verticalLineTo(2.05f) + curveTo(7.26f, 2.01f, 7.53f, 2f, 7.81f, 2f) + horizontalLineTo(16.19f) + curveTo(19.83f, 2f, 22f, 4.17f, 22f, 7.81f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(18.49f, 6.8f) + curveTo(18.17f, 6.55f, 17.59f, 6.31f, 16.67f, 6.56f) + lineTo(13.69f, 7.38f) + curveTo(12.8f, 7.61f, 12.22f, 8.36f, 12.22f, 9.3f) + verticalLineTo(11.05f) + verticalLineTo(13.21f) + curveTo(11.99f, 13.13f, 11.74f, 13.07f, 11.48f, 13.07f) + curveTo(10.24f, 13.07f, 9.24f, 14.08f, 9.24f, 15.31f) + curveTo(9.24f, 16.54f, 10.25f, 17.55f, 11.48f, 17.55f) + curveTo(12.7f, 17.55f, 13.7f, 16.56f, 13.72f, 15.35f) + curveTo(13.72f, 15.34f, 13.73f, 15.33f, 13.73f, 15.32f) + verticalLineTo(11.62f) + lineTo(17.7f, 10.54f) + verticalLineTo(12.22f) + curveTo(17.47f, 12.14f, 17.22f, 12.08f, 16.95f, 12.08f) + curveTo(15.71f, 12.08f, 14.71f, 13.09f, 14.71f, 14.32f) + curveTo(14.71f, 15.56f, 15.72f, 16.56f, 16.95f, 16.56f) + curveTo(18.17f, 16.56f, 19.17f, 15.57f, 19.19f, 14.35f) + curveTo(19.19f, 14.34f, 19.2f, 14.33f, 19.2f, 14.31f) + verticalLineTo(9.55f) + verticalLineTo(8.48f) + curveTo(19.18f, 7.72f, 18.95f, 7.16f, 18.49f, 6.8f) + close() + moveTo(11.47f, 16.05f) + curveTo(11.06f, 16.05f, 10.73f, 15.72f, 10.73f, 15.31f) + curveTo(10.73f, 14.9f, 11.06f, 14.57f, 11.47f, 14.57f) + curveTo(11.88f, 14.57f, 12.21f, 14.9f, 12.21f, 15.31f) + curveTo(12.21f, 15.72f, 11.87f, 16.05f, 11.47f, 16.05f) + close() + moveTo(16.93f, 15.05f) + curveTo(16.52f, 15.05f, 16.19f, 14.72f, 16.19f, 14.31f) + curveTo(16.19f, 13.9f, 16.52f, 13.57f, 16.93f, 13.57f) + curveTo(17.34f, 13.57f, 17.67f, 13.9f, 17.67f, 14.31f) + curveTo(17.67f, 14.72f, 17.34f, 15.05f, 16.93f, 15.05f) + close() + } + } + }.build() + + return _IconsaxMusicDashboard!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMusicDashboard: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicFilter.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicFilter.kt new file mode 100644 index 00000000..91bb39aa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicFilter.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMusicFilter: ImageVector + get() { + if (_IconsaxMusicFilter != null) { + return _IconsaxMusicFilter!! + } + _IconsaxMusicFilter = ImageVector.Builder( + name = "IconsaxMusicFilter", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(22f, 3.75f) + horizontalLineTo(2f) + curveTo(1.59f, 3.75f, 1.25f, 3.41f, 1.25f, 3f) + curveTo(1.25f, 2.59f, 1.59f, 2.25f, 2f, 2.25f) + horizontalLineTo(22f) + curveTo(22.41f, 2.25f, 22.75f, 2.59f, 22.75f, 3f) + curveTo(22.75f, 3.41f, 22.41f, 3.75f, 22f, 3.75f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(11f, 9.75f) + horizontalLineTo(2f) + curveTo(1.59f, 9.75f, 1.25f, 9.41f, 1.25f, 9f) + curveTo(1.25f, 8.59f, 1.59f, 8.25f, 2f, 8.25f) + horizontalLineTo(11f) + curveTo(11.41f, 8.25f, 11.75f, 8.59f, 11.75f, 9f) + curveTo(11.75f, 9.41f, 11.41f, 9.75f, 11f, 9.75f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(8f, 15.75f) + horizontalLineTo(2f) + curveTo(1.59f, 15.75f, 1.25f, 15.41f, 1.25f, 15f) + curveTo(1.25f, 14.59f, 1.59f, 14.25f, 2f, 14.25f) + horizontalLineTo(8f) + curveTo(8.41f, 14.25f, 8.75f, 14.59f, 8.75f, 15f) + curveTo(8.75f, 15.41f, 8.41f, 15.75f, 8f, 15.75f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(6f, 21.75f) + horizontalLineTo(2f) + curveTo(1.59f, 21.75f, 1.25f, 21.41f, 1.25f, 21f) + curveTo(1.25f, 20.59f, 1.59f, 20.25f, 2f, 20.25f) + horizontalLineTo(6f) + curveTo(6.41f, 20.25f, 6.75f, 20.59f, 6.75f, 21f) + curveTo(6.75f, 21.41f, 6.41f, 21.75f, 6f, 21.75f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(21.86f, 7.68f) + curveTo(21.27f, 7.23f, 20.46f, 7.14f, 19.51f, 7.4f) + lineTo(15.16f, 8.58f) + curveTo(13.99f, 8.9f, 13.27f, 9.85f, 13.27f, 11.05f) + verticalLineTo(13.6f) + verticalLineTo(17.28f) + curveTo(12.85f, 17.04f, 12.36f, 16.89f, 11.84f, 16.89f) + curveTo(10.23f, 16.89f, 8.91f, 18.2f, 8.91f, 19.82f) + curveTo(8.91f, 21.43f, 10.22f, 22.75f, 11.84f, 22.75f) + curveTo(13.46f, 22.75f, 14.77f, 21.44f, 14.77f, 19.82f) + verticalLineTo(14.17f) + lineTo(21.25f, 12.4f) + verticalLineTo(15.83f) + curveTo(20.83f, 15.59f, 20.34f, 15.44f, 19.82f, 15.44f) + curveTo(18.21f, 15.44f, 16.89f, 16.75f, 16.89f, 18.37f) + curveTo(16.89f, 19.98f, 18.2f, 21.3f, 19.82f, 21.3f) + curveTo(21.44f, 21.3f, 22.75f, 19.99f, 22.75f, 18.37f) + verticalLineTo(11.42f) + verticalLineTo(9.87f) + curveTo(22.75f, 8.86f, 22.45f, 8.12f, 21.86f, 7.68f) + close() + moveTo(11.84f, 21.25f) + curveTo(11.05f, 21.25f, 10.41f, 20.61f, 10.41f, 19.82f) + curveTo(10.41f, 19.03f, 11.05f, 18.39f, 11.84f, 18.39f) + curveTo(12.63f, 18.39f, 13.27f, 19.03f, 13.27f, 19.82f) + curveTo(13.27f, 20.61f, 12.63f, 21.25f, 11.84f, 21.25f) + close() + moveTo(19.82f, 19.8f) + curveTo(19.03f, 19.8f, 18.39f, 19.16f, 18.39f, 18.37f) + curveTo(18.39f, 17.58f, 19.03f, 16.94f, 19.82f, 16.94f) + curveTo(20.61f, 16.94f, 21.25f, 17.58f, 21.25f, 18.37f) + curveTo(21.25f, 19.16f, 20.61f, 19.8f, 19.82f, 19.8f) + close() + } + } + }.build() + + return _IconsaxMusicFilter!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMusicFilter: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibrary.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibrary.kt new file mode 100644 index 00000000..2584e03b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibrary.kt @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMusicLibrary: ImageVector + get() { + if (_IconsaxMusicLibrary != null) { + return _IconsaxMusicLibrary!! + } + _IconsaxMusicLibrary = ImageVector.Builder( + name = "IconsaxMusicLibrary", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(19f, 7f) + verticalLineTo(8.13f) + curveTo(18.68f, 8.04f, 18.35f, 8f, 18f, 8f) + horizontalLineTo(6f) + curveTo(5.65f, 8f, 5.32f, 8.04f, 5f, 8.13f) + verticalLineTo(7f) + curveTo(5f, 5.9f, 5.9f, 5f, 7f, 5f) + horizontalLineTo(17f) + curveTo(18.1f, 5f, 19f, 5.9f, 19f, 7f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16f, 3.51f) + verticalLineTo(5f) + horizontalLineTo(8f) + verticalLineTo(3.51f) + curveTo(8f, 2.68f, 8.68f, 2f, 9.51f, 2f) + horizontalLineTo(14.49f) + curveTo(15.32f, 2f, 16f, 2.68f, 16f, 3.51f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(22f, 12f) + verticalLineTo(18f) + curveTo(22f, 20.2f, 20.2f, 22f, 18f, 22f) + horizontalLineTo(6f) + curveTo(3.8f, 22f, 2f, 20.2f, 2f, 18f) + verticalLineTo(12f) + curveTo(2f, 10.15f, 3.28f, 8.58f, 5f, 8.13f) + curveTo(5.32f, 8.04f, 5.65f, 8f, 6f, 8f) + horizontalLineTo(18f) + curveTo(18.35f, 8f, 18.68f, 8.04f, 19f, 8.13f) + curveTo(20.72f, 8.58f, 22f, 10.15f, 22f, 12f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(15.35f, 10.51f) + curveTo(15.05f, 10.28f, 14.51f, 10.06f, 13.66f, 10.29f) + lineTo(11.01f, 11.02f) + curveTo(10.18f, 11.24f, 9.65f, 11.94f, 9.65f, 12.8f) + verticalLineTo(14.35f) + verticalLineTo(16.15f) + curveTo(9.47f, 16.1f, 9.27f, 16.06f, 9.07f, 16.06f) + curveTo(7.93f, 16.06f, 7f, 16.99f, 7f, 18.13f) + curveTo(7f, 19.27f, 7.93f, 20.2f, 9.07f, 20.2f) + curveTo(10.21f, 20.2f, 11.13f, 19.28f, 11.14f, 18.15f) + curveTo(11.14f, 18.14f, 11.15f, 18.13f, 11.15f, 18.12f) + verticalLineTo(14.91f) + lineTo(14.5f, 14f) + verticalLineTo(15.26f) + curveTo(14.32f, 15.21f, 14.13f, 15.17f, 13.93f, 15.17f) + curveTo(12.79f, 15.17f, 11.86f, 16.1f, 11.86f, 17.24f) + curveTo(11.86f, 18.38f, 12.79f, 19.31f, 13.93f, 19.31f) + curveTo(15.07f, 19.31f, 16f, 18.38f, 16f, 17.24f) + verticalLineTo(13.02f) + verticalLineTo(12.07f) + curveTo(16f, 11.2f, 15.64f, 10.74f, 15.35f, 10.51f) + close() + moveTo(9.07f, 18.71f) + curveTo(8.75f, 18.71f, 8.5f, 18.45f, 8.5f, 18.14f) + curveTo(8.5f, 17.83f, 8.76f, 17.57f, 9.07f, 17.57f) + curveTo(9.38f, 17.57f, 9.64f, 17.83f, 9.64f, 18.14f) + curveTo(9.64f, 18.45f, 9.39f, 18.71f, 9.07f, 18.71f) + close() + moveTo(13.93f, 17.82f) + curveTo(13.61f, 17.82f, 13.36f, 17.56f, 13.36f, 17.25f) + curveTo(13.36f, 16.94f, 13.62f, 16.68f, 13.93f, 16.68f) + curveTo(14.24f, 16.68f, 14.5f, 16.94f, 14.5f, 17.25f) + curveTo(14.5f, 17.56f, 14.24f, 17.82f, 13.93f, 17.82f) + close() + } + } + }.build() + + return _IconsaxMusicLibrary!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMusicLibrary: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt new file mode 100644 index 00000000..843e577c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMusicLibraryOutline: ImageVector + get() { + if (_IconsaxMusicLibraryOutline != null) { + return _IconsaxMusicLibraryOutline!! + } + _IconsaxMusicLibraryOutline = ImageVector.Builder( + name = "IconsaxMusicLibraryOutline", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(22f, 13f) + verticalLineTo(17f) + curveTo(22f, 20.5f, 20f, 22f, 17f, 22f) + horizontalLineTo(7f) + curveTo(4f, 22f, 2f, 20.5f, 2f, 17f) + verticalLineTo(13f) + curveTo(2f, 10.35f, 3.15f, 8.85f, 5f, 8.28f) + curveTo(5.6f, 8.09f, 6.27f, 8f, 7f, 8f) + horizontalLineTo(17f) + curveTo(17.73f, 8f, 18.4f, 8.09f, 19f, 8.28f) + curveTo(20.85f, 8.85f, 22f, 10.35f, 22f, 13f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(19f, 7f) + verticalLineTo(8.28f) + curveTo(18.4f, 8.09f, 17.73f, 8f, 17f, 8f) + horizontalLineTo(7f) + curveTo(6.27f, 8f, 5.6f, 8.09f, 5f, 8.28f) + verticalLineTo(7f) + curveTo(5f, 5.9f, 5.9f, 5f, 7f, 5f) + horizontalLineTo(17f) + curveTo(18.1f, 5f, 19f, 5.9f, 19f, 7f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(16f, 3.51f) + verticalLineTo(5f) + horizontalLineTo(8f) + verticalLineTo(3.51f) + curveTo(8f, 2.68f, 8.68f, 2f, 9.51f, 2f) + horizontalLineTo(14.49f) + curveTo(15.32f, 2f, 16f, 2.68f, 16f, 3.51f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(9.07f, 19.451f) + curveTo(9.799f, 19.451f, 10.39f, 18.86f, 10.39f, 18.131f) + curveTo(10.39f, 17.402f, 9.799f, 16.81f, 9.07f, 16.81f) + curveTo(8.341f, 16.81f, 7.75f, 17.402f, 7.75f, 18.131f) + curveTo(7.75f, 18.86f, 8.341f, 19.451f, 9.07f, 19.451f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(15.25f, 17.25f) + verticalLineTo(12.08f) + curveTo(15.25f, 10.98f, 14.56f, 10.82f, 13.86f, 11.02f) + lineTo(11.21f, 11.74f) + curveTo(10.73f, 11.87f, 10.4f, 12.25f, 10.4f, 12.8f) + verticalLineTo(13.72f) + verticalLineTo(14.34f) + verticalLineTo(18.13f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(13.93f, 18.57f) + curveTo(14.659f, 18.57f, 15.25f, 17.979f, 15.25f, 17.25f) + curveTo(15.25f, 16.521f, 14.659f, 15.93f, 13.93f, 15.93f) + curveTo(13.201f, 15.93f, 12.61f, 16.521f, 12.61f, 17.25f) + curveTo(12.61f, 17.979f, 13.201f, 18.57f, 13.93f, 18.57f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(10.4f, 14.35f) + lineTo(15.25f, 13.03f) + } + } + }.build() + + return _IconsaxMusicLibraryOutline!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMusicLibraryOutline: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlay.kt new file mode 100644 index 00000000..423725a6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlay.kt @@ -0,0 +1,109 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMusicPlay: ImageVector + get() { + if (_IconsaxMusicPlay != null) { + return _IconsaxMusicPlay!! + } + _IconsaxMusicPlay = ImageVector.Builder( + name = "IconsaxMusicPlay", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(13.18f, 11.861f) + curveTo(12.78f, 11.861f, 12.42f, 11.641f, 12.25f, 11.281f) + lineTo(10.8f, 8.391f) + lineTo(10.38f, 9.171f) + curveTo(10.15f, 9.601f, 9.69f, 9.871f, 9.2f, 9.871f) + horizontalLineTo(8.47f) + curveTo(8.06f, 9.871f, 7.72f, 9.531f, 7.72f, 9.121f) + curveTo(7.72f, 8.711f, 8.06f, 8.371f, 8.47f, 8.371f) + horizontalLineTo(9.11f) + lineTo(9.9f, 6.911f) + curveTo(10.09f, 6.571f, 10.47f, 6.341f, 10.83f, 6.361f) + curveTo(11.22f, 6.361f, 11.57f, 6.591f, 11.75f, 6.931f) + lineTo(13.18f, 9.791f) + lineTo(13.52f, 9.101f) + curveTo(13.75f, 8.641f, 14.2f, 8.361f, 14.72f, 8.361f) + horizontalLineTo(15.53f) + curveTo(15.94f, 8.361f, 16.28f, 8.701f, 16.28f, 9.111f) + curveTo(16.28f, 9.521f, 15.94f, 9.861f, 15.53f, 9.861f) + horizontalLineTo(14.82f) + lineTo(14.11f, 11.271f) + curveTo(13.93f, 11.641f, 13.58f, 11.861f, 13.18f, 11.861f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(2.75f, 18.651f) + curveTo(2.34f, 18.651f, 2f, 18.311f, 2f, 17.901f) + verticalLineTo(12.201f) + curveTo(1.95f, 9.491f, 2.96f, 6.931f, 4.84f, 5.011f) + curveTo(6.72f, 3.101f, 9.24f, 2.051f, 11.95f, 2.051f) + curveTo(17.49f, 2.051f, 22f, 6.561f, 22f, 12.101f) + verticalLineTo(17.801f) + curveTo(22f, 18.211f, 21.66f, 18.551f, 21.25f, 18.551f) + curveTo(20.84f, 18.551f, 20.5f, 18.211f, 20.5f, 17.801f) + verticalLineTo(12.101f) + curveTo(20.5f, 7.391f, 16.67f, 3.551f, 11.95f, 3.551f) + curveTo(9.64f, 3.551f, 7.5f, 4.441f, 5.91f, 6.061f) + curveTo(4.31f, 7.691f, 3.46f, 9.861f, 3.5f, 12.181f) + verticalLineTo(17.891f) + curveTo(3.5f, 18.311f, 3.17f, 18.651f, 2.75f, 18.651f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(5.94f, 12.451f) + horizontalLineTo(5.81f) + curveTo(3.71f, 12.451f, 2f, 14.161f, 2f, 16.261f) + verticalLineTo(18.141f) + curveTo(2f, 20.241f, 3.71f, 21.951f, 5.81f, 21.951f) + horizontalLineTo(5.94f) + curveTo(8.04f, 21.951f, 9.75f, 20.241f, 9.75f, 18.141f) + verticalLineTo(16.261f) + curveTo(9.75f, 14.161f, 8.04f, 12.451f, 5.94f, 12.451f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(18.19f, 12.451f) + horizontalLineTo(18.06f) + curveTo(15.96f, 12.451f, 14.25f, 14.161f, 14.25f, 16.261f) + verticalLineTo(18.141f) + curveTo(14.25f, 20.241f, 15.96f, 21.951f, 18.06f, 21.951f) + horizontalLineTo(18.19f) + curveTo(20.29f, 21.951f, 22f, 20.241f, 22f, 18.141f) + verticalLineTo(16.261f) + curveTo(22f, 14.161f, 20.29f, 12.451f, 18.19f, 12.451f) + close() + } + } + }.build() + + return _IconsaxMusicPlay!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMusicPlay: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlaylist.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlaylist.kt new file mode 100644 index 00000000..59920a99 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlaylist.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMusicPlaylist: ImageVector + get() { + if (_IconsaxMusicPlaylist != null) { + return _IconsaxMusicPlaylist!! + } + _IconsaxMusicPlaylist = ImageVector.Builder( + name = "IconsaxMusicPlaylist", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(18f, 5.25f) + horizontalLineTo(6f) + curveTo(5.59f, 5.25f, 5.25f, 4.91f, 5.25f, 4.5f) + curveTo(5.25f, 4.09f, 5.59f, 3.75f, 6f, 3.75f) + horizontalLineTo(18f) + curveTo(18.41f, 3.75f, 18.75f, 4.09f, 18.75f, 4.5f) + curveTo(18.75f, 4.91f, 18.41f, 5.25f, 18f, 5.25f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(15f, 2.75f) + horizontalLineTo(9f) + curveTo(8.59f, 2.75f, 8.25f, 2.41f, 8.25f, 2f) + curveTo(8.25f, 1.59f, 8.59f, 1.25f, 9f, 1.25f) + horizontalLineTo(15f) + curveTo(15.41f, 1.25f, 15.75f, 1.59f, 15.75f, 2f) + curveTo(15.75f, 2.41f, 15.41f, 2.75f, 15f, 2.75f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(18f, 7f) + horizontalLineTo(6f) + curveTo(3.8f, 7f, 2f, 8.8f, 2f, 11f) + verticalLineTo(18f) + curveTo(2f, 20.2f, 3.8f, 22f, 6f, 22f) + horizontalLineTo(18f) + curveTo(20.2f, 22f, 22f, 20.2f, 22f, 18f) + verticalLineTo(11f) + curveTo(22f, 8.8f, 20.2f, 7f, 18f, 7f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(15.37f, 9.89f) + curveTo(15.07f, 9.65f, 14.52f, 9.42f, 13.64f, 9.65f) + lineTo(10.91f, 10.4f) + curveTo(10.06f, 10.62f, 9.51f, 11.34f, 9.51f, 12.23f) + verticalLineTo(13.83f) + verticalLineTo(15.73f) + curveTo(9.31f, 15.67f, 9.11f, 15.63f, 8.89f, 15.63f) + curveTo(7.72f, 15.63f, 6.77f, 16.58f, 6.77f, 17.75f) + curveTo(6.77f, 18.92f, 7.72f, 19.87f, 8.89f, 19.87f) + curveTo(10.06f, 19.87f, 11.01f, 18.92f, 11.01f, 17.75f) + verticalLineTo(17.74f) + verticalLineTo(14.41f) + lineTo(14.53f, 13.45f) + verticalLineTo(14.82f) + curveTo(14.33f, 14.76f, 14.13f, 14.72f, 13.91f, 14.72f) + curveTo(12.74f, 14.72f, 11.79f, 15.67f, 11.79f, 16.84f) + curveTo(11.79f, 18.01f, 12.74f, 18.96f, 13.91f, 18.96f) + curveTo(15.06f, 18.96f, 16f, 18.04f, 16.02f, 16.89f) + curveTo(16.02f, 16.87f, 16.03f, 16.86f, 16.03f, 16.84f) + verticalLineTo(12.47f) + verticalLineTo(11.49f) + curveTo(16.03f, 10.59f, 15.67f, 10.12f, 15.37f, 9.89f) + close() + moveTo(8.89f, 18.36f) + curveTo(8.55f, 18.36f, 8.27f, 18.08f, 8.27f, 17.74f) + curveTo(8.27f, 17.4f, 8.55f, 17.12f, 8.89f, 17.12f) + curveTo(9.23f, 17.12f, 9.5f, 17.39f, 9.51f, 17.73f) + curveTo(9.51f, 18.08f, 9.23f, 18.36f, 8.89f, 18.36f) + close() + moveTo(13.91f, 17.45f) + curveTo(13.57f, 17.45f, 13.29f, 17.17f, 13.29f, 16.83f) + curveTo(13.29f, 16.49f, 13.57f, 16.21f, 13.91f, 16.21f) + curveTo(14.25f, 16.21f, 14.53f, 16.49f, 14.53f, 16.83f) + curveTo(14.53f, 17.17f, 14.25f, 17.45f, 13.91f, 17.45f) + close() + } + } + }.build() + + return _IconsaxMusicPlaylist!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMusicPlaylist: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt new file mode 100644 index 00000000..e82979e6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt @@ -0,0 +1,165 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMusicSquareRemove: ImageVector + get() { + if (_IconsaxMusicSquareRemove != null) { + return _IconsaxMusicSquareRemove!! + } + _IconsaxMusicSquareRemove = ImageVector.Builder( + name = "IconsaxMusicSquareRemove", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(21f, 8.65f) + verticalLineTo(14.35f) + curveTo(21f, 14.69f, 20.99f, 15.02f, 20.97f, 15.33f) + curveTo(20.25f, 14.51f, 19.18f, 14f, 18f, 14f) + curveTo(15.79f, 14f, 14f, 15.79f, 14f, 18f) + curveTo(14f, 18.75f, 14.21f, 19.46f, 14.58f, 20.06f) + curveTo(14.78f, 20.4f, 15.04f, 20.71f, 15.34f, 20.97f) + curveTo(15.03f, 20.99f, 14.7f, 21f, 14.35f, 21f) + horizontalLineTo(8.65f) + curveTo(3.9f, 21f, 2f, 19.1f, 2f, 14.35f) + verticalLineTo(8.65f) + curveTo(2f, 3.9f, 3.9f, 2f, 8.65f, 2f) + horizontalLineTo(14.35f) + curveTo(19.1f, 2f, 21f, 3.9f, 21f, 8.65f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(6.72f, 14.42f) + curveTo(7.521f, 14.42f, 8.17f, 13.77f, 8.17f, 12.969f) + curveTo(8.17f, 12.169f, 7.521f, 11.519f, 6.72f, 11.519f) + curveTo(5.919f, 11.519f, 5.27f, 12.169f, 5.27f, 12.969f) + curveTo(5.27f, 13.77f, 5.919f, 14.42f, 6.72f, 14.42f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(13.47f, 12f) + verticalLineTo(6.34f) + curveTo(13.47f, 5.13f, 12.71f, 4.969f, 11.95f, 5.179f) + lineTo(9.06f, 5.969f) + curveTo(8.54f, 6.109f, 8.17f, 6.53f, 8.17f, 7.129f) + verticalLineTo(8.14f) + verticalLineTo(8.819f) + verticalLineTo(12.969f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(12.03f, 13.45f) + curveTo(12.831f, 13.45f, 13.48f, 12.801f, 13.48f, 12f) + curveTo(13.48f, 11.199f, 12.831f, 10.55f, 12.03f, 10.55f) + curveTo(11.229f, 10.55f, 10.58f, 11.199f, 10.58f, 12f) + curveTo(10.58f, 12.801f, 11.229f, 13.45f, 12.03f, 13.45f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(8.17f, 8.83f) + lineTo(13.47f, 7.38f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(22f, 18f) + curveTo(22f, 18.75f, 21.79f, 19.46f, 21.42f, 20.06f) + curveTo(20.73f, 21.22f, 19.46f, 22f, 18f, 22f) + curveTo(16.97f, 22f, 16.04f, 21.61f, 15.34f, 20.97f) + curveTo(15.04f, 20.71f, 14.78f, 20.4f, 14.58f, 20.06f) + curveTo(14.21f, 19.46f, 14f, 18.75f, 14f, 18f) + curveTo(14f, 15.79f, 15.79f, 14f, 18f, 14f) + curveTo(19.18f, 14f, 20.25f, 14.51f, 20.97f, 15.33f) + curveTo(21.61f, 16.04f, 22f, 16.98f, 22f, 18f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(19.07f, 19.04f) + lineTo(16.95f, 16.93f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(19.05f, 16.96f) + lineTo(16.93f, 19.07f) + } + } + }.build() + + return _IconsaxMusicSquareRemove!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMusicSquareRemove: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt new file mode 100644 index 00000000..e5629855 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt @@ -0,0 +1,129 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMusicSquareRemoveFilled: ImageVector + get() { + if (_IconsaxMusicSquareRemoveFilled != null) { + return _IconsaxMusicSquareRemoveFilled!! + } + _IconsaxMusicSquareRemoveFilled = ImageVector.Builder( + name = "IconsaxMusicSquareRemoveFilled", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(21.97f, 16.33f) + curveTo(21.25f, 15.51f, 20.18f, 15f, 19f, 15f) + curveTo(16.79f, 15f, 15f, 16.79f, 15f, 19f) + curveTo(15f, 19.75f, 15.21f, 20.46f, 15.58f, 21.06f) + curveTo(15.78f, 21.4f, 16.04f, 21.71f, 16.34f, 21.97f) + curveTo(17.04f, 22.61f, 17.97f, 23f, 19f, 23f) + curveTo(20.46f, 23f, 21.73f, 22.22f, 22.42f, 21.06f) + curveTo(22.79f, 20.46f, 23f, 19.75f, 23f, 19f) + curveTo(23f, 17.98f, 22.61f, 17.04f, 21.97f, 16.33f) + close() + moveTo(20.6f, 20.58f) + curveTo(20.45f, 20.73f, 20.26f, 20.8f, 20.07f, 20.8f) + curveTo(19.88f, 20.8f, 19.69f, 20.73f, 19.54f, 20.58f) + lineTo(19.01f, 20.05f) + lineTo(18.46f, 20.6f) + curveTo(18.31f, 20.75f, 18.12f, 20.82f, 17.93f, 20.82f) + curveTo(17.74f, 20.82f, 17.55f, 20.75f, 17.4f, 20.6f) + curveTo(17.11f, 20.31f, 17.11f, 19.83f, 17.4f, 19.54f) + lineTo(17.95f, 18.99f) + lineTo(17.42f, 18.46f) + curveTo(17.13f, 18.17f, 17.13f, 17.69f, 17.42f, 17.4f) + curveTo(17.71f, 17.11f, 18.19f, 17.11f, 18.48f, 17.4f) + lineTo(19.01f, 17.93f) + lineTo(19.51f, 17.43f) + curveTo(19.8f, 17.14f, 20.28f, 17.14f, 20.57f, 17.43f) + curveTo(20.86f, 17.72f, 20.86f, 18.2f, 20.57f, 18.49f) + lineTo(20.07f, 18.99f) + lineTo(20.6f, 19.52f) + curveTo(20.89f, 19.81f, 20.89f, 20.28f, 20.6f, 20.58f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12.23f, 12.07f) + curveTo(11.82f, 12.07f, 11.49f, 12.4f, 11.49f, 12.81f) + curveTo(11.49f, 13.22f, 11.82f, 13.55f, 12.23f, 13.55f) + curveTo(12.64f, 13.55f, 12.97f, 13.22f, 12.97f, 12.81f) + curveTo(12.97f, 12.4f, 12.64f, 12.07f, 12.23f, 12.07f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(6.76f, 13.06f) + curveTo(6.35f, 13.06f, 6.02f, 13.39f, 6.02f, 13.8f) + curveTo(6.02f, 14.21f, 6.35f, 14.54f, 6.76f, 14.54f) + curveTo(7.17f, 14.54f, 7.5f, 14.21f, 7.5f, 13.8f) + curveTo(7.5f, 13.39f, 7.17f, 13.06f, 6.76f, 13.06f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(16.19f, 2f) + horizontalLineTo(7.81f) + curveTo(7.53f, 2f, 7.26f, 2.01f, 7f, 2.05f) + curveTo(3.85f, 2.34f, 2f, 4.45f, 2f, 7.81f) + verticalLineTo(16.19f) + curveTo(2f, 19.55f, 3.85f, 21.66f, 7f, 21.95f) + curveTo(7.26f, 21.99f, 7.53f, 22f, 7.81f, 22f) + horizontalLineTo(13.5f) + curveTo(13.89f, 22f, 14.14f, 21.56f, 13.99f, 21.2f) + curveTo(13.7f, 20.51f, 13.5f, 19.71f, 13.5f, 19f) + curveTo(13.5f, 15.97f, 15.97f, 13.5f, 19f, 13.5f) + curveTo(19.76f, 13.5f, 20.5f, 13.65f, 21.18f, 13.95f) + curveTo(21.55f, 14.11f, 22f, 13.86f, 22f, 13.46f) + verticalLineTo(7.81f) + curveTo(22f, 4.17f, 19.83f, 2f, 16.19f, 2f) + close() + moveTo(14.48f, 8.05f) + verticalLineTo(12.81f) + curveTo(14.48f, 12.82f, 14.47f, 12.83f, 14.47f, 12.85f) + curveTo(14.45f, 14.07f, 13.46f, 15.06f, 12.23f, 15.06f) + curveTo(10.99f, 15.06f, 9.99f, 14.05f, 9.99f, 12.82f) + curveTo(9.99f, 11.58f, 11f, 10.58f, 12.23f, 10.58f) + curveTo(12.49f, 10.58f, 12.74f, 10.63f, 12.98f, 10.72f) + verticalLineTo(9.03f) + lineTo(9.01f, 10.11f) + verticalLineTo(13.81f) + curveTo(9.01f, 13.82f, 9f, 13.83f, 9f, 13.84f) + curveTo(8.98f, 15.06f, 7.99f, 16.04f, 6.76f, 16.04f) + curveTo(5.52f, 16.04f, 4.52f, 15.03f, 4.52f, 13.8f) + curveTo(4.52f, 12.57f, 5.53f, 11.56f, 6.76f, 11.56f) + curveTo(7.02f, 11.56f, 7.27f, 11.61f, 7.5f, 11.7f) + verticalLineTo(9.54f) + verticalLineTo(7.79f) + curveTo(7.5f, 6.86f, 8.08f, 6.11f, 8.97f, 5.87f) + lineTo(11.95f, 5.05f) + curveTo(12.88f, 4.8f, 13.45f, 5.05f, 13.77f, 5.29f) + curveTo(14.23f, 5.64f, 14.46f, 6.21f, 14.46f, 6.97f) + verticalLineTo(8.05f) + horizontalLineTo(14.48f) + close() + } + } + }.build() + + return _IconsaxMusicSquareRemoveFilled!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMusicSquareRemoveFilled: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxNext.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxNext.kt new file mode 100644 index 00000000..c7fb610b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxNext.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxNext: ImageVector + get() { + if (_IconsaxNext != null) { + return _IconsaxNext!! + } + _IconsaxNext = ImageVector.Builder( + name = "IconsaxNext", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(3.76f, 7.22f) + verticalLineTo(16.79f) + curveTo(3.76f, 18.75f, 5.89f, 19.98f, 7.59f, 19f) + lineTo(11.74f, 16.61f) + lineTo(15.89f, 14.21f) + curveTo(17.59f, 13.23f, 17.59f, 10.78f, 15.89f, 9.8f) + lineTo(11.74f, 7.4f) + lineTo(7.59f, 5.01f) + curveTo(5.89f, 4.03f, 3.76f, 5.25f, 3.76f, 7.22f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(20.24f, 18.93f) + curveTo(19.83f, 18.93f, 19.49f, 18.59f, 19.49f, 18.18f) + verticalLineTo(5.82f) + curveTo(19.49f, 5.41f, 19.83f, 5.07f, 20.24f, 5.07f) + curveTo(20.65f, 5.07f, 20.99f, 5.41f, 20.99f, 5.82f) + verticalLineTo(18.18f) + curveTo(20.99f, 18.59f, 20.66f, 18.93f, 20.24f, 18.93f) + close() + } + } + }.build() + + return _IconsaxNext!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxNext: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPause.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPause.kt new file mode 100644 index 00000000..f7735bad --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPause.kt @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxPause: ImageVector + get() { + if (_IconsaxPause != null) { + return _IconsaxPause!! + } + _IconsaxPause = ImageVector.Builder( + name = "IconsaxPause", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(10.65f, 19.11f) + verticalLineTo(4.89f) + curveTo(10.65f, 3.54f, 10.08f, 3f, 8.64f, 3f) + horizontalLineTo(5.01f) + curveTo(3.57f, 3f, 3f, 3.54f, 3f, 4.89f) + verticalLineTo(19.11f) + curveTo(3f, 20.46f, 3.57f, 21f, 5.01f, 21f) + horizontalLineTo(8.64f) + curveTo(10.08f, 21f, 10.65f, 20.46f, 10.65f, 19.11f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(21f, 19.11f) + verticalLineTo(4.89f) + curveTo(21f, 3.54f, 20.43f, 3f, 18.99f, 3f) + horizontalLineTo(15.36f) + curveTo(13.93f, 3f, 13.35f, 3.54f, 13.35f, 4.89f) + verticalLineTo(19.11f) + curveTo(13.35f, 20.46f, 13.92f, 21f, 15.36f, 21f) + horizontalLineTo(18.99f) + curveTo(20.43f, 21f, 21f, 20.46f, 21f, 19.11f) + close() + } + } + }.build() + + return _IconsaxPause!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxPause: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPauseCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPauseCircle.kt new file mode 100644 index 00000000..9eb8430d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPauseCircle.kt @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxPauseCircle: ImageVector + get() { + if (_IconsaxPauseCircle != null) { + return _IconsaxPauseCircle!! + } + _IconsaxPauseCircle = ImageVector.Builder( + name = "IconsaxPauseCircle", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(11.97f, 22f) + curveTo(17.493f, 22f, 21.97f, 17.523f, 21.97f, 12f) + curveTo(21.97f, 6.477f, 17.493f, 2f, 11.97f, 2f) + curveTo(6.447f, 2f, 1.97f, 6.477f, 1.97f, 12f) + curveTo(1.97f, 17.523f, 6.447f, 22f, 11.97f, 22f) + close() + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(10.72f, 14.53f) + verticalLineTo(9.47f) + curveTo(10.72f, 8.99f, 10.52f, 8.8f, 10.01f, 8.8f) + horizontalLineTo(8.71f) + curveTo(8.2f, 8.8f, 8f, 8.99f, 8f, 9.47f) + verticalLineTo(14.53f) + curveTo(8f, 15.01f, 8.2f, 15.2f, 8.71f, 15.2f) + horizontalLineTo(10f) + curveTo(10.52f, 15.2f, 10.72f, 15.01f, 10.72f, 14.53f) + close() + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(16f, 14.53f) + verticalLineTo(9.47f) + curveTo(16f, 8.99f, 15.8f, 8.8f, 15.29f, 8.8f) + horizontalLineTo(14f) + curveTo(13.49f, 8.8f, 13.29f, 8.99f, 13.29f, 9.47f) + verticalLineTo(14.53f) + curveTo(13.29f, 15.01f, 13.49f, 15.2f, 14f, 15.2f) + horizontalLineTo(15.29f) + curveTo(15.8f, 15.2f, 16f, 15.01f, 16f, 14.53f) + close() + } + } + }.build() + + return _IconsaxPauseCircle!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxPauseCircle: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlay.kt new file mode 100644 index 00000000..36cdabd9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlay.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxPlay: ImageVector + get() { + if (_IconsaxPlay != null) { + return _IconsaxPlay!! + } + _IconsaxPlay = ImageVector.Builder( + name = "IconsaxPlay", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(18.7f, 8.98f) + lineTo(4.14f, 17.71f) + curveTo(4.05f, 17.38f, 4f, 17.03f, 4f, 16.67f) + verticalLineTo(7.33f) + curveTo(4f, 4.25f, 7.33f, 2.33f, 10f, 3.87f) + lineTo(14.04f, 6.2f) + lineTo(18.09f, 8.54f) + curveTo(18.31f, 8.67f, 18.52f, 8.81f, 18.7f, 8.98f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(18.09f, 15.46f) + lineTo(14.04f, 17.8f) + lineTo(10f, 20.13f) + curveTo(8.09f, 21.23f, 5.84f, 20.57f, 4.72f, 18.96f) + lineTo(5.14f, 18.71f) + lineTo(19.58f, 10.05f) + curveTo(20.58f, 11.85f, 20.09f, 14.31f, 18.09f, 15.46f) + close() + } + } + }.build() + + return _IconsaxPlay!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxPlay: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle.kt new file mode 100644 index 00000000..ae5334bd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxPlayCircle: ImageVector + get() { + if (_IconsaxPlayCircle != null) { + return _IconsaxPlayCircle!! + } + _IconsaxPlayCircle = ImageVector.Builder( + name = "IconsaxPlayCircle", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(14.91f, 14.12f) + curveTo(16.71f, 13.08f, 16.71f, 11.38f, 14.91f, 10.34f) + lineTo(13.46f, 9.5f) + lineTo(12.01f, 8.66f) + curveTo(10.21f, 7.62f, 8.74f, 8.47f, 8.74f, 10.55f) + verticalLineTo(12.22f) + verticalLineTo(13.89f) + curveTo(8.74f, 15.55f, 9.68f, 16.43f, 10.98f, 16.18f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(4f, 6f) + curveTo(2.75f, 7.67f, 2f, 9.75f, 2f, 12f) + curveTo(2f, 17.52f, 6.48f, 22f, 12f, 22f) + curveTo(17.52f, 22f, 22f, 17.52f, 22f, 12f) + curveTo(22f, 6.48f, 17.52f, 2f, 12f, 2f) + curveTo(10.57f, 2f, 9.2f, 2.3f, 7.97f, 2.85f) + } + } + }.build() + + return _IconsaxPlayCircle!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxPlayCircle: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle2.kt new file mode 100644 index 00000000..8c6c54ba --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle2.kt @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxPlayCircle2: ImageVector + get() { + if (_IconsaxPlayCircle2 != null) { + return _IconsaxPlayCircle2!! + } + _IconsaxPlayCircle2 = ImageVector.Builder( + name = "IconsaxPlayCircle2", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(19.07f, 19.82f) + curveTo(18.88f, 19.82f, 18.69f, 19.75f, 18.54f, 19.6f) + curveTo(18.25f, 19.31f, 18.25f, 18.83f, 18.54f, 18.54f) + curveTo(22.15f, 14.93f, 22.15f, 9.06f, 18.54f, 5.46f) + curveTo(18.25f, 5.17f, 18.25f, 4.69f, 18.54f, 4.4f) + curveTo(18.83f, 4.11f, 19.31f, 4.11f, 19.6f, 4.4f) + curveTo(23.79f, 8.59f, 23.79f, 15.41f, 19.6f, 19.6f) + curveTo(19.45f, 19.75f, 19.26f, 19.82f, 19.07f, 19.82f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(4.93f, 19.82f) + curveTo(4.74f, 19.82f, 4.55f, 19.75f, 4.4f, 19.6f) + curveTo(0.21f, 15.41f, 0.21f, 8.59f, 4.4f, 4.4f) + curveTo(4.69f, 4.11f, 5.17f, 4.11f, 5.46f, 4.4f) + curveTo(5.75f, 4.69f, 5.75f, 5.17f, 5.46f, 5.46f) + curveTo(1.85f, 9.07f, 1.85f, 14.94f, 5.46f, 18.54f) + curveTo(5.75f, 18.83f, 5.75f, 19.31f, 5.46f, 19.6f) + curveTo(5.31f, 19.75f, 5.12f, 19.82f, 4.93f, 19.82f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(12f, 22.71f) + curveTo(10.75f, 22.7f, 9.56f, 22.5f, 8.45f, 22.11f) + curveTo(8.06f, 21.97f, 7.85f, 21.54f, 7.99f, 21.15f) + curveTo(8.13f, 20.76f, 8.55f, 20.55f, 8.95f, 20.69f) + curveTo(9.91f, 21.02f, 10.93f, 21.2f, 12.01f, 21.2f) + curveTo(13.08f, 21.2f, 14.11f, 21.02f, 15.06f, 20.69f) + curveTo(15.45f, 20.56f, 15.88f, 20.76f, 16.02f, 21.15f) + curveTo(16.16f, 21.54f, 15.95f, 21.97f, 15.56f, 22.11f) + curveTo(14.44f, 22.5f, 13.25f, 22.71f, 12f, 22.71f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(15.3f, 3.34f) + curveTo(15.22f, 3.34f, 15.13f, 3.33f, 15.05f, 3.3f) + curveTo(14.09f, 2.97f, 13.06f, 2.79f, 11.99f, 2.79f) + curveTo(10.92f, 2.79f, 9.9f, 2.97f, 8.94f, 3.3f) + curveTo(8.55f, 3.43f, 8.12f, 3.23f, 7.98f, 2.84f) + curveTo(7.84f, 2.45f, 8.05f, 2.02f, 8.44f, 1.88f) + curveTo(9.55f, 1.49f, 10.75f, 1.29f, 11.99f, 1.29f) + curveTo(13.23f, 1.29f, 14.43f, 1.49f, 15.54f, 1.88f) + curveTo(15.93f, 2.02f, 16.14f, 2.45f, 16f, 2.84f) + curveTo(15.9f, 3.15f, 15.61f, 3.34f, 15.3f, 3.34f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(8.74f, 12f) + verticalLineTo(10.33f) + curveTo(8.74f, 8.25f, 10.21f, 7.4f, 12.01f, 8.44f) + lineTo(13.46f, 9.28f) + lineTo(14.91f, 10.12f) + curveTo(16.71f, 11.16f, 16.71f, 12.86f, 14.91f, 13.9f) + lineTo(13.46f, 14.74f) + lineTo(12.01f, 15.58f) + curveTo(10.21f, 16.62f, 8.74f, 15.77f, 8.74f, 13.69f) + verticalLineTo(12f) + close() + } + } + }.build() + + return _IconsaxPlayCircle2!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxPlayCircle2: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPrevious.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPrevious.kt new file mode 100644 index 00000000..34545a14 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPrevious.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxPrevious: ImageVector + get() { + if (_IconsaxPrevious != null) { + return _IconsaxPrevious!! + } + _IconsaxPrevious = ImageVector.Builder( + name = "IconsaxPrevious", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(20.24f, 7.22f) + verticalLineTo(16.79f) + curveTo(20.24f, 18.75f, 18.11f, 19.98f, 16.41f, 19f) + lineTo(12.26f, 16.61f) + lineTo(8.11f, 14.21f) + curveTo(6.41f, 13.23f, 6.41f, 10.78f, 8.11f, 9.8f) + lineTo(12.26f, 7.4f) + lineTo(16.41f, 5.01f) + curveTo(18.11f, 4.03f, 20.24f, 5.25f, 20.24f, 7.22f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(3.76f, 18.93f) + curveTo(3.35f, 18.93f, 3.01f, 18.59f, 3.01f, 18.18f) + verticalLineTo(5.82f) + curveTo(3.01f, 5.41f, 3.35f, 5.07f, 3.76f, 5.07f) + curveTo(4.17f, 5.07f, 4.51f, 5.41f, 4.51f, 5.82f) + verticalLineTo(18.18f) + curveTo(4.51f, 18.59f, 4.17f, 18.93f, 3.76f, 18.93f) + close() + } + } + }.build() + + return _IconsaxPrevious!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxPrevious: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshArrow2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshArrow2.kt new file mode 100644 index 00000000..8ae9eb93 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshArrow2.kt @@ -0,0 +1,83 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxRefreshArrow2: ImageVector + get() { + if (_IconsaxRefreshArrow2 != null) { + return _IconsaxRefreshArrow2!! + } + _IconsaxRefreshArrow2 = ImageVector.Builder( + name = "IconsaxRefreshArrow2", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(22f, 7.81f) + verticalLineTo(16.18f) + curveTo(22f, 19.82f, 19.83f, 21.99f, 16.19f, 21.99f) + horizontalLineTo(7.81f) + curveTo(4.17f, 22f, 2f, 19.83f, 2f, 16.19f) + verticalLineTo(7.81f) + curveTo(2f, 4.17f, 4.17f, 2f, 7.81f, 2f) + horizontalLineTo(16.18f) + curveTo(19.83f, 2f, 22f, 4.17f, 22f, 7.81f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 18.25f) + curveTo(10.21f, 18.25f, 8.81f, 17.36f, 7.86f, 16.48f) + verticalLineTo(17.19f) + curveTo(7.86f, 17.6f, 7.52f, 17.94f, 7.11f, 17.94f) + curveTo(6.7f, 17.94f, 6.36f, 17.6f, 6.36f, 17.19f) + verticalLineTo(14.44f) + curveTo(6.36f, 14.03f, 6.7f, 13.69f, 7.11f, 13.69f) + horizontalLineTo(9.59f) + curveTo(10f, 13.69f, 10.34f, 14.03f, 10.34f, 14.44f) + curveTo(10.34f, 14.85f, 10f, 15.19f, 9.59f, 15.19f) + horizontalLineTo(8.69f) + curveTo(9.43f, 15.93f, 10.58f, 16.75f, 12f, 16.75f) + curveTo(14.62f, 16.75f, 16.75f, 14.62f, 16.75f, 12f) + curveTo(16.75f, 11.59f, 17.09f, 11.25f, 17.5f, 11.25f) + curveTo(17.91f, 11.25f, 18.25f, 11.59f, 18.25f, 12f) + curveTo(18.25f, 15.45f, 15.45f, 18.25f, 12f, 18.25f) + close() + moveTo(6.5f, 12.75f) + curveTo(6.09f, 12.75f, 5.75f, 12.41f, 5.75f, 12f) + curveTo(5.75f, 8.55f, 8.55f, 5.75f, 12f, 5.75f) + curveTo(14.15f, 5.75f, 15.73f, 6.68f, 16.75f, 7.57f) + verticalLineTo(6.81f) + curveTo(16.75f, 6.4f, 17.09f, 6.06f, 17.5f, 6.06f) + curveTo(17.91f, 6.06f, 18.25f, 6.4f, 18.25f, 6.81f) + verticalLineTo(9.55f) + curveTo(18.25f, 9.58f, 18.25f, 9.61f, 18.25f, 9.63f) + curveTo(18.24f, 9.74f, 18.21f, 9.84f, 18.16f, 9.93f) + curveTo(18.11f, 10.02f, 18.04f, 10.1f, 17.95f, 10.17f) + curveTo(17.88f, 10.22f, 17.8f, 10.26f, 17.71f, 10.29f) + curveTo(17.64f, 10.31f, 17.57f, 10.32f, 17.5f, 10.32f) + horizontalLineTo(15.07f) + curveTo(14.66f, 10.32f, 14.32f, 9.98f, 14.32f, 9.57f) + curveTo(14.32f, 9.16f, 14.66f, 8.82f, 15.07f, 8.82f) + horizontalLineTo(15.9f) + curveTo(15.1f, 8.08f, 13.81f, 7.26f, 12.02f, 7.26f) + curveTo(9.4f, 7.26f, 7.27f, 9.39f, 7.27f, 12.01f) + curveTo(7.25f, 12.41f, 6.91f, 12.75f, 6.5f, 12.75f) + close() + } + }.build() + + return _IconsaxRefreshArrow2!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxRefreshArrow2: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshRight.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshRight.kt new file mode 100644 index 00000000..ed56e82d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshRight.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxRefreshRight: ImageVector + get() { + if (_IconsaxRefreshRight != null) { + return _IconsaxRefreshRight!! + } + _IconsaxRefreshRight = ImageVector.Builder( + name = "IconsaxRefreshRight", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16.19f, 2f) + horizontalLineTo(7.82f) + curveTo(4.17f, 2f, 2f, 4.17f, 2f, 7.81f) + verticalLineTo(16.18f) + curveTo(2f, 19.82f, 4.17f, 21.99f, 7.81f, 21.99f) + horizontalLineTo(16.18f) + curveTo(19.82f, 21.99f, 21.99f, 19.82f, 21.99f, 16.18f) + verticalLineTo(7.81f) + curveTo(22f, 4.17f, 19.83f, 2f, 16.19f, 2f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(16.78f, 9.07f) + curveTo(16.55f, 8.72f, 16.08f, 8.63f, 15.74f, 8.86f) + curveTo(15.4f, 9.09f, 15.3f, 9.56f, 15.53f, 9.9f) + curveTo(16f, 10.6f, 16.24f, 11.42f, 16.24f, 12.26f) + curveTo(16.24f, 14.6f, 14.33f, 16.51f, 11.99f, 16.51f) + curveTo(9.65f, 16.51f, 7.74f, 14.6f, 7.74f, 12.26f) + curveTo(7.74f, 9.92f, 9.65f, 8.01f, 11.99f, 8.01f) + curveTo(12.18f, 8.01f, 12.36f, 8.03f, 12.55f, 8.05f) + lineTo(12f, 8.46f) + curveTo(11.67f, 8.7f, 11.59f, 9.17f, 11.84f, 9.51f) + curveTo(11.99f, 9.71f, 12.22f, 9.82f, 12.45f, 9.82f) + curveTo(12.6f, 9.82f, 12.76f, 9.77f, 12.89f, 9.68f) + lineTo(14.83f, 8.26f) + curveTo(14.84f, 8.25f, 14.84f, 8.24f, 14.85f, 8.24f) + curveTo(14.86f, 8.23f, 14.87f, 8.23f, 14.88f, 8.22f) + curveTo(14.91f, 8.19f, 14.93f, 8.16f, 14.95f, 8.13f) + curveTo(14.98f, 8.09f, 15.02f, 8.06f, 15.04f, 8.01f) + curveTo(15.06f, 7.97f, 15.07f, 7.92f, 15.09f, 7.88f) + curveTo(15.1f, 7.83f, 15.12f, 7.79f, 15.13f, 7.74f) + curveTo(15.14f, 7.69f, 15.13f, 7.65f, 15.12f, 7.6f) + curveTo(15.12f, 7.55f, 15.12f, 7.51f, 15.1f, 7.46f) + curveTo(15.09f, 7.41f, 15.06f, 7.37f, 15.04f, 7.32f) + curveTo(15.02f, 7.29f, 15.02f, 7.25f, 14.99f, 7.21f) + curveTo(14.98f, 7.2f, 14.97f, 7.2f, 14.97f, 7.19f) + curveTo(14.96f, 7.18f, 14.96f, 7.17f, 14.95f, 7.16f) + lineTo(13.28f, 5.25f) + curveTo(13.01f, 4.94f, 12.53f, 4.9f, 12.22f, 5.18f) + curveTo(11.91f, 5.45f, 11.88f, 5.93f, 12.15f, 6.24f) + lineTo(12.43f, 6.56f) + curveTo(12.29f, 6.55f, 12.15f, 6.53f, 12f, 6.53f) + curveTo(8.83f, 6.53f, 6.25f, 9.11f, 6.25f, 12.28f) + curveTo(6.25f, 15.45f, 8.83f, 18.03f, 12f, 18.03f) + curveTo(15.17f, 18.03f, 17.75f, 15.45f, 17.75f, 12.28f) + curveTo(17.75f, 11.12f, 17.42f, 10.02f, 16.78f, 9.07f) + close() + } + }.build() + + return _IconsaxRefreshRight!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxRefreshRight: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatArrow.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatArrow.kt new file mode 100644 index 00000000..919d5887 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatArrow.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxRepeatArrow: ImageVector + get() { + if (_IconsaxRepeatArrow != null) { + return _IconsaxRepeatArrow!! + } + _IconsaxRepeatArrow = ImageVector.Builder( + name = "IconsaxRepeatArrow", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(3.58f, 5.16f) + horizontalLineTo(17.42f) + curveTo(19.08f, 5.16f, 20.42f, 6.5f, 20.42f, 8.16f) + verticalLineTo(11.48f) + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(6.74f, 2f) + lineTo(3.58f, 5.16f) + lineTo(6.74f, 8.32f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(20.42f, 18.84f) + horizontalLineTo(6.58f) + curveTo(4.92f, 18.84f, 3.58f, 17.5f, 3.58f, 15.84f) + verticalLineTo(12.519f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(17.26f, 22f) + lineTo(20.42f, 18.84f) + lineTo(17.26f, 15.68f) + } + }.build() + + return _IconsaxRepeatArrow!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxRepeatArrow: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatMusic.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatMusic.kt new file mode 100644 index 00000000..9ac0a975 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatMusic.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxRepeatMusic: ImageVector + get() { + if (_IconsaxRepeatMusic != null) { + return _IconsaxRepeatMusic!! + } + _IconsaxRepeatMusic = ImageVector.Builder( + name = "IconsaxRepeatMusic", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(3.66f, 16.931f) + curveTo(3.47f, 16.931f, 3.28f, 16.861f, 3.13f, 16.711f) + curveTo(1.76f, 15.331f, 1f, 13.511f, 1f, 11.581f) + curveTo(1f, 7.571f, 4.25f, 4.311f, 8.25f, 4.311f) + lineTo(14.32f, 4.331f) + lineTo(13.23f, 3.291f) + curveTo(12.93f, 3.001f, 12.92f, 2.531f, 13.21f, 2.231f) + curveTo(13.5f, 1.931f, 13.97f, 1.921f, 14.27f, 2.211f) + lineTo(16.71f, 4.551f) + curveTo(16.93f, 4.761f, 17f, 5.091f, 16.89f, 5.371f) + curveTo(16.78f, 5.651f, 16.5f, 5.841f, 16.19f, 5.841f) + lineTo(8.24f, 5.821f) + curveTo(5.07f, 5.821f, 2.49f, 8.411f, 2.49f, 11.591f) + curveTo(2.49f, 13.121f, 3.09f, 14.571f, 4.18f, 15.661f) + curveTo(4.47f, 15.951f, 4.47f, 16.431f, 4.18f, 16.721f) + curveTo(4.04f, 16.861f, 3.85f, 16.931f, 3.66f, 16.931f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(9.75f, 21.501f) + curveTo(9.56f, 21.501f, 9.38f, 21.431f, 9.23f, 21.291f) + lineTo(6.79f, 18.951f) + curveTo(6.57f, 18.741f, 6.5f, 18.411f, 6.61f, 18.131f) + curveTo(6.72f, 17.851f, 7f, 17.661f, 7.31f, 17.661f) + lineTo(15.26f, 17.681f) + curveTo(18.43f, 17.681f, 21.01f, 15.091f, 21.01f, 11.911f) + curveTo(21.01f, 10.381f, 20.41f, 8.931f, 19.32f, 7.841f) + curveTo(19.03f, 7.551f, 19.03f, 7.071f, 19.32f, 6.781f) + curveTo(19.61f, 6.491f, 20.09f, 6.491f, 20.38f, 6.781f) + curveTo(21.75f, 8.161f, 22.51f, 9.981f, 22.51f, 11.911f) + curveTo(22.51f, 15.921f, 19.26f, 19.181f, 15.26f, 19.181f) + lineTo(9.19f, 19.161f) + lineTo(10.28f, 20.201f) + curveTo(10.58f, 20.491f, 10.59f, 20.961f, 10.3f, 21.261f) + curveTo(10.14f, 21.421f, 9.95f, 21.501f, 9.75f, 21.501f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(9f, 15.5f) + horizontalLineTo(15f) + curveTo(16.93f, 15.5f, 18.5f, 13.92f, 18.5f, 12f) + curveTo(18.5f, 10.08f, 16.93f, 8.5f, 15f, 8.5f) + horizontalLineTo(9f) + curveTo(7.07f, 8.5f, 5.5f, 10.08f, 5.5f, 12f) + curveTo(5.5f, 13.92f, 7.07f, 15.5f, 9f, 15.5f) + close() + } + } + }.build() + + return _IconsaxRepeatMusic!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxRepeatMusic: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateMusic.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateMusic.kt new file mode 100644 index 00000000..f7088220 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateMusic.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxRepeateMusic: ImageVector + get() { + if (_IconsaxRepeateMusic != null) { + return _IconsaxRepeateMusic!! + } + _IconsaxRepeateMusic = ImageVector.Builder( + name = "IconsaxRepeateMusic", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(14f, 3f) + lineTo(16.44f, 5.34f) + lineTo(8.49f, 5.32f) + curveTo(4.92f, 5.32f, 1.99f, 8.25f, 1.99f, 11.84f) + curveTo(1.99f, 13.63f, 2.72f, 15.26f, 3.9f, 16.44f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(10f, 21f) + lineTo(7.56f, 18.66f) + lineTo(15.51f, 18.68f) + curveTo(19.08f, 18.68f, 22.01f, 15.75f, 22.01f, 12.16f) + curveTo(22.01f, 10.37f, 21.28f, 8.74f, 20.1f, 7.56f) + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(9f, 12f) + horizontalLineTo(15f) + } + } + }.build() + + return _IconsaxRepeateMusic!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxRepeateMusic: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateOne.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateOne.kt new file mode 100644 index 00000000..77151a13 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateOne.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxRepeateOne: ImageVector + get() { + if (_IconsaxRepeateOne != null) { + return _IconsaxRepeateOne!! + } + _IconsaxRepeateOne = ImageVector.Builder( + name = "IconsaxRepeateOne", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(14f, 3f) + lineTo(16.44f, 5.34f) + lineTo(8.49f, 5.32f) + curveTo(4.92f, 5.32f, 1.99f, 8.25f, 1.99f, 11.84f) + curveTo(1.99f, 13.63f, 2.72f, 15.26f, 3.9f, 16.44f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(10f, 21f) + lineTo(7.56f, 18.66f) + lineTo(15.51f, 18.68f) + curveTo(19.08f, 18.68f, 22.01f, 15.75f, 22.01f, 12.16f) + curveTo(22.01f, 10.37f, 21.28f, 8.74f, 20.1f, 7.56f) + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(12.25f, 14.67f) + verticalLineTo(9.33f) + lineTo(10.75f, 11f) + } + } + }.build() + + return _IconsaxRepeateOne!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxRepeateOne: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearch.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearch.kt new file mode 100644 index 00000000..93489b51 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearch.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxSearch: ImageVector + get() { + if (_IconsaxSearch != null) { + return _IconsaxSearch!! + } + _IconsaxSearch = ImageVector.Builder( + name = "IconsaxSearch", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(11.5f, 21f) + curveTo(16.747f, 21f, 21f, 16.747f, 21f, 11.5f) + curveTo(21f, 6.253f, 16.747f, 2f, 11.5f, 2f) + curveTo(6.253f, 2f, 2f, 6.253f, 2f, 11.5f) + curveTo(2f, 16.747f, 6.253f, 21f, 11.5f, 21f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(21.3f, 22f) + curveTo(21.12f, 22f, 20.94f, 21.93f, 20.81f, 21.8f) + lineTo(18.95f, 19.94f) + curveTo(18.68f, 19.67f, 18.68f, 19.23f, 18.95f, 18.95f) + curveTo(19.22f, 18.68f, 19.66f, 18.68f, 19.94f, 18.95f) + lineTo(21.8f, 20.81f) + curveTo(22.07f, 21.08f, 22.07f, 21.52f, 21.8f, 21.8f) + curveTo(21.66f, 21.93f, 21.48f, 22f, 21.3f, 22f) + close() + } + }.build() + + return _IconsaxSearch!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxSearch: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearchBroken.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearchBroken.kt new file mode 100644 index 00000000..31002b0d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearchBroken.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxSearchBroken: ImageVector + get() { + if (_IconsaxSearchBroken != null) { + return _IconsaxSearchBroken!! + } + _IconsaxSearchBroken = ImageVector.Builder( + name = "IconsaxSearchBroken", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(11.5f, 2f) + curveTo(16.75f, 2f, 21f, 6.25f, 21f, 11.5f) + curveTo(21f, 16.75f, 16.75f, 21f, 11.5f, 21f) + curveTo(6.25f, 21f, 2f, 16.75f, 2f, 11.5f) + curveTo(2f, 7.8f, 4.11f, 4.6f, 7.2f, 3.03f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(22f, 22f) + lineTo(20f, 20f) + } + }.build() + + return _IconsaxSearchBroken!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxSearchBroken: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting.kt new file mode 100644 index 00000000..22554bfe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting.kt @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxSetting: ImageVector + get() { + if (_IconsaxSetting != null) { + return _IconsaxSetting!! + } + _IconsaxSetting = ImageVector.Builder( + name = "IconsaxSetting", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(18.51f, 8.251f) + curveTo(19.01f, 8.251f, 19.41f, 7.851f, 19.41f, 7.351f) + verticalLineTo(2.701f) + curveTo(19.41f, 2.201f, 19.01f, 1.801f, 18.51f, 1.801f) + curveTo(18.01f, 1.801f, 17.61f, 2.201f, 17.61f, 2.701f) + verticalLineTo(7.351f) + curveTo(17.61f, 7.841f, 18.02f, 8.251f, 18.51f, 8.251f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 15.75f) + curveTo(11.5f, 15.75f, 11.1f, 16.15f, 11.1f, 16.65f) + verticalLineTo(21.3f) + curveTo(11.1f, 21.8f, 11.5f, 22.2f, 12f, 22.2f) + curveTo(12.5f, 22.2f, 12.9f, 21.8f, 12.9f, 21.3f) + verticalLineTo(16.65f) + curveTo(12.9f, 16.16f, 12.5f, 15.75f, 12f, 15.75f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(5.49f, 8.251f) + curveTo(5.99f, 8.251f, 6.39f, 7.851f, 6.39f, 7.351f) + verticalLineTo(2.701f) + curveTo(6.39f, 2.201f, 5.99f, 1.801f, 5.49f, 1.801f) + curveTo(4.99f, 1.801f, 4.59f, 2.201f, 4.59f, 2.701f) + verticalLineTo(7.351f) + curveTo(4.59f, 7.841f, 4.99f, 8.251f, 5.49f, 8.251f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(7.35f, 10.17f) + horizontalLineTo(3.63f) + curveTo(3.13f, 10.17f, 2.73f, 10.57f, 2.73f, 11.07f) + curveTo(2.73f, 11.57f, 3.13f, 11.97f, 3.63f, 11.97f) + horizontalLineTo(4.59f) + verticalLineTo(21.3f) + curveTo(4.59f, 21.8f, 4.99f, 22.2f, 5.49f, 22.2f) + curveTo(5.99f, 22.2f, 6.39f, 21.8f, 6.39f, 21.3f) + verticalLineTo(11.97f) + horizontalLineTo(7.35f) + curveTo(7.85f, 11.97f, 8.25f, 11.57f, 8.25f, 11.07f) + curveTo(8.25f, 10.57f, 7.84f, 10.17f, 7.35f, 10.17f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(20.37f, 10.17f) + horizontalLineTo(16.65f) + curveTo(16.15f, 10.17f, 15.75f, 10.57f, 15.75f, 11.07f) + curveTo(15.75f, 11.57f, 16.15f, 11.97f, 16.65f, 11.97f) + horizontalLineTo(17.61f) + verticalLineTo(21.3f) + curveTo(17.61f, 21.8f, 18.01f, 22.2f, 18.51f, 22.2f) + curveTo(19.01f, 22.2f, 19.41f, 21.8f, 19.41f, 21.3f) + verticalLineTo(11.97f) + horizontalLineTo(20.37f) + curveTo(20.87f, 11.97f, 21.27f, 11.57f, 21.27f, 11.07f) + curveTo(21.27f, 10.57f, 20.87f, 10.17f, 20.37f, 10.17f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(13.86f, 12.031f) + horizontalLineTo(12.9f) + verticalLineTo(2.701f) + curveTo(12.9f, 2.201f, 12.5f, 1.801f, 12f, 1.801f) + curveTo(11.5f, 1.801f, 11.1f, 2.201f, 11.1f, 2.701f) + verticalLineTo(12.031f) + horizontalLineTo(10.14f) + curveTo(9.64f, 12.031f, 9.24f, 12.431f, 9.24f, 12.931f) + curveTo(9.24f, 13.431f, 9.64f, 13.831f, 10.14f, 13.831f) + horizontalLineTo(13.86f) + curveTo(14.36f, 13.831f, 14.76f, 13.431f, 14.76f, 12.931f) + curveTo(14.76f, 12.431f, 14.36f, 12.031f, 13.86f, 12.031f) + close() + } + } + }.build() + + return _IconsaxSetting!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxSetting: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting2.kt new file mode 100644 index 00000000..1580a167 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting2.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxSetting2: ImageVector + get() { + if (_IconsaxSetting2 != null) { + return _IconsaxSetting2!! + } + _IconsaxSetting2 = ImageVector.Builder( + name = "IconsaxSetting2", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(2f, 12.879f) + verticalLineTo(11.119f) + curveTo(2f, 10.079f, 2.85f, 9.219f, 3.9f, 9.219f) + curveTo(5.71f, 9.219f, 6.45f, 7.939f, 5.54f, 6.369f) + curveTo(5.02f, 5.469f, 5.33f, 4.299f, 6.24f, 3.779f) + lineTo(7.97f, 2.789f) + curveTo(8.76f, 2.319f, 9.78f, 2.599f, 10.25f, 3.389f) + lineTo(10.36f, 3.579f) + curveTo(11.26f, 5.149f, 12.74f, 5.149f, 13.65f, 3.579f) + lineTo(13.76f, 3.389f) + curveTo(14.23f, 2.599f, 15.25f, 2.319f, 16.04f, 2.789f) + lineTo(17.77f, 3.779f) + curveTo(18.68f, 4.299f, 18.99f, 5.469f, 18.47f, 6.369f) + curveTo(17.56f, 7.939f, 18.3f, 9.219f, 20.11f, 9.219f) + curveTo(21.15f, 9.219f, 22.01f, 10.069f, 22.01f, 11.119f) + verticalLineTo(12.879f) + curveTo(22.01f, 13.919f, 21.16f, 14.779f, 20.11f, 14.779f) + curveTo(18.3f, 14.779f, 17.56f, 16.059f, 18.47f, 17.629f) + curveTo(18.99f, 18.539f, 18.68f, 19.699f, 17.77f, 20.219f) + lineTo(16.04f, 21.209f) + curveTo(15.25f, 21.679f, 14.23f, 21.399f, 13.76f, 20.609f) + lineTo(13.65f, 20.419f) + curveTo(12.75f, 18.849f, 11.27f, 18.849f, 10.36f, 20.419f) + lineTo(10.25f, 20.609f) + curveTo(9.78f, 21.399f, 8.76f, 21.679f, 7.97f, 21.209f) + lineTo(6.24f, 20.219f) + curveTo(5.33f, 19.699f, 5.02f, 18.529f, 5.54f, 17.629f) + curveTo(6.45f, 16.059f, 5.71f, 14.779f, 3.9f, 14.779f) + curveTo(2.85f, 14.779f, 2f, 13.919f, 2f, 12.879f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 15.25f) + curveTo(13.795f, 15.25f, 15.25f, 13.795f, 15.25f, 12f) + curveTo(15.25f, 10.205f, 13.795f, 8.75f, 12f, 8.75f) + curveTo(10.205f, 8.75f, 8.75f, 10.205f, 8.75f, 12f) + curveTo(8.75f, 13.795f, 10.205f, 15.25f, 12f, 15.25f) + close() + } + } + }.build() + + return _IconsaxSetting2!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxSetting2: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSettingTwotone.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSettingTwotone.kt new file mode 100644 index 00000000..84043b40 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSettingTwotone.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxSettingTwotone: ImageVector + get() { + if (_IconsaxSettingTwotone != null) { + return _IconsaxSettingTwotone!! + } + _IconsaxSettingTwotone = ImageVector.Builder( + name = "IconsaxSettingTwotone", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fillAlpha = 0.34f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.34f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(12f, 15f) + curveTo(13.657f, 15f, 15f, 13.657f, 15f, 12f) + curveTo(15f, 10.343f, 13.657f, 9f, 12f, 9f) + curveTo(10.343f, 9f, 9f, 10.343f, 9f, 12f) + curveTo(9f, 13.657f, 10.343f, 15f, 12f, 15f) + close() + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(2f, 12.879f) + verticalLineTo(11.119f) + curveTo(2f, 10.079f, 2.85f, 9.219f, 3.9f, 9.219f) + curveTo(5.71f, 9.219f, 6.45f, 7.939f, 5.54f, 6.369f) + curveTo(5.02f, 5.469f, 5.33f, 4.299f, 6.24f, 3.779f) + lineTo(7.97f, 2.789f) + curveTo(8.76f, 2.319f, 9.78f, 2.599f, 10.25f, 3.389f) + lineTo(10.36f, 3.579f) + curveTo(11.26f, 5.149f, 12.74f, 5.149f, 13.65f, 3.579f) + lineTo(13.76f, 3.389f) + curveTo(14.23f, 2.599f, 15.25f, 2.319f, 16.04f, 2.789f) + lineTo(17.77f, 3.779f) + curveTo(18.68f, 4.299f, 18.99f, 5.469f, 18.47f, 6.369f) + curveTo(17.56f, 7.939f, 18.3f, 9.219f, 20.11f, 9.219f) + curveTo(21.15f, 9.219f, 22.01f, 10.069f, 22.01f, 11.119f) + verticalLineTo(12.879f) + curveTo(22.01f, 13.919f, 21.16f, 14.779f, 20.11f, 14.779f) + curveTo(18.3f, 14.779f, 17.56f, 16.059f, 18.47f, 17.629f) + curveTo(18.99f, 18.539f, 18.68f, 19.699f, 17.77f, 20.219f) + lineTo(16.04f, 21.209f) + curveTo(15.25f, 21.679f, 14.23f, 21.399f, 13.76f, 20.609f) + lineTo(13.65f, 20.419f) + curveTo(12.75f, 18.849f, 11.27f, 18.849f, 10.36f, 20.419f) + lineTo(10.25f, 20.609f) + curveTo(9.78f, 21.399f, 8.76f, 21.679f, 7.97f, 21.209f) + lineTo(6.24f, 20.219f) + curveTo(5.33f, 19.699f, 5.02f, 18.529f, 5.54f, 17.629f) + curveTo(6.45f, 16.059f, 5.71f, 14.779f, 3.9f, 14.779f) + curveTo(2.85f, 14.779f, 2f, 13.919f, 2f, 12.879f) + close() + } + } + }.build() + + return _IconsaxSettingTwotone!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxSettingTwotone: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShare.kt new file mode 100644 index 00000000..e7592b34 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShare.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxShare: ImageVector + get() { + if (_IconsaxShare != null) { + return _IconsaxShare!! + } + _IconsaxShare = ImageVector.Builder( + name = "IconsaxShare", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(20.36f, 12.73f) + curveTo(19.99f, 12.73f, 19.68f, 12.45f, 19.64f, 12.08f) + curveTo(19.4f, 9.88f, 18.22f, 7.9f, 16.4f, 6.64f) + curveTo(16.07f, 6.41f, 15.99f, 5.96f, 16.22f, 5.63f) + curveTo(16.45f, 5.3f, 16.9f, 5.22f, 17.23f, 5.45f) + curveTo(19.4f, 6.96f, 20.8f, 9.32f, 21.09f, 11.93f) + curveTo(21.13f, 12.33f, 20.84f, 12.69f, 20.44f, 12.73f) + curveTo(20.41f, 12.73f, 20.39f, 12.73f, 20.36f, 12.73f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(3.74f, 12.779f) + curveTo(3.72f, 12.779f, 3.69f, 12.779f, 3.67f, 12.779f) + curveTo(3.27f, 12.739f, 2.98f, 12.379f, 3.02f, 11.979f) + curveTo(3.29f, 9.369f, 4.67f, 7.009f, 6.82f, 5.489f) + curveTo(7.14f, 5.259f, 7.6f, 5.339f, 7.83f, 5.659f) + curveTo(8.06f, 5.989f, 7.98f, 6.439f, 7.66f, 6.669f) + curveTo(5.86f, 7.949f, 4.69f, 9.929f, 4.47f, 12.119f) + curveTo(4.43f, 12.499f, 4.11f, 12.779f, 3.74f, 12.779f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(15.99f, 21.1f) + curveTo(14.76f, 21.69f, 13.44f, 21.99f, 12.06f, 21.99f) + curveTo(10.62f, 21.99f, 9.25f, 21.67f, 7.97f, 21.02f) + curveTo(7.61f, 20.85f, 7.47f, 20.41f, 7.65f, 20.05f) + curveTo(7.82f, 19.69f, 8.26f, 19.55f, 8.62f, 19.72f) + curveTo(9.25f, 20.04f, 9.92f, 20.26f, 10.6f, 20.39f) + curveTo(11.52f, 20.57f, 12.46f, 20.58f, 13.38f, 20.42f) + curveTo(14.06f, 20.3f, 14.73f, 20.09f, 15.35f, 19.79f) + curveTo(15.72f, 19.62f, 16.16f, 19.76f, 16.32f, 20.13f) + curveTo(16.5f, 20.49f, 16.36f, 20.93f, 15.99f, 21.1f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12.05f, 2.01f) + curveTo(10.5f, 2.01f, 9.23f, 3.27f, 9.23f, 4.83f) + curveTo(9.23f, 6.39f, 10.49f, 7.65f, 12.05f, 7.65f) + curveTo(13.61f, 7.65f, 14.87f, 6.39f, 14.87f, 4.83f) + curveTo(14.87f, 3.27f, 13.61f, 2.01f, 12.05f, 2.01f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(5.05f, 13.869f) + curveTo(3.5f, 13.869f, 2.23f, 15.129f, 2.23f, 16.689f) + curveTo(2.23f, 18.249f, 3.49f, 19.509f, 5.05f, 19.509f) + curveTo(6.61f, 19.509f, 7.87f, 18.249f, 7.87f, 16.689f) + curveTo(7.87f, 15.129f, 6.6f, 13.869f, 5.05f, 13.869f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(18.95f, 13.869f) + curveTo(17.4f, 13.869f, 16.13f, 15.129f, 16.13f, 16.689f) + curveTo(16.13f, 18.249f, 17.39f, 19.509f, 18.95f, 19.509f) + curveTo(20.51f, 19.509f, 21.77f, 18.249f, 21.77f, 16.689f) + curveTo(21.77f, 15.129f, 20.51f, 13.869f, 18.95f, 13.869f) + close() + } + } + }.build() + + return _IconsaxShare!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxShare: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShuffle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShuffle.kt new file mode 100644 index 00000000..497be379 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShuffle.kt @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxShuffle: ImageVector + get() { + if (_IconsaxShuffle != null) { + return _IconsaxShuffle!! + } + _IconsaxShuffle = ImageVector.Builder( + name = "IconsaxShuffle", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(21.75f, 17.98f) + curveTo(21.75f, 17.96f, 21.74f, 17.94f, 21.74f, 17.92f) + curveTo(21.73f, 17.84f, 21.72f, 17.76f, 21.69f, 17.69f) + curveTo(21.65f, 17.6f, 21.6f, 17.53f, 21.54f, 17.46f) + curveTo(21.54f, 17.46f, 21.54f, 17.45f, 21.53f, 17.45f) + curveTo(21.46f, 17.38f, 21.38f, 17.33f, 21.29f, 17.29f) + curveTo(21.2f, 17.25f, 21.1f, 17.23f, 21f, 17.23f) + lineTo(16.33f, 17.25f) + curveTo(16.33f, 17.25f, 16.33f, 17.25f, 16.32f, 17.25f) + curveTo(15.72f, 17.25f, 15.14f, 16.97f, 14.78f, 16.49f) + lineTo(13.56f, 14.92f) + curveTo(13.31f, 14.59f, 12.84f, 14.53f, 12.51f, 14.79f) + curveTo(12.18f, 15.05f, 12.12f, 15.51f, 12.38f, 15.84f) + lineTo(13.6f, 17.41f) + curveTo(14.25f, 18.25f, 15.27f, 18.75f, 16.33f, 18.75f) + horizontalLineTo(16.34f) + lineTo(19.19f, 18.74f) + lineTo(18.48f, 19.45f) + curveTo(18.19f, 19.74f, 18.19f, 20.22f, 18.48f, 20.51f) + curveTo(18.63f, 20.66f, 18.82f, 20.73f, 19.01f, 20.73f) + curveTo(19.2f, 20.73f, 19.39f, 20.66f, 19.54f, 20.51f) + lineTo(21.54f, 18.51f) + curveTo(21.61f, 18.44f, 21.66f, 18.36f, 21.7f, 18.27f) + curveTo(21.73f, 18.17f, 21.75f, 18.07f, 21.75f, 17.98f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(8.42f, 6.69f) + curveTo(7.77f, 5.79f, 6.73f, 5.26f, 5.62f, 5.26f) + curveTo(5.61f, 5.26f, 5.61f, 5.26f, 5.6f, 5.26f) + lineTo(2.99f, 5.27f) + curveTo(2.58f, 5.27f, 2.24f, 5.61f, 2.24f, 6.02f) + curveTo(2.24f, 6.43f, 2.58f, 6.77f, 2.99f, 6.77f) + lineTo(5.6f, 6.76f) + horizontalLineTo(5.61f) + curveTo(6.24f, 6.76f, 6.83f, 7.06f, 7.19f, 7.57f) + lineTo(8.27f, 9.07f) + curveTo(8.42f, 9.27f, 8.65f, 9.38f, 8.88f, 9.38f) + curveTo(9.03f, 9.38f, 9.19f, 9.33f, 9.32f, 9.24f) + curveTo(9.66f, 9f, 9.73f, 8.53f, 9.49f, 8.19f) + lineTo(8.42f, 6.69f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(21.74f, 6.08f) + curveTo(21.74f, 6.06f, 21.75f, 6.04f, 21.75f, 6.03f) + curveTo(21.75f, 5.93f, 21.73f, 5.83f, 21.69f, 5.74f) + curveTo(21.65f, 5.65f, 21.6f, 5.57f, 21.53f, 5.5f) + lineTo(19.53f, 3.5f) + curveTo(19.24f, 3.21f, 18.76f, 3.21f, 18.47f, 3.5f) + curveTo(18.18f, 3.79f, 18.18f, 4.27f, 18.47f, 4.56f) + lineTo(19.18f, 5.27f) + lineTo(16.45f, 5.26f) + curveTo(16.44f, 5.26f, 16.44f, 5.26f, 16.43f, 5.26f) + curveTo(15.28f, 5.26f, 14.2f, 5.83f, 13.56f, 6.8f) + lineTo(7.17f, 16.38f) + curveTo(6.81f, 16.92f, 6.2f, 17.25f, 5.55f, 17.25f) + horizontalLineTo(5.54f) + lineTo(2.99f, 17.24f) + curveTo(2.58f, 17.24f, 2.24f, 17.57f, 2.24f, 17.99f) + curveTo(2.24f, 18.4f, 2.57f, 18.74f, 2.99f, 18.74f) + lineTo(5.54f, 18.75f) + curveTo(5.55f, 18.75f, 5.55f, 18.75f, 5.56f, 18.75f) + curveTo(6.72f, 18.75f, 7.79f, 18.18f, 8.43f, 17.21f) + lineTo(14.82f, 7.63f) + curveTo(15.18f, 7.09f, 15.79f, 6.76f, 16.44f, 6.76f) + horizontalLineTo(16.45f) + lineTo(21f, 6.78f) + curveTo(21.1f, 6.78f, 21.19f, 6.76f, 21.29f, 6.72f) + curveTo(21.38f, 6.68f, 21.46f, 6.63f, 21.53f, 6.56f) + curveTo(21.53f, 6.56f, 21.53f, 6.55f, 21.54f, 6.55f) + curveTo(21.6f, 6.48f, 21.66f, 6.41f, 21.69f, 6.32f) + curveTo(21.72f, 6.24f, 21.73f, 6.16f, 21.74f, 6.08f) + close() + } + } + }.build() + + return _IconsaxShuffle!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxShuffle: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt new file mode 100644 index 00000000..af403ddf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxSidebarLeftBroken: ImageVector + get() { + if (_IconsaxSidebarLeftBroken != null) { + return _IconsaxSidebarLeftBroken!! + } + _IconsaxSidebarLeftBroken = ImageVector.Builder( + name = "IconsaxSidebarLeftBroken", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(7.97f, 2f) + verticalLineTo(22f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(14.97f, 9.439f) + lineTo(12.41f, 12f) + lineTo(14.97f, 14.559f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(2f, 13f) + verticalLineTo(15f) + curveTo(2f, 20f, 4f, 22f, 9f, 22f) + horizontalLineTo(15f) + curveTo(20f, 22f, 22f, 20f, 22f, 15f) + verticalLineTo(9f) + curveTo(22f, 4f, 20f, 2f, 15f, 2f) + horizontalLineTo(9f) + curveTo(4f, 2f, 2f, 4f, 2f, 9f) + } + } + }.build() + + return _IconsaxSidebarLeftBroken!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxSidebarLeftBroken: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt new file mode 100644 index 00000000..ecc9486d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxSidebarRightBroken: ImageVector + get() { + if (_IconsaxSidebarRightBroken != null) { + return _IconsaxSidebarRightBroken!! + } + _IconsaxSidebarRightBroken = ImageVector.Builder( + name = "IconsaxSidebarRightBroken", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(1.97f, 12.98f) + verticalLineTo(15f) + curveTo(1.97f, 20f, 3.97f, 22f, 8.97f, 22f) + horizontalLineTo(14.97f) + curveTo(19.97f, 22f, 21.97f, 20f, 21.97f, 15f) + verticalLineTo(9f) + curveTo(21.97f, 4f, 19.97f, 2f, 14.97f, 2f) + horizontalLineTo(8.97f) + curveTo(3.97f, 2f, 1.97f, 4f, 1.97f, 9f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(14.97f, 2f) + verticalLineTo(22f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(7.97f, 9.439f) + lineTo(10.53f, 12f) + lineTo(7.97f, 14.559f) + } + } + }.build() + + return _IconsaxSidebarRightBroken!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxSidebarRightBroken: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSort.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSort.kt new file mode 100644 index 00000000..c2c01626 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSort.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxSort: ImageVector + get() { + if (_IconsaxSort != null) { + return _IconsaxSort!! + } + _IconsaxSort = ImageVector.Builder( + name = "IconsaxSort", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16.19f, 2f) + horizontalLineTo(7.81f) + curveTo(4.17f, 2f, 2f, 4.17f, 2f, 7.81f) + verticalLineTo(16.18f) + curveTo(2f, 19.83f, 4.17f, 22f, 7.81f, 22f) + horizontalLineTo(16.18f) + curveTo(19.82f, 22f, 21.99f, 19.83f, 21.99f, 16.19f) + verticalLineTo(7.81f) + curveTo(22f, 4.17f, 19.83f, 2f, 16.19f, 2f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(18f, 8.5f) + horizontalLineTo(6f) + curveTo(5.59f, 8.5f, 5.25f, 8.16f, 5.25f, 7.75f) + curveTo(5.25f, 7.34f, 5.59f, 7f, 6f, 7f) + horizontalLineTo(18f) + curveTo(18.41f, 7f, 18.75f, 7.34f, 18.75f, 7.75f) + curveTo(18.75f, 8.16f, 18.41f, 8.5f, 18f, 8.5f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(16f, 12.75f) + horizontalLineTo(8f) + curveTo(7.59f, 12.75f, 7.25f, 12.41f, 7.25f, 12f) + curveTo(7.25f, 11.59f, 7.59f, 11.25f, 8f, 11.25f) + horizontalLineTo(16f) + curveTo(16.41f, 11.25f, 16.75f, 11.59f, 16.75f, 12f) + curveTo(16.75f, 12.41f, 16.41f, 12.75f, 16f, 12.75f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(13.33f, 17f) + horizontalLineTo(10.66f) + curveTo(10.25f, 17f, 9.91f, 16.66f, 9.91f, 16.25f) + curveTo(9.91f, 15.84f, 10.25f, 15.5f, 10.66f, 15.5f) + horizontalLineTo(13.33f) + curveTo(13.74f, 15.5f, 14.08f, 15.84f, 14.08f, 16.25f) + curveTo(14.08f, 16.66f, 13.75f, 17f, 13.33f, 17f) + close() + } + } + }.build() + + return _IconsaxSort!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxSort: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSound.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSound.kt new file mode 100644 index 00000000..c38f5c07 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSound.kt @@ -0,0 +1,89 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxSound: ImageVector + get() { + if (_IconsaxSound != null) { + return _IconsaxSound!! + } + _IconsaxSound = ImageVector.Builder( + name = "IconsaxSound", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(3f, 16.5f) + curveTo(2.59f, 16.5f, 2.25f, 16.16f, 2.25f, 15.75f) + verticalLineTo(8.25f) + curveTo(2.25f, 7.84f, 2.59f, 7.5f, 3f, 7.5f) + curveTo(3.41f, 7.5f, 3.75f, 7.84f, 3.75f, 8.25f) + verticalLineTo(15.75f) + curveTo(3.75f, 16.16f, 3.41f, 16.5f, 3f, 16.5f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(7.5f, 19f) + curveTo(7.09f, 19f, 6.75f, 18.66f, 6.75f, 18.25f) + verticalLineTo(5.75f) + curveTo(6.75f, 5.34f, 7.09f, 5f, 7.5f, 5f) + curveTo(7.91f, 5f, 8.25f, 5.34f, 8.25f, 5.75f) + verticalLineTo(18.25f) + curveTo(8.25f, 18.66f, 7.91f, 19f, 7.5f, 19f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 21.5f) + curveTo(11.59f, 21.5f, 11.25f, 21.16f, 11.25f, 20.75f) + verticalLineTo(3.25f) + curveTo(11.25f, 2.84f, 11.59f, 2.5f, 12f, 2.5f) + curveTo(12.41f, 2.5f, 12.75f, 2.84f, 12.75f, 3.25f) + verticalLineTo(20.75f) + curveTo(12.75f, 21.16f, 12.41f, 21.5f, 12f, 21.5f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(16.5f, 19f) + curveTo(16.09f, 19f, 15.75f, 18.66f, 15.75f, 18.25f) + verticalLineTo(5.75f) + curveTo(15.75f, 5.34f, 16.09f, 5f, 16.5f, 5f) + curveTo(16.91f, 5f, 17.25f, 5.34f, 17.25f, 5.75f) + verticalLineTo(18.25f) + curveTo(17.25f, 18.66f, 16.91f, 19f, 16.5f, 19f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(21f, 16.5f) + curveTo(20.59f, 16.5f, 20.25f, 16.16f, 20.25f, 15.75f) + verticalLineTo(8.25f) + curveTo(20.25f, 7.84f, 20.59f, 7.5f, 21f, 7.5f) + curveTo(21.41f, 7.5f, 21.75f, 7.84f, 21.75f, 8.25f) + verticalLineTo(15.75f) + curveTo(21.75f, 16.16f, 21.41f, 16.5f, 21f, 16.5f) + close() + } + } + }.build() + + return _IconsaxSound!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxSound: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSoundTwotone.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSoundTwotone.kt new file mode 100644 index 00000000..e7961ae7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSoundTwotone.kt @@ -0,0 +1,97 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxSoundTwotone: ImageVector + get() { + if (_IconsaxSoundTwotone != null) { + return _IconsaxSoundTwotone!! + } + _IconsaxSoundTwotone = ImageVector.Builder( + name = "IconsaxSoundTwotone", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(3f, 16.5f) + curveTo(2.59f, 16.5f, 2.25f, 16.16f, 2.25f, 15.75f) + verticalLineTo(8.25f) + curveTo(2.25f, 7.84f, 2.59f, 7.5f, 3f, 7.5f) + curveTo(3.41f, 7.5f, 3.75f, 7.84f, 3.75f, 8.25f) + verticalLineTo(15.75f) + curveTo(3.75f, 16.16f, 3.41f, 16.5f, 3f, 16.5f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(7.5f, 19f) + curveTo(7.09f, 19f, 6.75f, 18.66f, 6.75f, 18.25f) + verticalLineTo(5.75f) + curveTo(6.75f, 5.34f, 7.09f, 5f, 7.5f, 5f) + curveTo(7.91f, 5f, 8.25f, 5.34f, 8.25f, 5.75f) + verticalLineTo(18.25f) + curveTo(8.25f, 18.66f, 7.91f, 19f, 7.5f, 19f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 21.5f) + curveTo(11.59f, 21.5f, 11.25f, 21.16f, 11.25f, 20.75f) + verticalLineTo(3.25f) + curveTo(11.25f, 2.84f, 11.59f, 2.5f, 12f, 2.5f) + curveTo(12.41f, 2.5f, 12.75f, 2.84f, 12.75f, 3.25f) + verticalLineTo(20.75f) + curveTo(12.75f, 21.16f, 12.41f, 21.5f, 12f, 21.5f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16.5f, 19f) + curveTo(16.09f, 19f, 15.75f, 18.66f, 15.75f, 18.25f) + verticalLineTo(5.75f) + curveTo(15.75f, 5.34f, 16.09f, 5f, 16.5f, 5f) + curveTo(16.91f, 5f, 17.25f, 5.34f, 17.25f, 5.75f) + verticalLineTo(18.25f) + curveTo(17.25f, 18.66f, 16.91f, 19f, 16.5f, 19f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(21f, 16.5f) + curveTo(20.59f, 16.5f, 20.25f, 16.16f, 20.25f, 15.75f) + verticalLineTo(8.25f) + curveTo(20.25f, 7.84f, 20.59f, 7.5f, 21f, 7.5f) + curveTo(21.41f, 7.5f, 21.75f, 7.84f, 21.75f, 8.25f) + verticalLineTo(15.75f) + curveTo(21.75f, 16.16f, 21.41f, 16.5f, 21f, 16.5f) + close() + } + } + }.build() + + return _IconsaxSoundTwotone!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxSoundTwotone: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSquare.kt new file mode 100644 index 00000000..c60404fb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSquare.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxSquare: ImageVector + get() { + if (_IconsaxSquare != null) { + return _IconsaxSquare!! + } + _IconsaxSquare = ImageVector.Builder( + name = "IconsaxSquare", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color.White)) { + moveTo(15f, 22.75f) + horizontalLineTo(9f) + curveTo(3.57f, 22.75f, 1.25f, 20.43f, 1.25f, 15f) + verticalLineTo(9f) + curveTo(1.25f, 3.57f, 3.57f, 1.25f, 9f, 1.25f) + horizontalLineTo(15f) + curveTo(20.43f, 1.25f, 22.75f, 3.57f, 22.75f, 9f) + verticalLineTo(15f) + curveTo(22.75f, 20.43f, 20.43f, 22.75f, 15f, 22.75f) + close() + moveTo(9f, 2.75f) + curveTo(4.39f, 2.75f, 2.75f, 4.39f, 2.75f, 9f) + verticalLineTo(15f) + curveTo(2.75f, 19.61f, 4.39f, 21.25f, 9f, 21.25f) + horizontalLineTo(15f) + curveTo(19.61f, 21.25f, 21.25f, 19.61f, 21.25f, 15f) + verticalLineTo(9f) + curveTo(21.25f, 4.39f, 19.61f, 2.75f, 15f, 2.75f) + horizontalLineTo(9f) + close() + } + }.build() + + return _IconsaxSquare!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxSquare: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxStopCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxStopCircle.kt new file mode 100644 index 00000000..4c6bc6f9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxStopCircle.kt @@ -0,0 +1,63 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxStopCircle: ImageVector + get() { + if (_IconsaxStopCircle != null) { + return _IconsaxStopCircle!! + } + _IconsaxStopCircle = ImageVector.Builder( + name = "IconsaxStopCircle", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(11.97f, 22f) + curveTo(17.493f, 22f, 21.97f, 17.523f, 21.97f, 12f) + curveTo(21.97f, 6.477f, 17.493f, 2f, 11.97f, 2f) + curveTo(6.447f, 2f, 1.97f, 6.477f, 1.97f, 12f) + curveTo(1.97f, 17.523f, 6.447f, 22f, 11.97f, 22f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(10.77f, 16.229f) + horizontalLineTo(13.23f) + curveTo(14.89f, 16.229f, 16.23f, 14.889f, 16.23f, 13.229f) + verticalLineTo(10.769f) + curveTo(16.23f, 9.11f, 14.89f, 7.77f, 13.23f, 7.77f) + horizontalLineTo(10.77f) + curveTo(9.11f, 7.77f, 7.77f, 9.11f, 7.77f, 10.769f) + verticalLineTo(13.229f) + curveTo(7.77f, 14.889f, 9.11f, 16.229f, 10.77f, 16.229f) + close() + } + } + }.build() + + return _IconsaxStopCircle!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxStopCircle: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTag.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTag.kt new file mode 100644 index 00000000..0764a5c2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTag.kt @@ -0,0 +1,65 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxTag: ImageVector + get() { + if (_IconsaxTag != null) { + return _IconsaxTag!! + } + _IconsaxTag = ImageVector.Builder( + name = "IconsaxTag", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(4.17f, 15.3f) + lineTo(8.7f, 19.83f) + curveTo(10.56f, 21.69f, 13.58f, 21.69f, 15.45f, 19.83f) + lineTo(19.84f, 15.44f) + curveTo(21.7f, 13.58f, 21.7f, 10.56f, 19.84f, 8.69f) + lineTo(15.3f, 4.17f) + curveTo(14.35f, 3.22f, 13.04f, 2.71f, 11.7f, 2.78f) + lineTo(6.7f, 3.02f) + curveTo(4.7f, 3.11f, 3.11f, 4.7f, 3.01f, 6.69f) + lineTo(2.77f, 11.69f) + curveTo(2.71f, 13.04f, 3.22f, 14.35f, 4.17f, 15.3f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(9.5f, 12.38f) + curveTo(11.091f, 12.38f, 12.38f, 11.091f, 12.38f, 9.5f) + curveTo(12.38f, 7.91f, 11.091f, 6.62f, 9.5f, 6.62f) + curveTo(7.91f, 6.62f, 6.62f, 7.91f, 6.62f, 9.5f) + curveTo(6.62f, 11.091f, 7.91f, 12.38f, 9.5f, 12.38f) + close() + } + } + }.build() + + return _IconsaxTag!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxTag: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTextalignLeft.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTextalignLeft.kt new file mode 100644 index 00000000..4608269f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTextalignLeft.kt @@ -0,0 +1,87 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxTextalignLeft: ImageVector + get() { + if (_IconsaxTextalignLeft != null) { + return _IconsaxTextalignLeft!! + } + _IconsaxTextalignLeft = ImageVector.Builder( + name = "IconsaxTextalignLeft", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(21f, 5.25f) + horizontalLineTo(3f) + curveTo(2.59f, 5.25f, 2.25f, 4.91f, 2.25f, 4.5f) + curveTo(2.25f, 4.09f, 2.59f, 3.75f, 3f, 3.75f) + horizontalLineTo(21f) + curveTo(21.41f, 3.75f, 21.75f, 4.09f, 21.75f, 4.5f) + curveTo(21.75f, 4.91f, 21.41f, 5.25f, 21f, 5.25f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(12.47f, 10.25f) + horizontalLineTo(3f) + curveTo(2.59f, 10.25f, 2.25f, 9.91f, 2.25f, 9.5f) + curveTo(2.25f, 9.09f, 2.59f, 8.75f, 3f, 8.75f) + horizontalLineTo(12.47f) + curveTo(12.88f, 8.75f, 13.22f, 9.09f, 13.22f, 9.5f) + curveTo(13.22f, 9.91f, 12.89f, 10.25f, 12.47f, 10.25f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(21f, 15.25f) + horizontalLineTo(3f) + curveTo(2.59f, 15.25f, 2.25f, 14.91f, 2.25f, 14.5f) + curveTo(2.25f, 14.09f, 2.59f, 13.75f, 3f, 13.75f) + horizontalLineTo(21f) + curveTo(21.41f, 13.75f, 21.75f, 14.09f, 21.75f, 14.5f) + curveTo(21.75f, 14.91f, 21.41f, 15.25f, 21f, 15.25f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(12.47f, 20.25f) + horizontalLineTo(3f) + curveTo(2.59f, 20.25f, 2.25f, 19.91f, 2.25f, 19.5f) + curveTo(2.25f, 19.09f, 2.59f, 18.75f, 3f, 18.75f) + horizontalLineTo(12.47f) + curveTo(12.88f, 18.75f, 13.22f, 19.09f, 13.22f, 19.5f) + curveTo(13.22f, 19.91f, 12.89f, 20.25f, 12.47f, 20.25f) + close() + } + } + }.build() + + return _IconsaxTextalignLeft!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxTextalignLeft: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTrash.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTrash.kt new file mode 100644 index 00000000..3b6247ff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTrash.kt @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxTrash: ImageVector + get() { + if (_IconsaxTrash != null) { + return _IconsaxTrash!! + } + _IconsaxTrash = ImageVector.Builder( + name = "IconsaxTrash", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(21.07f, 5.23f) + curveTo(19.46f, 5.07f, 17.85f, 4.95f, 16.23f, 4.86f) + verticalLineTo(4.85f) + lineTo(16.01f, 3.55f) + curveTo(15.86f, 2.63f, 15.64f, 1.25f, 13.3f, 1.25f) + horizontalLineTo(10.68f) + curveTo(8.35f, 1.25f, 8.13f, 2.57f, 7.97f, 3.54f) + lineTo(7.76f, 4.82f) + curveTo(6.83f, 4.88f, 5.9f, 4.94f, 4.97f, 5.03f) + lineTo(2.93f, 5.23f) + curveTo(2.51f, 5.27f, 2.21f, 5.64f, 2.25f, 6.05f) + curveTo(2.29f, 6.46f, 2.65f, 6.76f, 3.07f, 6.72f) + lineTo(5.11f, 6.52f) + curveTo(10.35f, 6f, 15.63f, 6.2f, 20.93f, 6.73f) + curveTo(20.96f, 6.73f, 20.98f, 6.73f, 21.01f, 6.73f) + curveTo(21.39f, 6.73f, 21.72f, 6.44f, 21.76f, 6.05f) + curveTo(21.79f, 5.64f, 21.49f, 5.27f, 21.07f, 5.23f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.3991f, + strokeAlpha = 0.3991f + ) { + moveTo(19.23f, 8.14f) + curveTo(18.99f, 7.89f, 18.66f, 7.75f, 18.32f, 7.75f) + horizontalLineTo(5.68f) + curveTo(5.34f, 7.75f, 5f, 7.89f, 4.77f, 8.14f) + curveTo(4.54f, 8.39f, 4.41f, 8.73f, 4.43f, 9.08f) + lineTo(5.05f, 19.34f) + curveTo(5.16f, 20.86f, 5.3f, 22.76f, 8.79f, 22.76f) + horizontalLineTo(15.21f) + curveTo(18.7f, 22.76f, 18.84f, 20.87f, 18.95f, 19.34f) + lineTo(19.57f, 9.09f) + curveTo(19.59f, 8.73f, 19.46f, 8.39f, 19.23f, 8.14f) + close() + } + path( + fill = SolidColor(Color.White), + pathFillType = PathFillType.EvenOdd + ) { + moveTo(9.58f, 17f) + curveTo(9.58f, 16.586f, 9.916f, 16.25f, 10.33f, 16.25f) + horizontalLineTo(13.66f) + curveTo(14.074f, 16.25f, 14.41f, 16.586f, 14.41f, 17f) + curveTo(14.41f, 17.414f, 14.074f, 17.75f, 13.66f, 17.75f) + horizontalLineTo(10.33f) + curveTo(9.916f, 17.75f, 9.58f, 17.414f, 9.58f, 17f) + close() + } + path( + fill = SolidColor(Color.White), + pathFillType = PathFillType.EvenOdd + ) { + moveTo(8.75f, 13f) + curveTo(8.75f, 12.586f, 9.086f, 12.25f, 9.5f, 12.25f) + horizontalLineTo(14.5f) + curveTo(14.914f, 12.25f, 15.25f, 12.586f, 15.25f, 13f) + curveTo(15.25f, 13.414f, 14.914f, 13.75f, 14.5f, 13.75f) + horizontalLineTo(9.5f) + curveTo(9.086f, 13.75f, 8.75f, 13.414f, 8.75f, 13f) + close() + } + } + }.build() + + return _IconsaxTrash!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxTrash: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxUserRemove.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxUserRemove.kt new file mode 100644 index 00000000..0880483b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxUserRemove.kt @@ -0,0 +1,98 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxUserRemove: ImageVector + get() { + if (_IconsaxUserRemove != null) { + return _IconsaxUserRemove!! + } + _IconsaxUserRemove = ImageVector.Builder( + name = "IconsaxUserRemove", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(21.09f, 21.5f) + curveTo(21.09f, 21.78f, 20.87f, 22f, 20.59f, 22f) + horizontalLineTo(3.41f) + curveTo(3.13f, 22f, 2.91f, 21.78f, 2.91f, 21.5f) + curveTo(2.91f, 17.36f, 6.99f, 14f, 12f, 14f) + curveTo(13.03f, 14f, 14.03f, 14.14f, 14.95f, 14.41f) + curveTo(14.36f, 15.11f, 14f, 16.02f, 14f, 17f) + curveTo(14f, 17.75f, 14.21f, 18.46f, 14.58f, 19.06f) + curveTo(14.78f, 19.4f, 15.04f, 19.71f, 15.34f, 19.97f) + curveTo(16.04f, 20.61f, 16.97f, 21f, 18f, 21f) + curveTo(19.12f, 21f, 20.13f, 20.54f, 20.85f, 19.8f) + curveTo(21.01f, 20.34f, 21.09f, 20.91f, 21.09f, 21.5f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(21.88f, 16.04f) + curveTo(21.78f, 15.65f, 21.62f, 15.26f, 21.4f, 14.91f) + curveTo(21.25f, 14.65f, 21.05f, 14.4f, 20.83f, 14.17f) + curveTo(20.11f, 13.45f, 19.17f, 13.06f, 18.21f, 13.01f) + curveTo(17.12f, 12.94f, 16.01f, 13.34f, 15.17f, 14.17f) + curveTo(14.38f, 14.96f, 13.98f, 16.01f, 14f, 17.06f) + curveTo(14.01f, 18.06f, 14.41f, 19.06f, 15.17f, 19.83f) + curveTo(15.7f, 20.36f, 16.35f, 20.71f, 17.04f, 20.87f) + curveTo(17.42f, 20.97f, 17.82f, 21.01f, 18.22f, 20.98f) + curveTo(19.17f, 20.94f, 20.1f, 20.56f, 20.83f, 19.83f) + curveTo(21.86f, 18.8f, 22.21f, 17.35f, 21.88f, 16.04f) + close() + moveTo(19.6f, 18.6f) + curveTo(19.31f, 18.89f, 18.83f, 18.89f, 18.54f, 18.6f) + lineTo(17.99f, 18.05f) + lineTo(17.46f, 18.58f) + curveTo(17.17f, 18.87f, 16.69f, 18.87f, 16.4f, 18.58f) + curveTo(16.11f, 18.28f, 16.11f, 17.81f, 16.4f, 17.52f) + lineTo(16.93f, 16.99f) + lineTo(16.42f, 16.49f) + curveTo(16.13f, 16.19f, 16.13f, 15.72f, 16.42f, 15.42f) + curveTo(16.72f, 15.13f, 17.19f, 15.13f, 17.49f, 15.42f) + lineTo(17.99f, 15.93f) + lineTo(18.52f, 15.4f) + curveTo(18.81f, 15.11f, 19.28f, 15.11f, 19.58f, 15.4f) + curveTo(19.87f, 15.69f, 19.87f, 16.17f, 19.58f, 16.46f) + lineTo(19.05f, 16.99f) + lineTo(19.6f, 17.54f) + curveTo(19.89f, 17.83f, 19.89f, 18.31f, 19.6f, 18.6f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 12f) + curveTo(14.761f, 12f, 17f, 9.761f, 17f, 7f) + curveTo(17f, 4.239f, 14.761f, 2f, 12f, 2f) + curveTo(9.239f, 2f, 7f, 4.239f, 7f, 7f) + curveTo(7f, 9.761f, 9.239f, 12f, 12f, 12f) + close() + } + } + }.build() + + return _IconsaxUserRemove!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxUserRemove: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVerticalSetting.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVerticalSetting.kt new file mode 100644 index 00000000..1cfdece4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVerticalSetting.kt @@ -0,0 +1,130 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxVerticalSetting: ImageVector + get() { + if (_IconsaxVerticalSetting != null) { + return _IconsaxVerticalSetting!! + } + _IconsaxVerticalSetting = ImageVector.Builder( + name = "IconsaxVerticalSetting", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(18.51f, 1.801f) + curveTo(18.01f, 1.801f, 17.61f, 2.201f, 17.61f, 2.701f) + verticalLineTo(7.351f) + curveTo(17.61f, 7.851f, 18.01f, 8.251f, 18.51f, 8.251f) + curveTo(19.01f, 8.251f, 19.41f, 7.851f, 19.41f, 7.351f) + verticalLineTo(2.701f) + curveTo(19.41f, 2.201f, 19.01f, 1.801f, 18.51f, 1.801f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 15.75f) + curveTo(11.5f, 15.75f, 11.1f, 16.15f, 11.1f, 16.65f) + verticalLineTo(21.3f) + curveTo(11.1f, 21.8f, 11.5f, 22.2f, 12f, 22.2f) + curveTo(12.5f, 22.2f, 12.9f, 21.8f, 12.9f, 21.3f) + verticalLineTo(16.65f) + curveTo(12.9f, 16.16f, 12.5f, 15.75f, 12f, 15.75f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(5.49f, 1.801f) + curveTo(4.99f, 1.801f, 4.59f, 2.201f, 4.59f, 2.701f) + verticalLineTo(7.351f) + curveTo(4.59f, 7.851f, 4.99f, 8.251f, 5.49f, 8.251f) + curveTo(5.99f, 8.251f, 6.39f, 7.851f, 6.39f, 7.351f) + verticalLineTo(2.701f) + curveTo(6.39f, 2.201f, 5.98f, 1.801f, 5.49f, 1.801f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(7.35f, 10.17f) + horizontalLineTo(3.63f) + curveTo(3.13f, 10.17f, 2.73f, 10.57f, 2.73f, 11.07f) + curveTo(2.73f, 11.57f, 3.13f, 11.97f, 3.63f, 11.97f) + horizontalLineTo(4.59f) + verticalLineTo(21.3f) + curveTo(4.59f, 21.8f, 4.99f, 22.2f, 5.49f, 22.2f) + curveTo(5.99f, 22.2f, 6.39f, 21.8f, 6.39f, 21.3f) + verticalLineTo(11.97f) + horizontalLineTo(7.35f) + curveTo(7.85f, 11.97f, 8.25f, 11.57f, 8.25f, 11.07f) + curveTo(8.25f, 10.57f, 7.84f, 10.17f, 7.35f, 10.17f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(20.37f, 10.17f) + horizontalLineTo(16.65f) + curveTo(16.15f, 10.17f, 15.75f, 10.57f, 15.75f, 11.07f) + curveTo(15.75f, 11.57f, 16.15f, 11.97f, 16.65f, 11.97f) + horizontalLineTo(17.61f) + verticalLineTo(21.3f) + curveTo(17.61f, 21.8f, 18.01f, 22.2f, 18.51f, 22.2f) + curveTo(19.01f, 22.2f, 19.41f, 21.8f, 19.41f, 21.3f) + verticalLineTo(11.97f) + horizontalLineTo(20.37f) + curveTo(20.87f, 11.97f, 21.27f, 11.57f, 21.27f, 11.07f) + curveTo(21.27f, 10.57f, 20.87f, 10.17f, 20.37f, 10.17f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(13.86f, 12.031f) + horizontalLineTo(12.9f) + verticalLineTo(2.701f) + curveTo(12.9f, 2.201f, 12.5f, 1.801f, 12f, 1.801f) + curveTo(11.5f, 1.801f, 11.1f, 2.201f, 11.1f, 2.701f) + verticalLineTo(12.031f) + horizontalLineTo(10.14f) + curveTo(9.64f, 12.031f, 9.24f, 12.431f, 9.24f, 12.931f) + curveTo(9.24f, 13.431f, 9.64f, 13.831f, 10.14f, 13.831f) + horizontalLineTo(13.86f) + curveTo(14.36f, 13.831f, 14.76f, 13.431f, 14.76f, 12.931f) + curveTo(14.76f, 12.431f, 14.36f, 12.031f, 13.86f, 12.031f) + close() + } + } + }.build() + + return _IconsaxVerticalSetting!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxVerticalSetting: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeCross.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeCross.kt new file mode 100644 index 00000000..55c050ce --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeCross.kt @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxVolumeCross: ImageVector + get() { + if (_IconsaxVolumeCross != null) { + return _IconsaxVolumeCross!! + } + _IconsaxVolumeCross = ImageVector.Builder( + name = "IconsaxVolumeCross", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(22.53f, 13.42f) + lineTo(21.08f, 11.97f) + lineTo(22.48f, 10.57f) + curveTo(22.77f, 10.28f, 22.77f, 9.8f, 22.48f, 9.51f) + curveTo(22.19f, 9.22f, 21.71f, 9.22f, 21.42f, 9.51f) + lineTo(20.02f, 10.91f) + lineTo(18.57f, 9.46f) + curveTo(18.28f, 9.17f, 17.8f, 9.17f, 17.51f, 9.46f) + curveTo(17.22f, 9.75f, 17.22f, 10.23f, 17.51f, 10.52f) + lineTo(18.96f, 11.97f) + lineTo(17.47f, 13.46f) + curveTo(17.18f, 13.75f, 17.18f, 14.23f, 17.47f, 14.52f) + curveTo(17.62f, 14.67f, 17.81f, 14.74f, 18f, 14.74f) + curveTo(18.19f, 14.74f, 18.38f, 14.67f, 18.53f, 14.52f) + lineTo(20.02f, 13.03f) + lineTo(21.47f, 14.48f) + curveTo(21.62f, 14.63f, 21.81f, 14.7f, 22f, 14.7f) + curveTo(22.19f, 14.7f, 22.38f, 14.63f, 22.53f, 14.48f) + curveTo(22.82f, 14.19f, 22.82f, 13.72f, 22.53f, 13.42f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(14.02f, 3.78f) + curveTo(12.9f, 3.16f, 11.47f, 3.32f, 10.01f, 4.23f) + lineTo(7.09f, 6.06f) + curveTo(6.89f, 6.18f, 6.66f, 6.25f, 6.43f, 6.25f) + horizontalLineTo(5.5f) + horizontalLineTo(5f) + curveTo(2.58f, 6.25f, 1.25f, 7.58f, 1.25f, 10f) + verticalLineTo(14f) + curveTo(1.25f, 16.42f, 2.58f, 17.75f, 5f, 17.75f) + horizontalLineTo(5.5f) + horizontalLineTo(6.43f) + curveTo(6.66f, 17.75f, 6.89f, 17.82f, 7.09f, 17.94f) + lineTo(10.01f, 19.77f) + curveTo(10.89f, 20.32f, 11.75f, 20.59f, 12.55f, 20.59f) + curveTo(13.07f, 20.59f, 13.57f, 20.47f, 14.02f, 20.22f) + curveTo(15.13f, 19.6f, 15.75f, 18.31f, 15.75f, 16.59f) + verticalLineTo(7.41f) + curveTo(15.75f, 5.69f, 15.13f, 4.4f, 14.02f, 3.78f) + close() + } + } + }.build() + + return _IconsaxVolumeCross!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxVolumeCross: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeHigh.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeHigh.kt new file mode 100644 index 00000000..1001490e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeHigh.kt @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxVolumeHigh: ImageVector + get() { + if (_IconsaxVolumeHigh != null) { + return _IconsaxVolumeHigh!! + } + _IconsaxVolumeHigh = ImageVector.Builder( + name = "IconsaxVolumeHigh", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(18f, 16.75f) + curveTo(17.84f, 16.75f, 17.69f, 16.7f, 17.55f, 16.6f) + curveTo(17.22f, 16.35f, 17.15f, 15.88f, 17.4f, 15.55f) + curveTo(18.97f, 13.46f, 18.97f, 10.54f, 17.4f, 8.45f) + curveTo(17.15f, 8.12f, 17.22f, 7.65f, 17.55f, 7.4f) + curveTo(17.88f, 7.15f, 18.35f, 7.22f, 18.6f, 7.55f) + curveTo(20.56f, 10.17f, 20.56f, 13.83f, 18.6f, 16.45f) + curveTo(18.45f, 16.65f, 18.23f, 16.75f, 18f, 16.75f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(19.83f, 19.25f) + curveTo(19.67f, 19.25f, 19.52f, 19.2f, 19.38f, 19.1f) + curveTo(19.05f, 18.85f, 18.98f, 18.38f, 19.23f, 18.05f) + curveTo(21.9f, 14.49f, 21.9f, 9.51f, 19.23f, 5.95f) + curveTo(18.98f, 5.62f, 19.05f, 5.15f, 19.38f, 4.9f) + curveTo(19.71f, 4.65f, 20.18f, 4.72f, 20.43f, 5.05f) + curveTo(23.5f, 9.14f, 23.5f, 14.86f, 20.43f, 18.95f) + curveTo(20.29f, 19.15f, 20.06f, 19.25f, 19.83f, 19.25f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(14.02f, 3.78f) + curveTo(12.9f, 3.16f, 11.47f, 3.32f, 10.01f, 4.23f) + lineTo(7.09f, 6.06f) + curveTo(6.89f, 6.18f, 6.66f, 6.25f, 6.43f, 6.25f) + horizontalLineTo(5.5f) + horizontalLineTo(5f) + curveTo(2.58f, 6.25f, 1.25f, 7.58f, 1.25f, 10f) + verticalLineTo(14f) + curveTo(1.25f, 16.42f, 2.58f, 17.75f, 5f, 17.75f) + horizontalLineTo(5.5f) + horizontalLineTo(6.43f) + curveTo(6.66f, 17.75f, 6.89f, 17.82f, 7.09f, 17.94f) + lineTo(10.01f, 19.77f) + curveTo(10.89f, 20.32f, 11.75f, 20.59f, 12.55f, 20.59f) + curveTo(13.07f, 20.59f, 13.57f, 20.47f, 14.02f, 20.22f) + curveTo(15.13f, 19.6f, 15.75f, 18.31f, 15.75f, 16.59f) + verticalLineTo(7.41f) + curveTo(15.75f, 5.69f, 15.13f, 4.4f, 14.02f, 3.78f) + close() + } + } + }.build() + + return _IconsaxVolumeHigh!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxVolumeHigh: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeLow.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeLow.kt new file mode 100644 index 00000000..3889166f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeLow.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxVolumeLow: ImageVector + get() { + if (_IconsaxVolumeLow != null) { + return _IconsaxVolumeLow!! + } + _IconsaxVolumeLow = ImageVector.Builder( + name = "IconsaxVolumeLow", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path(fill = SolidColor(Color.White)) { + moveTo(19.33f, 16.75f) + curveTo(19.17f, 16.75f, 19.02f, 16.7f, 18.88f, 16.6f) + curveTo(18.55f, 16.35f, 18.48f, 15.88f, 18.73f, 15.55f) + curveTo(20.3f, 13.46f, 20.3f, 10.54f, 18.73f, 8.45f) + curveTo(18.48f, 8.12f, 18.55f, 7.65f, 18.88f, 7.4f) + curveTo(19.21f, 7.15f, 19.68f, 7.22f, 19.93f, 7.55f) + curveTo(21.9f, 10.17f, 21.9f, 13.83f, 19.93f, 16.45f) + curveTo(19.79f, 16.65f, 19.56f, 16.75f, 19.33f, 16.75f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(15.35f, 3.78f) + curveTo(14.23f, 3.16f, 12.8f, 3.32f, 11.34f, 4.23f) + lineTo(8.42f, 6.06f) + curveTo(8.22f, 6.18f, 7.99f, 6.25f, 7.76f, 6.25f) + horizontalLineTo(6.83f) + horizontalLineTo(6.33f) + curveTo(3.91f, 6.25f, 2.58f, 7.58f, 2.58f, 10f) + verticalLineTo(14f) + curveTo(2.58f, 16.42f, 3.91f, 17.75f, 6.33f, 17.75f) + horizontalLineTo(6.83f) + horizontalLineTo(7.76f) + curveTo(7.99f, 17.75f, 8.22f, 17.82f, 8.42f, 17.94f) + lineTo(11.34f, 19.77f) + curveTo(12.22f, 20.32f, 13.08f, 20.59f, 13.88f, 20.59f) + curveTo(14.4f, 20.59f, 14.9f, 20.47f, 15.35f, 20.22f) + curveTo(16.46f, 19.6f, 17.08f, 18.31f, 17.08f, 16.59f) + verticalLineTo(7.41f) + curveTo(17.08f, 5.69f, 16.46f, 4.4f, 15.35f, 3.78f) + close() + } + } + }.build() + + return _IconsaxVolumeLow!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxVolumeLow: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxWifiSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxWifiSquare.kt new file mode 100644 index 00000000..83fe5866 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxWifiSquare.kt @@ -0,0 +1,88 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxWifiSquare: ImageVector + get() { + if (_IconsaxWifiSquare != null) { + return _IconsaxWifiSquare!! + } + _IconsaxWifiSquare = ImageVector.Builder( + name = "IconsaxWifiSquare", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16.19f, 2f) + horizontalLineTo(7.82f) + curveTo(4.17f, 2f, 2f, 4.17f, 2f, 7.81f) + verticalLineTo(16.18f) + curveTo(2f, 19.82f, 4.17f, 21.99f, 7.81f, 21.99f) + horizontalLineTo(16.18f) + curveTo(19.82f, 21.99f, 21.99f, 19.82f, 21.99f, 16.18f) + verticalLineTo(7.81f) + curveTo(22f, 4.17f, 19.83f, 2f, 16.19f, 2f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(18f, 10.71f) + curveTo(17.84f, 10.71f, 17.68f, 10.66f, 17.54f, 10.55f) + curveTo(14.17f, 7.95f, 9.82f, 7.95f, 6.46f, 10.55f) + curveTo(6.13f, 10.8f, 5.66f, 10.74f, 5.41f, 10.42f) + curveTo(5.16f, 10.09f, 5.22f, 9.62f, 5.54f, 9.37f) + curveTo(9.46f, 6.34f, 14.53f, 6.34f, 18.46f, 9.37f) + curveTo(18.79f, 9.62f, 18.85f, 10.09f, 18.59f, 10.42f) + curveTo(18.45f, 10.61f, 18.22f, 10.71f, 18f, 10.71f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(16.4f, 13.8f) + curveTo(16.24f, 13.8f, 16.08f, 13.75f, 15.94f, 13.64f) + curveTo(13.54f, 11.79f, 10.45f, 11.79f, 8.05f, 13.64f) + curveTo(7.72f, 13.89f, 7.25f, 13.83f, 7f, 13.51f) + curveTo(6.75f, 13.19f, 6.81f, 12.71f, 7.13f, 12.46f) + curveTo(10.08f, 10.18f, 13.9f, 10.18f, 16.85f, 12.46f) + curveTo(17.18f, 12.71f, 17.24f, 13.18f, 16.98f, 13.51f) + curveTo(16.85f, 13.7f, 16.63f, 13.8f, 16.4f, 13.8f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(14.2f, 16.889f) + curveTo(14.04f, 16.889f, 13.88f, 16.84f, 13.74f, 16.729f) + curveTo(12.68f, 15.91f, 11.31f, 15.91f, 10.25f, 16.729f) + curveTo(9.92f, 16.979f, 9.45f, 16.92f, 9.2f, 16.6f) + curveTo(8.95f, 16.27f, 9.01f, 15.8f, 9.33f, 15.55f) + curveTo(10.92f, 14.319f, 13.06f, 14.319f, 14.65f, 15.55f) + curveTo(14.98f, 15.8f, 15.04f, 16.27f, 14.78f, 16.6f) + curveTo(14.65f, 16.789f, 14.43f, 16.889f, 14.2f, 16.889f) + close() + } + } + }.build() + + return _IconsaxWifiSquare!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxWifiSquare: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/InconsaxClock.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/InconsaxClock.kt new file mode 100644 index 00000000..87c8078e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/InconsaxClock.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.InconsaxClock: ImageVector + get() { + if (_InconsaxClock != null) { + return _InconsaxClock!! + } + _InconsaxClock = ImageVector.Builder( + name = "InconsaxClock", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(12f, 22f) + curveTo(17.523f, 22f, 22f, 17.523f, 22f, 12f) + curveTo(22f, 6.477f, 17.523f, 2f, 12f, 2f) + curveTo(6.477f, 2f, 2f, 6.477f, 2f, 12f) + curveTo(2f, 17.523f, 6.477f, 22f, 12f, 22f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(15.71f, 15.93f) + curveTo(15.58f, 15.93f, 15.45f, 15.9f, 15.33f, 15.82f) + lineTo(12.23f, 13.97f) + curveTo(11.46f, 13.51f, 10.89f, 12.5f, 10.89f, 11.61f) + verticalLineTo(7.51f) + curveTo(10.89f, 7.1f, 11.23f, 6.76f, 11.64f, 6.76f) + curveTo(12.05f, 6.76f, 12.39f, 7.1f, 12.39f, 7.51f) + verticalLineTo(11.61f) + curveTo(12.39f, 11.97f, 12.69f, 12.5f, 13f, 12.68f) + lineTo(16.1f, 14.53f) + curveTo(16.46f, 14.74f, 16.57f, 15.2f, 16.36f, 15.56f) + curveTo(16.21f, 15.8f, 15.96f, 15.93f, 15.71f, 15.93f) + close() + } + } + }.build() + + return _InconsaxClock!! + } + +@Suppress("ObjectPropertyName") +private var _InconsaxClock: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/LineDiscord.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/LineDiscord.kt new file mode 100644 index 00000000..6d0efd61 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/LineDiscord.kt @@ -0,0 +1,59 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.LineDiscord: ImageVector + get() { + if (_LineDiscord != null) { + return _LineDiscord!! + } + _LineDiscord = ImageVector.Builder( + name = "LineDiscord", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(18.942f, 5.555f) + curveTo(17.647f, 4.95f, 16.263f, 4.509f, 14.816f, 4.259f) + curveTo(14.638f, 4.58f, 14.43f, 5.012f, 14.287f, 5.356f) + curveTo(12.749f, 5.125f, 11.224f, 5.125f, 9.714f, 5.356f) + curveTo(9.571f, 5.012f, 9.359f, 4.58f, 9.179f, 4.259f) + curveTo(7.731f, 4.509f, 6.345f, 4.951f, 5.05f, 5.559f) + curveTo(2.439f, 9.505f, 1.731f, 13.353f, 2.085f, 17.146f) + curveTo(3.817f, 18.439f, 5.495f, 19.225f, 7.145f, 19.739f) + curveTo(7.553f, 19.178f, 7.916f, 18.582f, 8.229f, 17.954f) + curveTo(7.633f, 17.728f, 7.062f, 17.448f, 6.523f, 17.124f) + curveTo(6.666f, 17.017f, 6.806f, 16.907f, 6.941f, 16.792f) + curveTo(10.232f, 18.332f, 13.807f, 18.332f, 17.059f, 16.792f) + curveTo(17.196f, 16.907f, 17.336f, 17.017f, 17.477f, 17.124f) + curveTo(16.936f, 17.45f, 16.364f, 17.729f, 15.767f, 17.956f) + curveTo(16.08f, 18.582f, 16.442f, 19.18f, 16.851f, 19.741f) + curveTo(18.503f, 19.227f, 20.183f, 18.441f, 21.915f, 17.146f) + curveTo(22.33f, 12.748f, 21.206f, 8.936f, 18.942f, 5.555f) + close() + moveTo(8.678f, 14.813f) + curveTo(7.69f, 14.813f, 6.88f, 13.891f, 6.88f, 12.768f) + curveTo(6.88f, 11.645f, 7.672f, 10.721f, 8.678f, 10.721f) + curveTo(9.683f, 10.721f, 10.493f, 11.643f, 10.476f, 12.768f) + curveTo(10.477f, 13.891f, 9.683f, 14.813f, 8.678f, 14.813f) + close() + moveTo(15.322f, 14.813f) + curveTo(14.334f, 14.813f, 13.524f, 13.891f, 13.524f, 12.768f) + curveTo(13.524f, 11.645f, 14.317f, 10.721f, 15.322f, 10.721f) + curveTo(16.327f, 10.721f, 17.138f, 11.643f, 17.12f, 12.768f) + curveTo(17.12f, 13.891f, 16.327f, 14.813f, 15.322f, 14.813f) + close() + } + }.build() + + return _LineDiscord!! + } + +@Suppress("ObjectPropertyName") +private var _LineDiscord: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt new file mode 100644 index 00000000..4bbf52df --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt @@ -0,0 +1,78 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.PhosphorDatabaseDuotone: ImageVector + get() { + if (_PhosphorDatabaseDuotone != null) { + return _PhosphorDatabaseDuotone!! + } + _PhosphorDatabaseDuotone = ImageVector.Builder( + name = "PhosphorDatabaseDuotone", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 256f, + viewportHeight = 256f + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 0.2f, + strokeAlpha = 0.2f + ) { + moveTo(216f, 80f) + curveToRelative(0f, 26.51f, -39.4f, 48f, -88f, 48f) + reflectiveCurveTo(40f, 106.51f, 40f, 80f) + reflectiveCurveToRelative(39.4f, -48f, 88f, -48f) + reflectiveCurveTo(216f, 53.49f, 216f, 80f) + close() + } + path(fill = SolidColor(Color.Black)) { + moveTo(128f, 24f) + curveTo(74.17f, 24f, 32f, 48.6f, 32f, 80f) + verticalLineToRelative(96f) + curveToRelative(0f, 31.4f, 42.17f, 56f, 96f, 56f) + reflectiveCurveToRelative(96f, -24.6f, 96f, -56f) + lineTo(224f, 80f) + curveTo(224f, 48.6f, 181.83f, 24f, 128f, 24f) + close() + moveTo(208f, 128f) + curveToRelative(0f, 9.62f, -7.88f, 19.43f, -21.61f, 26.92f) + curveTo(170.93f, 163.35f, 150.19f, 168f, 128f, 168f) + reflectiveCurveToRelative(-42.93f, -4.65f, -58.39f, -13.08f) + curveTo(55.88f, 147.43f, 48f, 137.62f, 48f, 128f) + lineTo(48f, 111.36f) + curveToRelative(17.06f, 15f, 46.23f, 24.64f, 80f, 24.64f) + reflectiveCurveToRelative(62.94f, -9.68f, 80f, -24.64f) + close() + moveTo(69.61f, 53.08f) + curveTo(85.07f, 44.65f, 105.81f, 40f, 128f, 40f) + reflectiveCurveToRelative(42.93f, 4.65f, 58.39f, 13.08f) + curveTo(200.12f, 60.57f, 208f, 70.38f, 208f, 80f) + reflectiveCurveToRelative(-7.88f, 19.43f, -21.61f, 26.92f) + curveTo(170.93f, 115.35f, 150.19f, 120f, 128f, 120f) + reflectiveCurveToRelative(-42.93f, -4.65f, -58.39f, -13.08f) + curveTo(55.88f, 99.43f, 48f, 89.62f, 48f, 80f) + reflectiveCurveTo(55.88f, 60.57f, 69.61f, 53.08f) + close() + moveTo(186.39f, 202.92f) + curveTo(170.93f, 211.35f, 150.19f, 216f, 128f, 216f) + reflectiveCurveToRelative(-42.93f, -4.65f, -58.39f, -13.08f) + curveTo(55.88f, 195.43f, 48f, 185.62f, 48f, 176f) + lineTo(48f, 159.36f) + curveToRelative(17.06f, 15f, 46.23f, 24.64f, 80f, 24.64f) + reflectiveCurveToRelative(62.94f, -9.68f, 80f, -24.64f) + lineTo(208f, 176f) + curveTo(208f, 185.62f, 200.12f, 195.43f, 186.39f, 202.92f) + close() + } + }.build() + + return _PhosphorDatabaseDuotone!! + } + +@Suppress("ObjectPropertyName") +private var _PhosphorDatabaseDuotone: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Play.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Play.kt new file mode 100644 index 00000000..c2bd063a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Play.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.Play: ImageVector + get() { + if (_Play != null) { + return _Play!! + } + _Play = ImageVector.Builder( + name = "Play", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(17.13f, 7.98f) + curveTo(20.96f, 10.19f, 20.96f, 13.81f, 17.13f, 16.02f) + lineTo(14.04f, 17.8f) + lineTo(10.95f, 19.58f) + curveTo(7.13f, 21.79f, 4f, 19.98f, 4f, 15.56f) + verticalLineTo(12f) + verticalLineTo(8.44f) + curveTo(4f, 4.02f, 7.13f, 2.21f, 10.96f, 4.42f) + lineTo(13.21f, 5.72f) + } + } + }.build() + + return _Play!! + } + +@Suppress("ObjectPropertyName") +private var _Play: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/SwapHorizontal2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/SwapHorizontal2.kt new file mode 100644 index 00000000..64f351af --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/SwapHorizontal2.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.SwapHorizontal2: ImageVector + get() { + if (_SwapHorizontal2 != null) { + return _SwapHorizontal2!! + } + _SwapHorizontal2 = ImageVector.Builder( + name = "SwapHorizontal2", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16.19f, 2f) + horizontalLineTo(7.81f) + curveTo(4.17f, 2f, 2f, 4.17f, 2f, 7.81f) + verticalLineTo(16.18f) + curveTo(2f, 19.83f, 4.17f, 22f, 7.81f, 22f) + horizontalLineTo(16.18f) + curveTo(19.82f, 22f, 21.99f, 19.83f, 21.99f, 16.19f) + verticalLineTo(7.81f) + curveTo(22f, 4.17f, 19.83f, 2f, 16.19f, 2f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(17.85f, 13.53f) + curveTo(17.77f, 13.35f, 17.63f, 13.2f, 17.44f, 13.12f) + curveTo(17.35f, 13.08f, 17.25f, 13.06f, 17.15f, 13.06f) + horizontalLineTo(6.85f) + curveTo(6.44f, 13.06f, 6.1f, 13.4f, 6.1f, 13.81f) + curveTo(6.1f, 14.22f, 6.44f, 14.56f, 6.85f, 14.56f) + horizontalLineTo(15.35f) + lineTo(13.59f, 16.32f) + curveTo(13.3f, 16.61f, 13.3f, 17.09f, 13.59f, 17.38f) + curveTo(13.74f, 17.53f, 13.93f, 17.6f, 14.12f, 17.6f) + curveTo(14.31f, 17.6f, 14.5f, 17.53f, 14.65f, 17.38f) + lineTo(17.69f, 14.34f) + curveTo(17.76f, 14.27f, 17.81f, 14.19f, 17.85f, 14.1f) + curveTo(17.92f, 13.92f, 17.92f, 13.71f, 17.85f, 13.53f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(6.15f, 10.47f) + curveTo(6.23f, 10.65f, 6.37f, 10.8f, 6.56f, 10.88f) + curveTo(6.65f, 10.92f, 6.75f, 10.94f, 6.85f, 10.94f) + horizontalLineTo(17.16f) + curveTo(17.57f, 10.94f, 17.91f, 10.6f, 17.91f, 10.19f) + curveTo(17.91f, 9.78f, 17.57f, 9.44f, 17.16f, 9.44f) + horizontalLineTo(8.66f) + lineTo(10.42f, 7.68f) + curveTo(10.71f, 7.39f, 10.71f, 6.91f, 10.42f, 6.62f) + curveTo(10.13f, 6.33f, 9.65f, 6.33f, 9.36f, 6.62f) + lineTo(6.32f, 9.65f) + curveTo(6.25f, 9.72f, 6.19f, 9.81f, 6.15f, 9.9f) + curveTo(6.08f, 10.08f, 6.08f, 10.29f, 6.15f, 10.47f) + close() + } + }.build() + + return _SwapHorizontal2!! + } + +@Suppress("ObjectPropertyName") +private var _SwapHorizontal2: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/User.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/User.kt new file mode 100644 index 00000000..f5540c5c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/User.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.User: ImageVector + get() { + if (_User != null) { + return _User!! + } + _User = ImageVector.Builder( + name = "User", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(12f, 12f) + curveTo(14.761f, 12f, 17f, 9.761f, 17f, 7f) + curveTo(17f, 4.239f, 14.761f, 2f, 12f, 2f) + curveTo(9.239f, 2f, 7f, 4.239f, 7f, 7f) + curveTo(7f, 9.761f, 9.239f, 12f, 12f, 12f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 14.5f) + curveTo(6.99f, 14.5f, 2.91f, 17.86f, 2.91f, 22f) + curveTo(2.91f, 22.28f, 3.13f, 22.5f, 3.41f, 22.5f) + horizontalLineTo(20.59f) + curveTo(20.87f, 22.5f, 21.09f, 22.28f, 21.09f, 22f) + curveTo(21.09f, 17.86f, 17.01f, 14.5f, 12f, 14.5f) + close() + } + } + }.build() + + return _User!! + } + +@Suppress("ObjectPropertyName") +private var _User: ImageVector? = null diff --git a/composeApp/src/commonMain/rust/discord_rpc.rs b/composeApp/src/commonMain/rust/discord_rpc.rs new file mode 100644 index 00000000..e11c0f94 --- /dev/null +++ b/composeApp/src/commonMain/rust/discord_rpc.rs @@ -0,0 +1,138 @@ +use discord_rich_presence::activity::{Activity, ActivityType, Assets, Timestamps}; +use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, uniffi::Error)] +pub enum DiscordRpcError { + ConnectionError { reason: String }, + UpdateError { reason: String }, + NotConnected, +} + +impl std::fmt::Display for DiscordRpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DiscordRpcError::ConnectionError { reason } => write!(f, "Connection error: {}", reason), + DiscordRpcError::UpdateError { reason } => write!(f, "Update error: {}", reason), + DiscordRpcError::NotConnected => write!(f, "Not connected"), + } + } +} + +#[derive(uniffi::Object)] +pub struct DiscordRpcClient { + client_id: String, + client: Mutex>, +} + +#[uniffi::export] +impl DiscordRpcClient { + #[uniffi::constructor] + pub fn new(client_id: String) -> Self { + Self { + client_id, + client: Mutex::new(None), + } + } + + pub fn connect(&self) -> Result<(), DiscordRpcError> { + let mut client_guard = self.client.lock().unwrap(); + if client_guard.is_some() { + return Ok(()); + } + + let mut client = DiscordIpcClient::new(&self.client_id); + + client + .connect() + .map_err(|e| DiscordRpcError::ConnectionError { reason: e.to_string() })?; + + *client_guard = Some(client); + Ok(()) + } + + pub fn disconnect(&self) -> Result<(), DiscordRpcError> { + let mut client_guard = self.client.lock().unwrap(); + if let Some(mut client) = client_guard.take() { + client + .close() + .map_err(|e| DiscordRpcError::ConnectionError { reason: e.to_string() })?; + } + Ok(()) + } + + pub fn update_presence( + &self, + title: String, + artist: String, + album: String, + cover_url: String, + position_ms: i64, + duration_ms: i64, + ) -> Result<(), DiscordRpcError> { + let mut client_guard = self.client.lock().unwrap(); + let client = client_guard + .as_mut() + .ok_or(DiscordRpcError::NotConnected)?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let start = now - (position_ms / 1000); + let end = start + (duration_ms / 1000); + + let details = if title.is_empty() { + "Unknown Track".to_string() + } else { + title + }; + + let state = if artist.is_empty() { + "Unknown Artist".to_string() + } else { + format!("by {}", artist) + }; + + let mut activity = Activity::new() + .details(&details) + .state(&state) + .timestamps(Timestamps::new().start(start).end(end)) + .activity_type(ActivityType::Listening); + + if !album.is_empty() { + let mut assets = Assets::new().large_text(&album); + if !cover_url.is_empty() { + assets = assets.large_image(&cover_url); + } + activity = activity.assets(assets); + } else if !cover_url.is_empty() { + activity = activity.assets(Assets::new().large_image(&cover_url)); + } + + client + .set_activity(activity) + .map_err(|e| DiscordRpcError::UpdateError { reason: e.to_string() })?; + + Ok(()) + } + + pub fn clear_presence(&self) -> Result<(), DiscordRpcError> { + let mut client_guard = self.client.lock().unwrap(); + let client = client_guard + .as_mut() + .ok_or(DiscordRpcError::NotConnected)?; + + client + .clear_activity() + .map_err(|e| DiscordRpcError::UpdateError { reason: e.to_string() })?; + + Ok(()) + } + + pub fn is_connected(&self) -> bool { + self.client.lock().unwrap().is_some() + } +} diff --git a/composeApp/src/commonMain/rust/lib.rs b/composeApp/src/commonMain/rust/lib.rs new file mode 100644 index 00000000..f8e0e84e --- /dev/null +++ b/composeApp/src/commonMain/rust/lib.rs @@ -0,0 +1,7 @@ +mod metadata; +mod discord_rpc; + +pub use metadata::*; +pub use discord_rpc::*; + +uniffi::setup_scaffolding!(); \ No newline at end of file diff --git a/composeApp/src/commonMain/rust/metadata.rs b/composeApp/src/commonMain/rust/metadata.rs new file mode 100644 index 00000000..73402f3f --- /dev/null +++ b/composeApp/src/commonMain/rust/metadata.rs @@ -0,0 +1,158 @@ +use lofty::config::WriteOptions; +use lofty::file::{AudioFile, FileType, TaggedFileExt}; +use lofty::picture::Picture; +use lofty::probe::Probe; +use lofty::tag::{Accessor, Tag, TagExt}; +use std::path::Path; + +#[derive(uniffi::Record)] +pub struct AudioMetadata { + pub title: Option, + pub artists: Vec, + pub album: Option, + pub duration_ms: i64, + pub track_number: Option, + pub disc_number: Option, + pub cover_bytes: Option>, +} + +#[derive(Debug, uniffi::Error)] +pub enum AudioTagError { + FileNotFound, + ReadError { reason: String }, + WriteError { reason: String }, +} + +impl std::fmt::Display for AudioTagError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AudioTagError::FileNotFound => write!(f, "File not found"), + AudioTagError::ReadError { reason } => write!(f, "Read error: {}", reason), + AudioTagError::WriteError { reason } => write!(f, "Write error: {}", reason), + } + } +} + +#[uniffi::export] +pub fn read_audio_metadata(file_path: String) -> Result { + let path = Path::new(&file_path); + if !path.exists() { + return Err(AudioTagError::FileNotFound); + } + + let tagged_file = Probe::open(path) + .map_err(|e| AudioTagError::ReadError { reason: e.to_string() })? + .read() + .map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?; + + let duration = tagged_file.properties().duration(); + let duration_ms = duration.as_millis() as i64; + + let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()); + + let mut title = None; + let mut artists = Vec::new(); + let mut album = None; + let mut track_number = None; + let mut disc_number = None; + let mut cover_bytes = None; + + if let Some(tag) = tag { + title = tag.title().map(|s| s.to_string()); + + if let Some(artist) = tag.artist() { + artists = artist + .split(';') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + + album = tag.album().map(|s| s.to_string()); + track_number = tag.track(); + disc_number = tag.disk(); + + if let Some(pic) = tag.pictures().first() { + cover_bytes = Some(pic.data().to_vec()); + } + } + + Ok(AudioMetadata { + title, + artists, + album, + duration_ms, + track_number, + disc_number, + cover_bytes, + }) +} + +#[uniffi::export] +pub fn write_audio_metadata( + file_path: String, + title: String, + artists: String, + album: Option, + track_number: Option, + disc_number: Option, + cover_bytes: Option>, +) -> Result<(), AudioTagError> { + let path = Path::new(&file_path); + if !path.exists() { + return Err(AudioTagError::FileNotFound); + } + + let mut tagged_file = Probe::open(path) + .map_err(|e| AudioTagError::ReadError { reason: e.to_string() })? + .read() + .map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?; + + let file_type = tagged_file.file_type(); + + if tagged_file.primary_tag().is_none() && tagged_file.first_tag().is_none() { + let new_tag = match file_type { + FileType::Mpeg => Tag::new(lofty::tag::TagType::Id3v2), + FileType::Mp4 => Tag::new(lofty::tag::TagType::Mp4Ilst), + FileType::Flac => Tag::new(lofty::tag::TagType::VorbisComments), + FileType::Opus | FileType::Vorbis => Tag::new(lofty::tag::TagType::VorbisComments), + _ => Tag::new(lofty::tag::TagType::Id3v2), + }; + tagged_file.insert_tag(new_tag); + } + + let tag = if tagged_file.primary_tag().is_some() { + tagged_file.primary_tag_mut().unwrap() + } else if tagged_file.first_tag().is_some() { + tagged_file.first_tag_mut().unwrap() + } else { + unreachable!() + }; + + tag.set_title(title); + tag.set_artist(artists); + + if let Some(album_name) = album { + tag.set_album(album_name); + } + + if let Some(num) = track_number { + tag.set_track(num as u32); + } + + if let Some(num) = disc_number { + tag.set_disk(num as u32); + } + + if let Some(bytes) = cover_bytes { + if !bytes.is_empty() { + let picture = Picture::unchecked(bytes).build(); + tag.push_picture(picture); + } + } + + tag.save_to_path(path, WriteOptions::default()) + .map_err(|e| AudioTagError::WriteError { reason: e.to_string() })?; + + Ok(()) +} diff --git a/composeApp/src/commonMain/templates/AppBuildInfo.kt.template b/composeApp/src/commonMain/templates/AppBuildInfo.kt.template new file mode 100644 index 00000000..b1dee0db --- /dev/null +++ b/composeApp/src/commonMain/templates/AppBuildInfo.kt.template @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube + +internal object AppBuildInfo { + const val VERSION = "${appVersion}" + const val BUILD_NUMBER = ${appBuildNumber} + const val REPOSITORY_URL = "${repositoryUrl}" + const val LATEST_RELEASE_API_URL = "${latestReleaseApiUrl}" +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueTest.kt new file mode 100644 index 00000000..2711d897 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueTest.kt @@ -0,0 +1,86 @@ +package org.spotube.spotube.core.audioplayer + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AudioPlayerQueueTest { + + private fun createQueue( + currentCollectionEntry: QueueCollectionEntry? = null + ): AudioPlayerQueue { + val collectionEntryFlow = MutableStateFlow(currentCollectionEntry) + return object : AudioPlayerQueue { + override val queueFlow: StateFlow> = MutableStateFlow(emptyList()) + override val currentQueueEntryFlow: StateFlow = MutableStateFlow(null) + override val currentCollectionEntryFlow: StateFlow = collectionEntryFlow + override val collectionHistoryFlow: StateFlow> = MutableStateFlow(emptyList()) + + override suspend fun load(entries: List, autoPlay: Boolean, startPosition: Int, collectionEntry: QueueCollectionEntry?) = Unit + override suspend fun addToQueue(entry: QueueEntry) = Unit + override suspend fun addAllToQueue(entries: List, collectionEntry: QueueCollectionEntry?) = Unit + override suspend fun addAllAfterCurrent(entries: List) = Unit + override suspend fun removeFromQueue(entry: QueueEntry) = Unit + override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = Unit + override suspend fun move(fromIndex: Int, toIndex: Int) = Unit + override suspend fun jumpTo(index: Int, autoPlay: Boolean) = Unit + override suspend fun reloadCurrent() = Unit + override suspend fun clear() = Unit + override suspend fun getQueue(): List = emptyList() + override suspend fun getCurrentQueueEntry(): QueueEntry? = null + override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null + override suspend fun getCollectionHistory(): List = emptyList() + } + } + + @Test + fun `isPlaylistPlaying returns true when current collection is matching playlist`() = runTest { + val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.Playlist("pl-1")) + assertTrue(queue.isPlaylistPlaying("pl-1")) + } + + @Test + fun `isPlaylistPlaying returns false when playlist id does not match`() = runTest { + val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.Playlist("pl-1")) + assertFalse(queue.isPlaylistPlaying("pl-2")) + } + + @Test + fun `isPlaylistPlaying returns false when current is not a playlist`() = runTest { + val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.Album("al-1")) + assertFalse(queue.isPlaylistPlaying()) + } + + @Test + fun `isAlbumPlaying returns true when current collection is matching album`() = runTest { + val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.Album("al-1")) + assertTrue(queue.isAlbumPlaying("al-1")) + } + + @Test + fun `isAlbumPlaying returns false when album id does not match`() = runTest { + val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.Album("al-1")) + assertFalse(queue.isAlbumPlaying("al-2")) + } + + @Test + fun `isAlbumPlaying returns false when current is not an album`() = runTest { + val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.Playlist("pl-1")) + assertFalse(queue.isAlbumPlaying()) + } + + @Test + fun `isSavedTracksPlaying returns true when current is SavedTracks`() = runTest { + val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.SavedTracks) + assertTrue(queue.isSavedTracksPlaying()) + } + + @Test + fun `isSavedTracksPlaying returns false when current is not SavedTracks`() = runTest { + val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.Album("al-1")) + assertFalse(queue.isSavedTracksPlaying()) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt new file mode 100644 index 00000000..5dde1c65 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt @@ -0,0 +1,497 @@ +package org.spotube.spotube.core.audioplayer + +import co.touchlab.kermit.Logger +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds + +class DeviceAudioPlayerQueueTest { + + private lateinit var fakePlayer: FakeAudioPlayer + private lateinit var fakeSettings: FakeSettingsProvider + private lateinit var fakeRepo: FakeAudioPlayerQueueRepository + private lateinit var fakePlugin: FakePluginProvider + private lateinit var fakeBlacklist: FakeBlacklistRepository + + @BeforeTest + fun setup() { + startKoin { + modules(module { + factory { (tag: String?) -> Logger.withTag(tag ?: "test") } + }) + } + fakePlayer = FakeAudioPlayer() + fakeSettings = FakeSettingsProvider() + fakeRepo = FakeAudioPlayerQueueRepository() + fakePlugin = FakePluginProvider() + fakeBlacklist = FakeBlacklistRepository() + } + + @AfterTest + fun teardown() { + stopKoin() + } + + private fun createQueue(): DeviceAudioPlayerQueue { + return DeviceAudioPlayerQueue(fakePlayer, fakeSettings, fakeRepo, fakePlugin, fakeBlacklist) + } + + private fun streamingTrack( + id: String, + title: String = "Track $id", + artist: String = "Artist $id" + ): MetadataTrack { + val artistBasic = MetadataArtist.Basic( + id = "artist-$id", + name = artist, + thumbnails = emptyList(), + externalUri = null + ) + val album = MetadataAlbum.Detailed( + releaseDate = "2024", + genres = emptyList(), + trackCount = 1, + id = "album-$id", + title = "Album $title", + description = null, + thumbnails = listOf(Thumbnail(url = "https://cover/$id", width = 300, height = 300)), + albumType = MetadataAlbumType.Album, + artists = listOf(artistBasic), + externalUri = null + ) + return MetadataTrack( + id = id, + title = title, + durationMs = 200_000L, + trackNumber = 1, + discNumber = 1, + artists = listOf(artistBasic), + album = album, + thumbnails = album.thumbnails, + explicit = false, + popularity = 50, + isrcCode = null, + externalUri = null + ) + } + + private fun streamingEntry( + trackId: String, + url: String = "http://127.0.0.1:14769/stream/$trackId" + ): QueueEntry.StreamingTrack { + return QueueEntry.StreamingTrack( + track = streamingTrack(trackId), + url = url, + protocol = StreamProtocol.PROGRESSIVE + ) + } + + private fun localEntry( + name: String = "Local Track", + url: String = "file:///music/$name.mp3" + ): QueueEntry.LocalTrack { + return QueueEntry.LocalTrack( + name = name, + artists = listOf("Local Artist"), + duration = 180_000L, + album = "Local Album", + coverBytes = null, + url = url + ) + } + + // --- load --- + + @Test + fun `load populates player playlist with media items`() = runTest { + val queue = createQueue() + val entries = listOf(streamingEntry("t1"), streamingEntry("t2")) + + queue.load(entries) + + assertEquals(1, fakePlayer.loadCallCount) + assertEquals(2, fakePlayer.lastLoadPlaylist?.size) + assertEquals("Track t1", fakePlayer.lastLoadPlaylist?.get(0)?.title) + assertEquals("Track t2", fakePlayer.lastLoadPlaylist?.get(1)?.title) + } + + @Test + fun `load respects autoPlay parameter`() = runTest { + val queue = createQueue() + val entries = listOf(streamingEntry("t1")) + + queue.load(entries, autoPlay = false) + + assertEquals(false, fakePlayer.lastLoadAutoPlay) + } + + @Test + fun `load respects startPosition`() = runTest { + val queue = createQueue() + val entries = listOf(streamingEntry("t1"), streamingEntry("t2")) + + queue.load(entries, startPosition = 1) + + assertEquals(1, fakePlayer.lastLoadStartPosition) + } + + @Test + fun `load sets collection context`() = runTest { + val queue = createQueue() + val colEntry = QueueCollectionEntry.Playlist("pl-1") + + queue.load( + entries = listOf(streamingEntry("t1")), + collectionEntry = colEntry + ) + + assertEquals(colEntry, queue.getCurrentCollectionEntry()) + } + + @Test + fun `load with empty entries does not crash`() = runTest { + val queue = createQueue() + + queue.load(emptyList()) + + assertEquals(1, fakePlayer.loadCallCount) + assertTrue { fakePlayer.lastLoadPlaylist?.isEmpty() == true } + } + + // --- addToQueue --- + + @Test + fun `addToQueue adds entry and calls player`() = runTest { + val queue = createQueue() + val entry = streamingEntry("t1") + + queue.addToQueue(entry) + + assertEquals(1, fakePlayer.addMediaItemCallCount) + assertNotNull(fakePlayer.lastAddedMediaItem) + assertEquals("Track t1", fakePlayer.lastAddedMediaItem?.title) + } + + @Test + fun `addToQueue skips local entry with blank url`() = runTest { + val queue = createQueue() + val entry = localEntry(url = "") + + queue.addToQueue(entry) + + assertEquals(0, fakePlayer.addMediaItemCallCount) + } + + // --- addAllToQueue --- + + @Test + fun `addAllToQueue adds multiple entries`() = runTest { + val queue = createQueue() + val entries = listOf(streamingEntry("t1"), streamingEntry("t2")) + + queue.addAllToQueue(entries) + + assertEquals(2, fakePlayer.addMediaItemCallCount) + } + + @Test + fun `addAllToQueue sets collection context`() = runTest { + val queue = createQueue() + val colEntry = QueueCollectionEntry.Album("al-1") + + queue.addAllToQueue( + entries = listOf(streamingEntry("t1")), + collectionEntry = colEntry + ) + + assertEquals(colEntry, queue.getCurrentCollectionEntry()) + } + + // --- addAllAfterCurrent --- + + @Test + fun `addAllAfterCurrent inserts after current track`() = runTest { + val queue = createQueue() + val entries = listOf(streamingEntry("t1")) + + queue.addAllAfterCurrent(entries) + + assertEquals(1, fakePlayer.insertAtNextCallCount) + } + + // --- removeFromQueue --- + + @Test + fun `removeFromQueue removes entry by media item url`() = runTest { + val queue = createQueue() + val entry = streamingEntry("t1") + queue.addToQueue(entry) + + fakePlayer.resetCallCounts() + queue.removeFromQueue(entry) + + assertEquals(1, fakePlayer.removeMediaItemCallCount) + } + + @Test + fun `removeFromQueueByMediaUrl calls player remove`() = runTest { + val queue = createQueue() + val entry = streamingEntry("t1", url = "http://example.com/t1") + queue.addToQueue(entry) + + fakePlayer.resetCallCounts() + queue.removeFromQueueByMediaUrl("http://example.com/t1") + + assertEquals(1, fakePlayer.removeMediaItemCallCount) + } + + // --- move --- + + @Test + fun `move delegates to player`() = runTest { + val queue = createQueue() + + queue.move(0, 2) + + assertEquals(1, fakePlayer.moveCallCount) + assertEquals(0, fakePlayer.lastMoveFromIndex) + assertEquals(2, fakePlayer.lastMoveToIndex) + } + + // --- jumpTo --- + + @Test + fun `jumpTo with autoPlay calls player jumpTo and play`() = runTest { + val queue = createQueue() + fakePlayer.setPlaylist(listOf( + MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE), + MediaItem("T2", "A2", "Al2", 200.milliseconds, "", "url2", StreamProtocol.PROGRESSIVE))) + + queue.jumpTo(1, autoPlay = true) + + assertEquals(1, fakePlayer.jumpToCallCount) + assertEquals(1, fakePlayer.lastJumpToIndex) + assertEquals(1, fakePlayer.playCallCount) + } + + @Test + fun `jumpTo without autoPlay does not call play`() = runTest { + val queue = createQueue() + fakePlayer.setPlaylist(listOf( + MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE))) + + queue.jumpTo(0, autoPlay = false) + + assertEquals(1, fakePlayer.jumpToCallCount) + assertEquals(0, fakePlayer.playCallCount) + } + + @Test + fun `jumpTo clamps out of range index`() = runTest { + val queue = createQueue() + fakePlayer.setPlaylist(listOf( + MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE))) + + queue.jumpTo(999) + + assertEquals(1, fakePlayer.jumpToCallCount) + assertEquals(0, fakePlayer.lastJumpToIndex) + } + + // --- reloadCurrent --- + + @Test + fun `reloadCurrent does nothing when no current entry`() = runTest { + val queue = createQueue() + fakePlayer.setCurrentItem(null) + + queue.reloadCurrent() + + assertEquals(0, fakePlayer.jumpToCallCount) + } + + // --- clear --- + + @Test + fun `clear resets everything`() = runTest { + val queue = createQueue() + queue.load(listOf(streamingEntry("t1")), collectionEntry = QueueCollectionEntry.Playlist("pl-1")) + + queue.clear() + + val lastLoad = fakePlayer.lastLoadPlaylist + assertTrue { lastLoad?.isEmpty() == true } + assertEquals(false, fakePlayer.lastLoadAutoPlay) + assertNull(queue.getCurrentCollectionEntry()) + assertTrue { queue.getCollectionHistory().isEmpty() } + } + + // --- getQueue --- + + @Test + fun `getQueue returns entries with metadata`() = runTest { + val queue = createQueue() + val entry = streamingEntry("t1", url = "url1") + queue.addToQueue(entry) + + val result = queue.getQueue() + + assertEquals(1, result.size) + val qEntry = result[0] as? QueueEntry.StreamingTrack + assertNotNull(qEntry) + assertEquals("t1", qEntry.track.id) + } + + @Test + fun `getCurrentQueueEntry returns current media as queue entry`() = runTest { + val queue = createQueue() + fakePlayer.setCurrentItem(MediaItem("Test", "Artist", "Album", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE)) + + val result = queue.getCurrentQueueEntry() + + assertNotNull(result) + val localEntry = result as? QueueEntry.LocalTrack + assertNotNull(localEntry) + assertEquals("Test", localEntry.name) + } + + // --- collection history --- + + @Test + fun `collection history tracks plays in order`() = runTest { + val queue = createQueue() + queue.addAllToQueue( + listOf(streamingEntry("t1")), + collectionEntry = QueueCollectionEntry.Playlist("pl-1") + ) + queue.addAllToQueue( + listOf(streamingEntry("t2")), + collectionEntry = QueueCollectionEntry.Album("al-1") + ) + + val history = queue.getCollectionHistory() + assertEquals(2, history.size) + assertEquals(QueueCollectionEntry.Album("al-1"), history[0]) + assertEquals(QueueCollectionEntry.Playlist("pl-1"), history[1]) + } + + @Test + fun `collection history deduplicates`() = runTest { + val queue = createQueue() + queue.addAllToQueue( + listOf(streamingEntry("t1")), + collectionEntry = QueueCollectionEntry.Playlist("pl-1") + ) + queue.addAllToQueue( + listOf(streamingEntry("t2")), + collectionEntry = QueueCollectionEntry.Playlist("pl-1") + ) + + val history = queue.getCollectionHistory() + assertEquals(1, history.size) + } + + // --- persistence --- + + @Test + fun `restorePersistedState loads from repository on init`() = runTest { + val entries = listOf(streamingEntry("t1")) + fakeRepo.setState( + PersistedQueueState( + entries = entries, + currentIndex = 0, + currentCollectionEntry = QueueCollectionEntry.Playlist("pl-1"), + collectionHistory = listOf(QueueCollectionEntry.Playlist("pl-1")) + ) + ) + + val queue = createQueue() + withContext(Dispatchers.Default) { } // yield to let init coroutine run + + assertEquals(1, fakePlayer.loadCallCount) + assertEquals(QueueCollectionEntry.Playlist("pl-1"), queue.getCurrentCollectionEntry()) + assertEquals(1, queue.getCollectionHistory().size) + } + + @Test + fun `restorePersistedState skips when no persisted state`() = runTest { + fakeRepo.setState(null) + + val queue = createQueue() + withContext(Dispatchers.Default) { } + + assertEquals(0, fakePlayer.loadCallCount) + } + + // --- toMediaItem / toFallbackQueueEntry --- + + @Test + fun `streaming track converts to media item`() = runTest { + val queue = createQueue() + fakeSettings.setSettings( + org.spotube.spotube.modules.settings.UserSettings(playbackProxyServerPort = 14769) + ) + + val entry = streamingEntry("t1") + queue.addToQueue(entry) + + val mediaItem = fakePlayer.lastAddedMediaItem + assertNotNull(mediaItem) + assertEquals("Track t1", mediaItem.title) + assertEquals("Artist t1", mediaItem.artist) + assertTrue { mediaItem.url.contains("/stream/t1") } + } + + @Test + fun `local track converts to media item`() = runTest { + val queue = createQueue() + val entry = localEntry() + + queue.addToQueue(entry) + + val mediaItem = fakePlayer.lastAddedMediaItem + assertNotNull(mediaItem) + assertEquals("Local Track", mediaItem.title) + assertEquals("file:///music/Local Track.mp3", mediaItem.url) + } + + @Test + fun `fallback queue entry preserves media item fields`() = runTest { + val queue = createQueue() + val mediaItem = MediaItem( + title = "Fallback", + artist = "Artist1, Artist2", + album = "Album", + duration = 300.milliseconds, + coverURL = "", + url = "file:///test.mp3", + protocol = StreamProtocol.PROGRESSIVE + ) + fakePlayer.setCurrentItem(mediaItem) + + val result = queue.getCurrentQueueEntry() + + assertNotNull(result) + val local = result as? QueueEntry.LocalTrack + assertNotNull(local) + assertEquals("Fallback", local.name) + assertEquals(listOf("Artist1", "Artist2"), local.artists) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayer.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayer.kt new file mode 100644 index 00000000..4f6fa948 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayer.kt @@ -0,0 +1,194 @@ +package org.spotube.spotube.core.audioplayer + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlin.time.Duration + +class FakeAudioPlayer : AudioPlayerInterface { + private val _playlistFlow = MutableStateFlow>(emptyList()) + override val playlistFlow: StateFlow> = _playlistFlow.asStateFlow() + + private val _durationFlow = MutableStateFlow(Duration.ZERO) + override val durationFlow: StateFlow = _durationFlow.asStateFlow() + + private val _positionFlow = MutableStateFlow(Duration.ZERO) + override val positionFlow: StateFlow = _positionFlow.asStateFlow() + + override val bufferingPositionFlow: StateFlow + get() = TODO("Not yet implemented") + + private val _currentMediaItemFlow = MutableStateFlow(null) + override val currentMediaItemFlow: StateFlow = _currentMediaItemFlow.asStateFlow() + + private val _loopStateFlow = MutableStateFlow(LoopState.NONE) + override val loopStateFlow: StateFlow = _loopStateFlow.asStateFlow() + override val shuffleModeFlow: StateFlow + get() = TODO("Not yet implemented") + override val playbackSpeedFlow: StateFlow + get() = TODO("Not yet implemented") + override val volumeFlow: StateFlow + get() = TODO("Not yet implemented") + override val completionFlow: Flow + get() = TODO("Not yet implemented") + override val errorFlow: Flow + get() = TODO("Not yet implemented") + + override suspend fun setVolume(volume: Float) { + TODO("Not yet implemented") + } + + override suspend fun setPlaybackSpeed(speed: Float) { + TODO("Not yet implemented") + } + + override fun isDisposed(): Boolean { + TODO("Not yet implemented") + } + + override fun dispose() { + TODO("Not yet implemented") + } + + var loadCallCount = 0 + var lastLoadPlaylist: List? = null + var lastLoadAutoPlay: Boolean = true + var lastLoadStartPosition: Int = 0 + + var playCallCount = 0 + + var addMediaItemCallCount = 0 + var lastAddedMediaItem: MediaItem? = null + + var insertAtNextCallCount = 0 + var lastInsertedMediaItem: MediaItem? = null + + var removeMediaItemCallCount = 0 + var lastRemovedMediaItem: MediaItem? = null + + var moveCallCount = 0 + var lastMoveFromIndex: Int = 0 + var lastMoveToIndex: Int = 0 + + var jumpToCallCount = 0 + var lastJumpToIndex: Int = 0 + + override suspend fun load(playlist: List, autoPlay: Boolean, startPosition: Int) { + loadCallCount++ + lastLoadPlaylist = playlist + lastLoadAutoPlay = autoPlay + lastLoadStartPosition = startPosition + _playlistFlow.value = playlist.toList() + _currentMediaItemFlow.value = playlist.getOrNull(startPosition) + } + + override suspend fun play() { + playCallCount++ + } + + override suspend fun pause() { + TODO("Not yet implemented") + } + + override suspend fun stop() { + TODO("Not yet implemented") + } + + override suspend fun seekTo(position: Duration) { + TODO("Not yet implemented") + } + + override suspend fun loop(state: LoopState) { + TODO("Not yet implemented") + } + + override suspend fun shuffle(enabled: Boolean) { + TODO("Not yet implemented") + } + + override suspend fun addMediaItem(mediaItem: MediaItem) { + addMediaItemCallCount++ + lastAddedMediaItem = mediaItem + val updated = _playlistFlow.value.toMutableList() + updated.add(mediaItem) + _playlistFlow.value = updated + } + + override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) { + insertAtNextCallCount++ + lastInsertedMediaItem = mediaItem + val updated = _playlistFlow.value.toMutableList() + updated.add(mediaItem) + _playlistFlow.value = updated + } + + override suspend fun removeMediaItem(mediaItem: MediaItem) { + removeMediaItemCallCount++ + lastRemovedMediaItem = mediaItem + _playlistFlow.value = _playlistFlow.value.filter { it.url != mediaItem.url } + } + + override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) { + moveCallCount++ + lastMoveFromIndex = fromIndex + lastMoveToIndex = toIndex + val updated = _playlistFlow.value.toMutableList() + if (fromIndex in updated.indices && toIndex in updated.indices) { + val item = updated.removeAt(fromIndex) + updated.add(toIndex, item) + _playlistFlow.value = updated + } + } + + override suspend fun skipToNext() { + TODO("Not yet implemented") + } + + override suspend fun skipToPrevious() { + TODO("Not yet implemented") + } + + override suspend fun jumpTo(index: Int) { + jumpToCallCount++ + lastJumpToIndex = index + _currentMediaItemFlow.value = _playlistFlow.value.getOrNull(index) + } + + private val _playerStateFlow = MutableStateFlow(PlayerState.IDLE) + override val playerStateFlow: StateFlow = _playerStateFlow.asStateFlow() + + fun setPlaylist(items: List) { + _playlistFlow.value = items + } + + fun setCurrentItem(item: MediaItem?) { + _currentMediaItemFlow.value = item + } + + fun setLoopState(state: LoopState) { + _loopStateFlow.value = state + } + + fun setPlayerState(state: PlayerState) { + _playerStateFlow.value = state + } + + fun setPosition(position: Duration) { + _positionFlow.value = position + } + + fun setDuration(duration: Duration) { + _durationFlow.value = duration + } + + fun resetCallCounts() { + loadCallCount = 0 + playCallCount = 0 + addMediaItemCallCount = 0 + insertAtNextCallCount = 0 + removeMediaItemCallCount = 0 + moveCallCount = 0 + jumpToCallCount = 0 + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt new file mode 100644 index 00000000..b9149c83 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt @@ -0,0 +1,19 @@ +package org.spotube.spotube.core.audioplayer + +class FakeAudioPlayerQueueRepository : QueueStateRepository { + private var persistedState: PersistedQueueState? = null + + override suspend fun getPersistedState(): PersistedQueueState? = persistedState + + override suspend fun saveState(state: PersistedQueueState) { + persistedState = state + } + + override suspend fun clearState() { + persistedState = null + } + + fun setState(state: PersistedQueueState?) { + persistedState = state + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeBlacklistRepository.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeBlacklistRepository.kt new file mode 100644 index 00000000..8793f64e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeBlacklistRepository.kt @@ -0,0 +1,34 @@ +package org.spotube.spotube.core.audioplayer + +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeBlacklistRepository : BlacklistRepository( + database = Database(Paths()) +) { + private val _blacklistedTracks = MutableStateFlow>(emptyList()) + override val blacklistedTracks: Flow> = _blacklistedTracks.asStateFlow() + + private val _blacklistedArtists = MutableStateFlow>(emptyList()) + override val blacklistedArtists: Flow> = _blacklistedArtists.asStateFlow() + + override suspend fun toggleTrack(track: MetadataTrack) { + val current = _blacklistedTracks.value.toMutableList() + val idx = current.indexOfFirst { it.id == track.id } + if (idx >= 0) current.removeAt(idx) else current.add(track) + _blacklistedTracks.value = current + } + + override suspend fun toggleArtist(artist: MetadataArtist) { + val current = _blacklistedArtists.value.toMutableList() + val idx = current.indexOfFirst { it.id == artist.id } + if (idx >= 0) current.removeAt(idx) else current.add(artist) + _blacklistedArtists.value = current + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt new file mode 100644 index 00000000..b3daab34 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt @@ -0,0 +1,60 @@ +package org.spotube.spotube.core.audioplayer + +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import org.spotube.spotube.core.zipline.PluginService +import org.spotube.spotube.core.zipline.PluginServiceScope +import org.spotube.spotube.modules.plugin.PluginProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class FakeMetadataTrackAPI( + private val recommendations: List = emptyList() +) : MetadataTrackAPI { + override suspend fun getTrack(id: String): MetadataTrack = + error("not mocked") + + override suspend fun savedTracks(pagination: PaginationStrategy?): PaginationResult = + error("not mocked") + + override suspend fun isSavedTracks(ids: List): List = + error("not mocked") + + override suspend fun saveTracks(ids: List) = error("not mocked") + + override suspend fun removeSavedTracks(ids: List) = error("not mocked") + + override suspend fun recommendationsBasedOnTracks( + seedTrackIds: List, + limit: Int + ): List = recommendations +} + +class FakePluginService( + private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(), + override val pluginId: String = "fake-plugin") : PluginService { + override val loggedInFlow: StateFlow = MutableStateFlow(true) + + override suspend fun start() = Unit + override suspend fun stop() = Unit + + override suspend fun use(block: suspend PluginServiceScope.() -> T): T { + val scope = PluginServiceScope( + mapOf(MetadataTrackAPI::class to metadataTrackAPI) + ) + return scope.block() + } +} + +class FakePluginProvider( + private val pluginService: PluginService? = null +) : PluginProvider { + private val _selectedMetadataPlugin = MutableStateFlow(pluginService) + override val selectedMetadataPlugin: StateFlow = _selectedMetadataPlugin + + fun setPlugin(service: PluginService?) { + _selectedMetadataPlugin.value = service + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeSettingsProvider.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeSettingsProvider.kt new file mode 100644 index 00000000..c979107d --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeSettingsProvider.kt @@ -0,0 +1,22 @@ +package org.spotube.spotube.core.audioplayer + +import org.spotube.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.modules.settings.UserSettings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeSettingsProvider( + initialSettings: UserSettings? = UserSettings() +) : SettingsProvider { + private val _settingsState = MutableStateFlow(initialSettings) + override val settingsState: StateFlow = _settingsState.asStateFlow() + + fun updateSettings(transform: UserSettings.() -> UserSettings) { + _settingsState.value = _settingsState.value?.transform() + } + + fun setSettings(settings: UserSettings?) { + _settingsState.value = settings + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManagerTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManagerTest.kt new file mode 100644 index 00000000..f4549508 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManagerTest.kt @@ -0,0 +1,394 @@ +package org.spotube.spotube.core.scrobble + +import co.touchlab.kermit.Logger +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.FakeAudioPlayer +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.zipline.PluginService +import org.spotube.spotube.core.zipline.PluginServiceScope +import org.spotube.spotube.modules.plugin.ScrobblePluginSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class ScrobbleManagerTest { + + private lateinit var player: FakeAudioPlayer + private lateinit var queue: FakeQueue + private lateinit var plugin: FakeScrobblePluginService + private lateinit var pluginSource: FakeScrobblePluginSource + + /** Virtual playback position advanced by [playSeconds]; mirrors a real player clock. */ + private var currentPositionMs = 0L + + @BeforeTest + fun setup() { + startKoin { + modules(module { + factory { (tag: String?) -> Logger.withTag(tag ?: "test") } + }) + } + player = FakeAudioPlayer() + queue = FakeQueue() + plugin = FakeScrobblePluginService() + pluginSource = FakeScrobblePluginSource(plugin) + currentPositionMs = 0L + } + + @AfterTest + fun teardown() { + stopKoin() + } + + private fun TestScope.createManager(): ScrobbleManager { + return ScrobbleManager( + audioPlayer = player, + audioPlayerQueue = queue, + scrobblePluginSource = pluginSource, + scope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler))) + } + + private fun playSeconds(seconds: Int) { + repeat(seconds) { + currentPositionMs += 1_000 + player.setPosition(currentPositionMs.milliseconds) + } + } + + @Test + fun `scrobbles once half of the track has been played`() = runTest { + createManager() + queue.setCurrentEntry( + streamingEntry( + id = "t1", + durationMs = 200_000, + externalUri = "https://open.spotify.com/track/t1") + ) + player.setPlayerState(PlayerState.PLAYING) + player.setDuration(200.seconds) + + playSeconds(99) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + + val scrobble = plugin.scrobbled.single() + assertEquals("t1", scrobble.trackId) + assertEquals("artist-t1", scrobble.artistId) + assertEquals("album-t1", scrobble.albumId) + assertEquals("Track t1", scrobble.trackName) + assertEquals("Artist t1", scrobble.artistName) + assertEquals("Album t1", scrobble.albumName) + assertEquals("spotify", scrobble.streamingProvider) + assertEquals(200_000L, scrobble.durationMs) + assertTrue(scrobble.timestamp > 0) + } + + @Test + fun `does not scrobble tracks shorter than 30 seconds`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "short", durationMs = 20_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + + assertEquals(0, plugin.scrobbled.size) + } + + @Test + fun `caps the listening threshold at four minutes`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "long", durationMs = 1_800_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(239) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `only scrobbles a track once per play`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + playSeconds(15) + + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `does not credit forward seeks`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 100_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(10) + currentPositionMs = 60_000 + player.setPosition(currentPositionMs.milliseconds) + + playSeconds(39) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `does not credit time while paused`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 100_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(30) + + player.setPlayerState(PlayerState.PAUSED) + currentPositionMs = 80_000 + player.setPosition(currentPositionMs.milliseconds) + + player.setPlayerState(PlayerState.PLAYING) + playSeconds(19) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `restarting the track allows scrobbling it again`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + assertEquals(1, plugin.scrobbled.size) + + // Repeat-one restarts playback from the beginning without a queue entry change. + currentPositionMs = 0 + player.setPosition(0.milliseconds) + playSeconds(20) + + assertEquals(2, plugin.scrobbled.size) + } + + @Test + fun `switching tracks starts a fresh scrobble session`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + assertEquals(1, plugin.scrobbled.size) + + queue.setCurrentEntry(streamingEntry(id = "t2", durationMs = 40_000)) + currentPositionMs = 0 + player.setPosition(0.milliseconds) + playSeconds(20) + + assertEquals(2, plugin.scrobbled.size) + assertEquals("t2", plugin.scrobbled.last().trackId) + } + + @Test + fun `waits for a scrobble plugin to be selected`() = runTest { + pluginSource.setPlugin(null) + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(25) + assertEquals(0, plugin.scrobbled.size) + + pluginSource.setPlugin(plugin) + playSeconds(1) + + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `scrobbles local tracks`() = runTest { + createManager() + queue.setCurrentEntry( + localEntry( + name = "Local Song", + url = "file:///music/local.mp3", + duration = 100_000) + ) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(50) + + val scrobble = plugin.scrobbled.single() + assertEquals("file:///music/local.mp3", scrobble.trackId) + assertEquals("Local Song", scrobble.trackName) + assertEquals("Local Artist", scrobble.artistName) + assertEquals("Local Album", scrobble.albumName) + assertEquals("local", scrobble.streamingProvider) + assertEquals(100_000L, scrobble.durationMs) + } + + @Test + fun `uses unknown provider when the track has no external uri`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000, externalUri = null)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + + assertEquals("unknown", plugin.scrobbled.single().streamingProvider) + } + + // --- fixtures --- + + private fun streamingTrack( + id: String, + durationMs: Long = 200_000, + externalUri: String? = null): MetadataTrack { + val artist = MetadataArtist.Basic( + id = "artist-$id", + name = "Artist $id", + thumbnails = emptyList(), + externalUri = null) + val album = MetadataAlbum.Detailed( + releaseDate = "2024", + genres = emptyList(), + trackCount = 1, + id = "album-$id", + title = "Album $id", + description = null, + thumbnails = emptyList(), + albumType = MetadataAlbumType.Album, + artists = listOf(artist), + externalUri = null) + return MetadataTrack( + id = id, + title = "Track $id", + durationMs = durationMs, + trackNumber = 1, + discNumber = 1, + artists = listOf(artist), + album = album, + thumbnails = null, + explicit = false, + popularity = null, + isrcCode = null, + externalUri = externalUri) + } + + private fun streamingEntry( + id: String, + durationMs: Long = 200_000, + externalUri: String? = null): QueueEntry.StreamingTrack { + return QueueEntry.StreamingTrack( + track = streamingTrack(id, durationMs, externalUri), + url = "http://127.0.0.1:14769/stream/$id") + } + + private fun localEntry( + name: String = "Local Track", + url: String = "file:///music/local.mp3", + duration: Long = 180_000): QueueEntry.LocalTrack { + return QueueEntry.LocalTrack( + name = name, + artists = listOf("Local Artist"), + duration = duration, + album = "Local Album", + coverBytes = null, + url = url) + } + + private class FakeQueue : AudioPlayerQueue { + private val queueState = MutableStateFlow>(emptyList()) + private val currentEntryState = MutableStateFlow(null) + + override val queueFlow: StateFlow> = queueState + override val currentQueueEntryFlow: StateFlow = currentEntryState + override val currentCollectionEntryFlow: StateFlow = + MutableStateFlow(null) + override val collectionHistoryFlow: StateFlow> = + MutableStateFlow(emptyList()) + + fun setCurrentEntry(entry: QueueEntry?) { + currentEntryState.value = entry + } + + override suspend fun load( + entries: List, + autoPlay: Boolean, + startPosition: Int, + collectionEntry: QueueCollectionEntry?) = error("not used") + + override suspend fun addToQueue(entry: QueueEntry) = error("not used") + override suspend fun addAllToQueue( + entries: List, + collectionEntry: QueueCollectionEntry?) = error("not used") + + override suspend fun addAllAfterCurrent(entries: List) = error("not used") + override suspend fun removeFromQueue(entry: QueueEntry) = error("not used") + override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = error("not used") + override suspend fun move(fromIndex: Int, toIndex: Int) = error("not used") + override suspend fun jumpTo(index: Int, autoPlay: Boolean) = error("not used") + override suspend fun reloadCurrent() = error("not used") + override suspend fun clear() = error("not used") + override suspend fun getQueue(): List = queueState.value + override suspend fun getCurrentQueueEntry(): QueueEntry? = currentEntryState.value + override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null + override suspend fun getCollectionHistory(): List = emptyList() + } + + private class FakeScrobblePluginSource( + initialPlugin: PluginService? = null) : ScrobblePluginSource { + private val selected = MutableStateFlow(initialPlugin) + override val selectedScrobblePlugin: StateFlow = selected + + fun setPlugin(service: PluginService?) { + selected.value = service + } + } + + private class FakeScrobblePluginService : PluginService { + val scrobbled = mutableListOf() + override val pluginId: String = "fake-scrobble-plugin" + override val loggedInFlow: StateFlow = MutableStateFlow(true) + + override suspend fun start() = Unit + override suspend fun stop() = Unit + + override suspend fun use(block: suspend PluginServiceScope.() -> T): T { + val api = object : ScrobbleAPI { + override suspend fun scrobble(track: ScrobbleTrack) { + scrobbled.add(track) + } + } + val scope = PluginServiceScope(mapOf(ScrobbleAPI::class to api)) + return scope.block() + } + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt new file mode 100644 index 00000000..f297b17a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -0,0 +1,630 @@ +package org.spotube.spotube.core.server + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.settings.UserSettings +import co.touchlab.kermit.Logger +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Behavioral / caching tests for [StreamingUrlRepository]: the in-memory stream-url cache, + * the matched-source lookup, and the interaction between the two when the active audio + * plugin changes. + * + * These specifically target the "wrong track plays" class of bug: a stale match or cached + * URL from one plugin (or one track) being silently reused for a different plugin/track. + */ +class StreamingUrlRepositoryBehaviorTest { + + @BeforeTest + fun setup() { + startKoin { + modules(module { + factory { (tag: String?) -> Logger.withTag(tag ?: "test") } + }) + } + } + + @AfterTest + fun teardown() { + stopKoin() + } + + private fun track(id: String, title: String = "Track $id") = MetadataTrack( + id = id, + title = title, + durationMs = 200_000, + trackNumber = 1, + discNumber = 1, + artists = emptyList(), + album = null, + thumbnails = null, + explicit = false, + popularity = null, + isrcCode = null, + externalUri = null) + + private fun basicSource(id: String, confidence: Float = 1f) = AudioSource.Basic( + id = id, + title = "source-$id", + artist = null, + album = null, + thumbnails = emptyList(), + externalUri = null, + confidence = confidence) + + private fun streamedSource( + sourceId: String, + streams: List, + confidence: Float = 1f) = AudioSource.Streamed( + id = sourceId, + title = "source-$sourceId", + artist = null, + album = null, + thumbnails = emptyList(), + externalUri = null, + confidence = confidence, + streams = streams) + + private fun stream(url: String, bitrate: Int = 256_000) = AudioStream.Lossy( + url = url, + codec = "opus", + container = "webm", + protocol = StreamProtocol.PROGRESSIVE, + bitrate = bitrate) + + private class Harness(cacheTtlMs: Long = 30_000) { + val queue = FakeStreamingAudioPlayerQueue() + val matchedTracksRepository = FakeTrackSourceRepository() + val settings = FakeUserSettingsSource() + val pluginSource = FakeAudioPluginSource() + val repository = StreamingUrlRepository( + pluginManager = pluginSource, + audioPlayerQueue = queue, + matchedTracksRepository = matchedTracksRepository, + settingsRepository = settings, + streamUrlCacheTtlMs = cacheTtlMs) + + fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService { + val service = FakeAudioPluginService(pluginId, audioAPI) + pluginSource.setPlugin(service) + return service + } + } + + // ---------- basic resolution ---------- + + @Test + fun `resolves and returns a stream via track lookup when nothing is cached`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + val info = h.repository.resolveStreamInfo(t) + + assertNotNull(info) + assertEquals("https://example.com/a.opus", info.url) + } + + @Test + fun `returns null when no audio plugin is selected`() = runTest { + val h = Harness() + val info = h.repository.resolveStreamInfo(track("t1")) + assertNull(info) + } + + @Test + fun `waits for an audio plugin that is still loading`() = runTest { + val h = Harness() + val loader = launch { + delay(5_000) + h.selectPlugin("pluginA").audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + } + + val info = h.repository.resolveStreamInfo(track("t1")) + loader.join() + + assertEquals("https://example.com/a.opus", info?.url) + } + + @Test + fun `returns null when the plugin yields no sources`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { emptyList() } + + assertNull(h.repository.resolveStreamInfo(track("t1"))) + } + + @Test + fun `resolveStreamInfo by trackId looks up the matching queue entry`() = runTest { + val h = Harness() + val t = track("t1") + h.queue.setQueue(listOf(QueueEntry.StreamingTrack(track = t, url = ""))) + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + val info = h.repository.resolveStreamInfo(trackId = "t1") + + assertNotNull(info) + assertEquals("https://example.com/a.opus", info.url) + } + + @Test + fun `resolveStreamInfo by trackId returns null when track is not queued`() = runTest { + val h = Harness() + h.selectPlugin("pluginA") + assertNull(h.repository.resolveStreamInfo(trackId = "not-queued")) + } + + // ---------- in-memory URL cache ---------- + + @Test + fun `second resolution for the same track reuses the cache instead of calling the plugin again`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + h.repository.resolveStreamInfo(t) + h.repository.resolveStreamInfo(t) + + assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `forceRefresh bypasses the cache and re-resolves`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + var counter = 0 + plugin.audioAPI.onGetStreamsByTrack = { + counter++ + listOf(streamedSource("src$counter", listOf(stream("https://example.com/$counter.opus")))) + } + + val first = h.repository.resolveStreamInfo(t) + val second = h.repository.resolveStreamInfo(t, forceRefresh = true) + + assertEquals("https://example.com/1.opus", first?.url) + assertEquals("https://example.com/2.opus", second?.url) + assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `changing the preferred format invalidates the cached url`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf( + streamedSource( + "src1", + listOf( + stream("https://example.com/opus.webm"), + AudioStream.Lossy( + url = "https://example.com/aac.mp4", + codec = "aac", + container = "mp4", + bitrate = 256_000)) + ) + ) + } + + val first = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/opus.webm", first?.url) + + h.settings.update { + copy(streamingMusicFormat = streamingMusicFormat.copy(codec = "aac", container = "mp4")) + } + + val second = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/aac.mp4", second?.url) + // Preference change forced a second real lookup rather than serving the stale URL. + assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `changing the preferred quality invalidates the cached url`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf( + streamedSource( + "src1", + listOf(stream("https://example.com/128.opus", 128_000), stream("https://example.com/320.opus", 320_000)) + ) + ) + } + + h.settings.update { copy(streamingMusicQuality = AudioQuality.Lossy(128_000)) } + val first = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/128.opus", first?.url) + + h.settings.update { copy(streamingMusicQuality = AudioQuality.Lossy(320_000)) } + val second = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/320.opus", second?.url) + } + + @Test + fun `cached url expires after the configured ttl`() = kotlinx.coroutines.runBlocking { + val h = Harness(cacheTtlMs = 10) + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + var counter = 0 + plugin.audioAPI.onGetStreamsByTrack = { + counter++ + listOf(streamedSource("src$counter", listOf(stream("https://example.com/$counter.opus")))) + } + + h.repository.resolveStreamInfo(t) + kotlinx.coroutines.delay(50) + h.repository.resolveStreamInfo(t) + + assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `invalidateCachedStreamUrl removes the cached entry unconditionally when no url is given`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + h.repository.resolveStreamInfo(t) + h.repository.invalidateCachedStreamUrl(t.id) + h.repository.resolveStreamInfo(t) + + assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `invalidateCachedStreamUrl with a mismatching url is a no-op`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + h.repository.resolveStreamInfo(t) + h.repository.invalidateCachedStreamUrl(t.id, url = "https://not-the-cached-url") + h.repository.resolveStreamInfo(t) + + // Cache entry survived because the supplied url didn't match; plugin isn't hit again. + assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount) + } + + // ---------- matched-source fast path ---------- + + @Test + fun `a previously matched source from the same plugin is reused via getStreamsOfAudioSource`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA") + plugin.audioAPI.onGetStreamsOfAudioSource = { + listOf(streamedSource(it.id, listOf(stream("https://example.com/matched.opus")))) + } + plugin.audioAPI.onGetStreamsByTrack = { + error("should not fall back to track lookup when a matched source resolves") + } + + val info = h.repository.resolveStreamInfo(t) + + assertEquals("https://example.com/matched.opus", info?.url) + assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount) + assertEquals(0, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `THE BUG - a source matched by a previous plugin is never handed to a newly selected plugin`() = runTest { + val h = Harness() + val t = track("t1") + + // The user matched this track while "pluginA" (e.g. a YouTube-backed plugin) was active. + // "yt-video-123" is meaningful *only* to pluginA. + h.matchedTracksRepository.seed(t, basicSource("yt-video-123"), pluginId = "pluginA") + + // The user then switches the active audio plugin to "pluginB" (a different backend). + val pluginB = h.selectPlugin("pluginB") + pluginB.audioAPI.onGetStreamsOfAudioSource = { + error( + "pluginB must never receive a source id matched by a different plugin " + + "(id=${it.id}); this is exactly how playback of the wrong track happens." + ) + } + pluginB.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("pluginB-native-source", listOf(stream("https://example.com/correct.opus")))) + } + + val info = h.repository.resolveStreamInfo(t) + + // pluginB never saw the stale "yt-video-123" id... + assertEquals(0, pluginB.audioAPI.getStreamsOfAudioSourceCallCount) + // ...and instead correctly fell back to a fresh, pluginB-native track lookup. + assertEquals(1, pluginB.audioAPI.getStreamsByTrackCallCount) + assertEquals("https://example.com/correct.opus", info?.url) + } + + @Test + fun `a cached stream url from a previous plugin is not served after switching plugins`() = runTest { + val h = Harness() + val t = track("t1") + + val pluginA = h.selectPlugin("pluginA") + pluginA.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("srcA", listOf(stream("https://example.com/pluginA.opus")))) + } + val first = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/pluginA.opus", first?.url) + + val pluginB = h.selectPlugin("pluginB") + pluginB.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("srcB", listOf(stream("https://example.com/pluginB.opus")))) + } + val second = h.repository.resolveStreamInfo(t) + + // Must re-resolve via pluginB rather than replaying pluginA's cached URL. + assertEquals("https://example.com/pluginB.opus", second?.url) + assertEquals(1, pluginB.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `matched source is saved and tagged with the resolving plugin id`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + h.repository.resolveStreamInfo(t) + + val saved = h.matchedTracksRepository.rawEntryFor(t.id) + assertNotNull(saved) + assertEquals("pluginA", saved.pluginId) + assertEquals("src1", saved.source.id) + } + + @Test + fun `matched source is looked up but ignored when the format preference has no matching stream`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA") + // Matched source resolves to a stream, so preferred-format mismatch is irrelevant here; + // this documents that selection still happens on top of the matched-source stream list. + plugin.audioAPI.onGetStreamsOfAudioSource = { + listOf(streamedSource(it.id, listOf(stream("https://example.com/only.opus")))) + } + + val info = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/only.opus", info?.url) + } + + @Test + fun `falls back to track lookup when the matched source resolves no streams`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA") + plugin.audioAPI.onGetStreamsOfAudioSource = { emptyList() } + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("fresh", listOf(stream("https://example.com/fresh.opus")))) + } + + val info = h.repository.resolveStreamInfo(t) + + assertEquals("https://example.com/fresh.opus", info?.url) + assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount) + assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `track lookup persists a Basic source by resolving its streams before caching`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { listOf(basicSource("basic-1")) } + plugin.audioAPI.onGetStreamsOfAudioSource = { + listOf(streamedSource(it.id, listOf(stream("https://example.com/resolved.opus")))) + } + + val info = h.repository.resolveStreamInfo(t) + + assertEquals("https://example.com/resolved.opus", info?.url) + assertEquals("basic-1", h.matchedTracksRepository.rawEntryFor(t.id)?.source?.id) + } + + @Test + fun `distinct tracks never share cache entries even with concurrent resolution`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { t -> + listOf(streamedSource("src-${t.id}", listOf(stream("https://example.com/${t.id}.opus")))) + } + + val t1 = track("t1") + val t2 = track("t2") + + val info1 = h.repository.resolveStreamInfo(t1) + val info2 = h.repository.resolveStreamInfo(t2) + + assertEquals("https://example.com/t1.opus", info1?.url) + assertEquals("https://example.com/t2.opus", info2?.url) + assertTrue(info1?.url != info2?.url) + } + + // ---------- alternatives cache ---------- + + @Test + fun `getCachedAlternatives returns null before anything is cached`() = runTest { + val h = Harness() + assertNull(h.repository.getCachedAlternatives("t1", "pluginA")) + } + + @Test + fun `alternatives resolved via track lookup are cached and retrievable`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + val candidates = listOf(basicSource("alt-1"), basicSource("alt-2")) + plugin.audioAPI.onGetStreamsByTrack = { candidates } + plugin.audioAPI.onGetStreamsOfAudioSource = { + listOf(streamedSource(it.id, listOf(stream("https://example.com/${it.id}.opus")))) + } + + h.repository.resolveStreamInfo(t) + + val cached = h.repository.getCachedAlternatives(t.id, "pluginA") + assertNotNull(cached) + assertEquals(2, cached.size) + } + + @Test + fun `alternatives cached by one plugin are not visible to a different plugin`() = runTest { + val h = Harness() + val t = track("t1") + val pluginA = h.selectPlugin("pluginA") + pluginA.audioAPI.onGetStreamsByTrack = { listOf(basicSource("alt-1")) } + pluginA.audioAPI.onGetStreamsOfAudioSource = { + listOf(streamedSource(it.id, listOf(stream("https://example.com/a.opus")))) + } + h.repository.resolveStreamInfo(t) + + assertNotNull(h.repository.getCachedAlternatives(t.id, "pluginA")) + assertNull(h.repository.getCachedAlternatives(t.id, "pluginB")) + } + + @Test + fun `cacheAlternatives followed by invalidateCachedAlternatives clears the entry`() = runTest { + val h = Harness() + h.repository.cacheAlternatives("t1", listOf(basicSource("alt-1")), "pluginA") + assertNotNull(h.repository.getCachedAlternatives("t1", "pluginA")) + + h.repository.invalidateCachedAlternatives("t1") + + assertNull(h.repository.getCachedAlternatives("t1", "pluginA")) + } + + // ---------- getCachedStreamUrlEntry ---------- + + @Test + fun `getCachedStreamUrlEntry returns null when nothing has been cached`() = runTest { + val h = Harness() + assertNull(h.repository.getCachedStreamUrlEntry("t1")) + } + + @Test + fun `getCachedStreamUrlEntry never fetches - it only reflects the existing cache`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + assertNull(h.repository.getCachedStreamUrlEntry(t.id)) + h.repository.resolveStreamInfo(t) + val entry = h.repository.getCachedStreamUrlEntry(t.id) + + assertNotNull(entry) + assertEquals("https://example.com/a.opus", entry.url) + assertEquals("pluginA", entry.pluginId) + // Fetching the entry doesn't call the plugin again. + assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount) + } + + // ---------- caller requests (downloads) ---------- + + private val aacRequest = StreamRequest( + format = org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat( + codec = "aac", container = "mp4", qualities = listOf(AudioQuality.Lossy(128_000))), + quality = AudioQuality.Lossy(128_000), + progressiveOnly = true) + + private fun aacStream(url: String, protocol: StreamProtocol = StreamProtocol.PROGRESSIVE) = AudioStream.Lossy( + url = url, codec = "aac", container = "mp4", protocol = protocol, bitrate = 128_000) + + @Test + fun `a request's format overrides the streaming settings`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"), aacStream("https://example.com/a.m4a")))) + } + + val info = h.repository.resolveStreamInfo(track("t1"), request = aacRequest) + + assertEquals("https://example.com/a.m4a", info?.url) + } + + @Test + fun `progressiveOnly never picks a manifest even when it matches best`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf( + aacStream("https://example.com/manifest.mpd", StreamProtocol.DASH), + stream("https://example.com/a.opus")))) + } + + val info = h.repository.resolveStreamInfo(track("t1"), request = aacRequest) + + assertEquals("https://example.com/a.opus", info?.url) + assertEquals(StreamProtocol.PROGRESSIVE, info?.protocol) + } + + @Test + fun `progressiveOnly returns null when only manifests exist`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(aacStream("https://example.com/manifest.m3u8", StreamProtocol.HLS)))) + } + + assertNull(h.repository.resolveStreamInfo(track("t1"), request = aacRequest)) + } + + @Test + fun `a request does not write the playback url cache`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(aacStream("https://example.com/a.m4a")))) + } + + h.repository.resolveStreamInfo(track("t1"), request = aacRequest) + + assertNull(h.repository.getCachedStreamUrlEntry("t1")) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt new file mode 100644 index 00000000..d8f1af5f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt @@ -0,0 +1,134 @@ +package org.spotube.spotube.core.server + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.zipline.PluginService +import org.spotube.spotube.core.zipline.PluginServiceScope +import org.spotube.spotube.modules.plugin.AudioPluginSource +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.UserSettingsSource +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** In-memory [AudioAPI] whose behaviour for each call is fully scripted by the test. */ +class FakeAudioAPI( + override val supportedQualities: List = emptyList(), + var onGetStreamsByTrack: suspend (MetadataTrack) -> List = { emptyList() }, + var onGetStreamsOfAudioSource: suspend (AudioSource.Basic) -> List = { emptyList() }) : AudioAPI { + var getStreamsByTrackCallCount = 0 + var getStreamsOfAudioSourceCallCount = 0 + val getStreamsOfAudioSourceCalls = mutableListOf() + + override suspend fun getStreamsByTrack(track: MetadataTrack): List { + getStreamsByTrackCallCount++ + return onGetStreamsByTrack(track) + } + + override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List { + getStreamsOfAudioSourceCallCount++ + getStreamsOfAudioSourceCalls.add(source) + return onGetStreamsOfAudioSource(source) + } +} + +/** Minimal [PluginService] that only ever exposes an [AudioAPI], identified by [pluginId]. */ +class FakeAudioPluginService( + override val pluginId: String, + val audioAPI: FakeAudioAPI = FakeAudioAPI()) : PluginService { + override val loggedInFlow: StateFlow = MutableStateFlow(true) + + override suspend fun start() = Unit + override suspend fun stop() = Unit + + override suspend fun use(block: suspend PluginServiceScope.() -> T): T { + val scope = PluginServiceScope(mapOf(AudioAPI::class to audioAPI)) + return scope.block() + } +} + +class FakeAudioPluginSource( + initialPlugin: PluginService? = null +) : AudioPluginSource { + private val _selectedAudioPlugin = MutableStateFlow(initialPlugin) + override val selectedAudioPlugin: StateFlow = _selectedAudioPlugin + + fun setPlugin(service: PluginService?) { + _selectedAudioPlugin.value = service + } +} + +/** In-memory [TrackSourceRepository], mirroring the plugin-scoped persistence contract of + * the real [MatchedTracksRepository] without touching disk. */ +class FakeTrackSourceRepository : TrackSourceRepository { + private val store = mutableMapOf() + var getCallCount = 0 + var saveCallCount = 0 + + override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? { + getCallCount++ + val tagged = store[track.id] ?: return null + if (tagged.pluginId != pluginId) return null + return tagged.source + } + + override suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) { + saveCallCount++ + store[track.id] = PluginTaggedAudioSource(pluginId, source) + } + + fun rawEntryFor(trackId: String): PluginTaggedAudioSource? = store[trackId] + + fun seed(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) { + store[track.id] = PluginTaggedAudioSource(pluginId, source) + } +} + +class FakeUserSettingsSource(initial: UserSettings = UserSettings()) : UserSettingsSource { + private val _userSettings = MutableStateFlow(initial) + override val userSettings: StateFlow = _userSettings + + fun update(transform: UserSettings.() -> UserSettings) { + _userSettings.value = _userSettings.value.transform() + } +} + +/** Bare-bones [AudioPlayerQueue] that only backs [queueFlow]; every other member is unused + * by [StreamingUrlRepository] and throws if accidentally exercised. */ +class FakeStreamingAudioPlayerQueue : AudioPlayerQueue { + private val _queueFlow = MutableStateFlow>(emptyList()) + override val queueFlow: StateFlow> = _queueFlow + override val currentQueueEntryFlow: StateFlow = MutableStateFlow(null) + override val currentCollectionEntryFlow: StateFlow = MutableStateFlow(null) + override val collectionHistoryFlow: StateFlow> = MutableStateFlow(emptyList()) + + fun setQueue(entries: List) { + _queueFlow.value = entries + } + + override suspend fun load( + entries: List, + autoPlay: Boolean, + startPosition: Int, + collectionEntry: QueueCollectionEntry?) = error("not used") + + override suspend fun addToQueue(entry: QueueEntry) = error("not used") + override suspend fun addAllToQueue(entries: List, collectionEntry: QueueCollectionEntry?) = + error("not used") + + override suspend fun addAllAfterCurrent(entries: List) = error("not used") + override suspend fun removeFromQueue(entry: QueueEntry) = error("not used") + override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = error("not used") + override suspend fun move(fromIndex: Int, toIndex: Int) = error("not used") + override suspend fun jumpTo(index: Int, autoPlay: Boolean) = error("not used") + override suspend fun reloadCurrent() = error("not used") + override suspend fun clear() = error("not used") + override suspend fun getQueue(): List = _queueFlow.value + override suspend fun getCurrentQueueEntry(): QueueEntry? = null + override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null + override suspend fun getCollectionHistory(): List = emptyList() +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt new file mode 100644 index 00000000..b5f68eb9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt @@ -0,0 +1,439 @@ +package org.spotube.spotube.core.server + +import org.spotube.spotube.PlatformType +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +/** + * Tests for the stream preference selection used by [StreamingUrlRepository]. + * + * Ordering contract (best first): + * 0. Playable by the platform's player (iOS: no WebM/Ogg containers, no DASH). + * 1. Fewest codec/container mismatches against the preferred format. + * 2. Matching quality family (lossy vs lossless). + * 3. Closest bitrate / sample rate. + * 4. Closest channel count (lossless only). + * 5. First occurrence wins remaining ties. + */ +class StreamingUrlRepositoryTest { + + // ---------- helpers ---------- + + private fun opusWebm() = AudioFormat(codec = "opus", container = "webm", qualities = emptyList()) + + private fun flac() = AudioFormat(codec = "flac", container = "flac", qualities = emptyList()) + + private fun lossy( + url: String, + bitrate: Int, + codec: String = "opus", + container: String = "webm", + protocol: StreamProtocol = StreamProtocol.PROGRESSIVE) = AudioStream.Lossy( + url = url, + codec = codec, + container = container, + protocol = protocol, + bitrate = bitrate) + + private fun lossless( + url: String, + sampleRate: Int, + channels: Int, + codec: String = "flac", + container: String = "flac") = AudioStream.Lossless( + url = url, + codec = codec, + container = container, + sampleRate = sampleRate, + channels = channels) + + private fun select( + streams: List, + format: AudioFormat = opusWebm(), + quality: AudioQuality = AudioQuality.Lossy(256_000), + platform: PlatformType = PlatformType.Linux) = selectPreferredAudioStream(streams, format, quality, platform) + + // ---------- empty / single ---------- + + @Test + fun `returns null when no streams are available`() { + assertNull(select(emptyList())) + } + + @Test + fun `returns the only stream when exactly one is available`() { + val only = lossy("opus-128", 128_000) + assertSame(only, select(listOf(only))) + } + + @Test + fun `does not mutate the input list`() { + val streams = mutableListOf( + lossy("opus-44", 44_000), + lossy("opus-256", 256_000)) + val snapshot = streams.toList() + + select(streams) + + assertEquals(snapshot, streams) + } + + // ---------- format priority ---------- + + @Test + fun `prefers an exact codec and container match over a closer quality in another format`() { + val otherFormat = lossy("aac-256", 256_000, codec = "aac", container = "mp4") + val preferredFormat = lossy("opus-128", 128_000) + + val result = select( + streams = listOf(otherFormat, preferredFormat), + quality = AudioQuality.Lossy(256_000)) + + assertSame(preferredFormat, result) + } + + @Test + fun `format matching ignores codec and container case`() { + val exact = lossy("opus-128", 128_000, codec = "OpUs", container = "WeBm") + val mismatch = lossy("aac-128", 128_000, codec = "aac", container = "mp4") + + val result = select( + streams = listOf(mismatch, exact), + format = AudioFormat(codec = "OPUS", container = "WEBM", qualities = emptyList())) + + assertSame(exact, result) + } + + @Test + fun `a partial format match beats a full format mismatch regardless of bitrate`() { + val partial = lossy("opus-mp4-320", 320_000, container = "mp4") + val fullMismatch = lossy("aac-mp4-44", 44_000, codec = "aac", container = "mp4") + + val result = select( + streams = listOf(fullMismatch, partial), + quality = AudioQuality.Lossy(44_000)) + + assertSame(partial, result) + } + + @Test + fun `a one-field match beats a two-field mismatch`() { + val oneField = lossy("opus-ogg", 128_000, container = "ogg") + val twoFields = lossy("aac-mp4", 128_000, codec = "aac", container = "mp4") + + val result = select(streams = listOf(twoFields, oneField)) + + assertSame(oneField, result) + } + + @Test + fun `codec-only and container-only matches tie on format and are resolved by quality`() { + val codecOnly = lossy("opus-mp4-320", 320_000, container = "mp4") + val containerOnly = lossy("aac-webm-44", 44_000, codec = "aac", container = "webm") + + val result = select( + streams = listOf(codecOnly, containerOnly), + quality = AudioQuality.Lossy(44_000)) + + assertSame(containerOnly, result) + } + + @Test + fun `when nothing matches the format the closest quality is chosen`() { + val low = lossy("aac-128", 128_000, codec = "aac", container = "mp4") + val target = lossy("aac-256", 256_000, codec = "aac", container = "mp4") + val high = lossy("aac-320", 320_000, codec = "aac", container = "mp4") + + val result = select( + streams = listOf(low, target, high), + quality = AudioQuality.Lossy(256_000)) + + assertSame(target, result) + } + + @Test + fun `format match is prioritized over matching the quality family`() { + // Same format but wrong family vs. wrong format but correct family. + val sameFormatWrongFamily = lossless("opus-webm-flac", 48_000, 2, codec = "opus", container = "webm") + val wrongFormatRightFamily = lossy("aac-44", 44_000, codec = "aac", container = "mp4") + + val result = select( + streams = listOf(wrongFormatRightFamily, sameFormatWrongFamily), + quality = AudioQuality.Lossy(44_000)) + + assertSame(sameFormatWrongFamily, result) + } + + // ---------- lossy quality ---------- + + @Test + fun `exact lossy bitrate wins`() { + val low = lossy("opus-44", 44_000) + val exact = lossy("opus-128", 128_000) + val high = lossy("opus-256", 256_000) + + val result = select( + streams = listOf(low, exact, high), + quality = AudioQuality.Lossy(128_000)) + + assertSame(exact, result) + } + + @Test + fun `closest lower lossy bitrate wins`() { + val lower = lossy("opus-128", 128_000) + val higher = lossy("opus-320", 320_000) + + val result = select( + streams = listOf(higher, lower), + quality = AudioQuality.Lossy(200_000)) + + assertSame(lower, result) + } + + @Test + fun `closest higher lossy bitrate wins`() { + val lower = lossy("opus-44", 44_000) + val higher = lossy("opus-128", 128_000) + + val result = select( + streams = listOf(lower, higher), + quality = AudioQuality.Lossy(100_000)) + + assertSame(higher, result) + } + + @Test + fun `equidistant lossy bitrates pick the first occurrence`() { + val below = lossy("opus-96", 96_000) + val above = lossy("opus-104", 104_000) + + assertSame(below, select(listOf(below, above), quality = AudioQuality.Lossy(100_000))) + assertSame(above, select(listOf(above, below), quality = AudioQuality.Lossy(100_000))) + } + + @Test + fun `lossy preference prefers a lossy stream over lossless in the same format`() { + val losslessStream = lossless("opus-webm-lossless", 48_000, 2, codec = "opus", container = "webm") + val lossyStream = lossy("opus-256", 256_000) + + val result = select( + streams = listOf(losslessStream, lossyStream), + quality = AudioQuality.Lossy(128_000)) + + assertSame(lossyStream, result) + } + + @Test + fun `lossy distance does not overflow for extreme bitrates`() { + val min = lossy("opus-min", Int.MIN_VALUE) + val max = lossy("opus-max", Int.MAX_VALUE) + + val result = select( + streams = listOf(min, max), + quality = AudioQuality.Lossy(Int.MAX_VALUE)) + + assertSame(max, result) + } + + // ---------- lossless quality ---------- + + @Test + fun `exact lossless sample rate and channels wins`() { + val otherRate = lossless("flac-44", 44_100, 2) + val mono = lossless("flac-48-mono", 48_000, 1) + val exact = lossless("flac-48-stereo", 48_000, 2) + + val result = select( + streams = listOf(otherRate, mono, exact), + format = flac(), + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) + + assertSame(exact, result) + } + + @Test + fun `closest lossless sample rate wins`() { + val near = lossless("flac-44", 44_100, 2) + val far = lossless("flac-96", 96_000, 2) + + val result = select( + streams = listOf(far, near), + format = flac(), + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) + + assertSame(near, result) + } + + @Test + fun `lossless sample rate tie is broken by channel count`() { + val mono = lossless("flac-44-mono", 44_100, 1) + val stereo = lossless("flac-44-stereo", 44_100, 2) + + val result = select( + streams = listOf(mono, stereo), + format = flac(), + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) + + assertSame(stereo, result) + } + + @Test + fun `lossless preference prefers a lossless stream over lossy in the same format`() { + val lossyStream = lossy("flac-flac-lossy", 128_000, codec = "flac", container = "flac") + val losslessStream = lossless("flac-44", 44_100, 2) + + val result = select( + streams = listOf(lossyStream, losslessStream), + format = flac(), + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) + + assertSame(losslessStream, result) + } + + // ---------- quality-family fallbacks ---------- + + @Test + fun `lossy preference with only lossless streams still returns the first stream`() { + val first = lossless("flac-44", 44_100, 2) + val second = lossless("flac-48", 48_000, 2) + + val result = select( + streams = listOf(first, second), + format = flac(), + quality = AudioQuality.Lossy(256_000)) + + assertSame(first, result) + } + + @Test + fun `lossless preference with only lossy streams still returns the first stream`() { + val first = lossy("opus-128", 128_000) + val second = lossy("opus-320", 320_000) + + val result = select( + streams = listOf(first, second), + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) + + assertSame(first, result) + } + + // ---------- degenerate / orthogonal inputs ---------- + + @Test + fun `empty preferred codec and container fall back to quality`() { + val low = lossy("opus-44", 44_000) + val target = lossy("opus-256", 256_000) + + val result = select( + streams = listOf(low, target), + format = AudioFormat(codec = "", container = "", qualities = emptyList()), + quality = AudioQuality.Lossy(256_000)) + + assertSame(target, result) + } + + @Test + fun `protocol does not influence selection`() { + val progressive = lossy("a", 128_000, protocol = StreamProtocol.PROGRESSIVE) + val dash = lossy("b", 128_000, protocol = StreamProtocol.DASH) + val hls = lossy("c", 128_000, protocol = StreamProtocol.HLS) + + val result = select(streams = listOf(progressive, dash, hls)) + + assertSame(progressive, result) + } + + @Test + fun `selection is stable across repeated calls`() { + val streams = listOf( + lossy("opus-44", 44_000), + lossy("opus-128", 128_000), + lossy("opus-256", 256_000)) + + val first = select(streams, quality = AudioQuality.Lossy(100_000)) + val second = select(streams, quality = AudioQuality.Lossy(100_000)) + + assertSame(first, second) + } + + // ---------- normalizeManifestUrl ---------- + + @Test + fun `absolute http urls are returned unchanged`() { + val url = "http://example.com/audio.mp3" + assertEquals(url, normalizeManifestUrl(url, StreamProtocol.PROGRESSIVE)) + } + + @Test + fun `absolute https urls are returned unchanged for every protocol`() { + val url = "https://example.com/manifest.m3u8" + assertEquals(url, normalizeManifestUrl(url, StreamProtocol.HLS)) + assertEquals(url, normalizeManifestUrl(url, StreamProtocol.DASH)) + assertEquals(url, normalizeManifestUrl(url, StreamProtocol.PROGRESSIVE)) + } + + @Test + fun `relative hls manifest paths are resolved against the youtube origin`() { + assertEquals( + "https://www.youtube.com/watch?v=abc", + normalizeManifestUrl("/watch?v=abc", StreamProtocol.HLS)) + } + + @Test + fun `relative dash manifest paths are resolved against the youtube origin`() { + assertEquals( + "https://www.youtube.com/watch?v=abc", + normalizeManifestUrl("/watch?v=abc", StreamProtocol.DASH)) + } + + @Test + fun `relative progressive urls are returned unchanged`() { + assertEquals( + "/watch?v=abc", + normalizeManifestUrl("/watch?v=abc", StreamProtocol.PROGRESSIVE)) + } + + @Test + fun `empty url is handled without throwing`() { + assertEquals("", normalizeManifestUrl("", StreamProtocol.PROGRESSIVE)) + assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.DASH)) + assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS)) + } + + // ---------- device playability ---------- + + @Test + fun `on iOS a playable m4a stream beats the preferred but unplayable opus webm`() { + val opus = lossy("opus-160", 160_000) + val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") + assertSame(aac, select(listOf(opus, aac), platform = PlatformType.IOS)) + } + + @Test + fun `on iOS dash manifests are skipped in favour of progressive streams`() { + val dash = lossy("dash", 256_000, codec = "mp4a.40.2", container = "m4a", protocol = StreamProtocol.DASH) + val progressive = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") + assertSame(progressive, select(listOf(dash, progressive), platform = PlatformType.IOS)) + } + + @Test + fun `on iOS an unplayable stream is still returned when nothing else exists`() { + val opus = lossy("opus-160", 160_000) + assertSame(opus, select(listOf(opus), platform = PlatformType.IOS)) + } + + @Test + fun `off iOS the preferred opus webm stream still wins`() { + val opus = lossy("opus-160", 160_000) + val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") + assertSame(opus, select(listOf(opus, aac), platform = PlatformType.Android)) + } + +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DESTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DESTest.kt new file mode 100644 index 00000000..088f642b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DESTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline.host_apis.additionals + +import org.spotube.plugin_interfaces.host_apis.PaddingTypes +import org.spotube.plugin_interfaces.host_apis.SymmetricModes +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DESTest { + + @Test + fun `encrypt and decrypt roundtrip with 8-byte key`() = runTest { + val key = "12345678".encodeToByteArray() + val plaintext = "Hello!!!".encodeToByteArray() + + val encrypted = DES.encrypt(plaintext, key, SymmetricModes.ECB, PaddingTypes.PKCS7) + val decrypted = DES.decrypt(encrypted, key, SymmetricModes.ECB, PaddingTypes.PKCS7) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun `encrypt and decrypt roundtrip with longer plaintext`() = runTest { + val key = "38346591".encodeToByteArray() + val plaintext = "This is a longer message that spans multiple blocks!".encodeToByteArray() + + val encrypted = DES.encrypt(plaintext, key, SymmetricModes.ECB, PaddingTypes.PKCS7) + val decrypted = DES.decrypt(encrypted, key, SymmetricModes.ECB, PaddingTypes.PKCS7) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun `encrypt and decrypt roundtrip with exact 8-byte plaintext`() = runTest { + val key = "testkey1".encodeToByteArray() + val plaintext = "12345678".encodeToByteArray() + + val encrypted = DES.encrypt(plaintext, key, SymmetricModes.ECB, PaddingTypes.PKCS7) + assertEquals(16, encrypted.size) + val decrypted = DES.decrypt(encrypted, key, SymmetricModes.ECB, PaddingTypes.PKCS7) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun `encrypt and decrypt with no padding`() = runTest { + val key = "12345678".encodeToByteArray() + val plaintext = "12345678".encodeToByteArray() + + val encrypted = DES.encrypt(plaintext, key, SymmetricModes.ECB, PaddingTypes.NONE) + val decrypted = DES.decrypt(encrypted, key, SymmetricModes.ECB, PaddingTypes.NONE) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun `decrypt with string key 38346591`() = runTest { + val key = "38346591".encodeToByteArray() + assertEquals(8, key.size) + + val plaintext = "SecretData".encodeToByteArray() + val encrypted = DES.encrypt(plaintext, key, SymmetricModes.ECB, PaddingTypes.PKCS7) + val decrypted = DES.decrypt(encrypted, key, SymmetricModes.ECB, PaddingTypes.PKCS7) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun `key must be exactly 8 bytes`() = runTest { + val shortKey = "1234567".encodeToByteArray() + val data = "testdata".encodeToByteArray() + + try { + DES.encrypt(data, shortKey) + throw AssertionError("Should have thrown IllegalArgumentException") + } catch (e: IllegalArgumentException) { + assertEquals("DES key must be exactly 8 bytes (64 bits)", e.message) + } + } + + @Test + fun `ciphertext must be multiple of 8 bytes`() = runTest { + val key = "12345678".encodeToByteArray() + val badCiphertext = byteArrayOf(1, 2, 3) + + try { + DES.decrypt(badCiphertext, key) + throw AssertionError("Should have thrown IllegalArgumentException") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("multiple of 8")) + } + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt new file mode 100644 index 00000000..cc35c849 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.spotube.PlatformType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class StreamingFormatDefaultsTest { + + private val opusWebm = AudioFormat(codec = "opus", container = "webm", qualities = emptyList()) + + @Test + fun `ios defaults to aac in mp4`() { + val format = defaultStreamingMusicFormat(PlatformType.IOS) + assertEquals("aac" to "mp4", format.codec to format.container) + } + + @Test + fun `other platforms keep opus in webm`() { + val format = defaultStreamingMusicFormat(PlatformType.Android) + assertEquals("opus" to "webm", format.codec to format.container) + } + + @Test + fun `saved opus webm is moved to aac on ios`() { + val migrated = UserSettings(streamingMusicFormat = opusWebm).withPlayableStreamingFormat(PlatformType.IOS) + assertEquals("mp4", migrated.streamingMusicFormat.container) + } + + @Test + fun `saved opus webm is kept off ios`() { + val settings = UserSettings(streamingMusicFormat = opusWebm) + assertSame(settings, settings.withPlayableStreamingFormat(PlatformType.Linux)) + } +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt new file mode 100644 index 00000000..e3b722f6 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.window.ComposeUIViewController +import com.yushosei.newpipe.extractor.NewPipe +import com.yushosei.newpipe.util.DefaultDownloaderImpl +import org.spotube.spotube.core.di.initKoin +import org.spotube.spotube.core.webview.SafariUserAgent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +fun MainViewController() = ComposeUIViewController(configure = { + initKoin() + SafariUserAgent.prime() +}) { InitNewPipe { App() } } + +@Composable +fun InitNewPipe( + content: @Composable () -> Unit +) { + var isInitialized by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { + NewPipe.init(DefaultDownloaderImpl.initDefault()) + isInitialized = true + } + + if (isInitialized) { + content() + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/Platform.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/Platform.ios.kt new file mode 100644 index 00000000..7f8ca2c3 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/Platform.ios.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube + +import platform.Foundation.NSURL +import platform.UIKit.UIApplication +import platform.UIKit.UIDevice + +class IOSPlatform: Platform { + override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion + override val type: PlatformType = PlatformType.IOS +} + +actual fun getPlatform(): Platform = IOSPlatform() + +actual fun openUrlInBrowser(url: String) { + val nsUrl = NSURL.URLWithString(url) ?: return + UIApplication.sharedApplication.openURL(nsUrl) +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt new file mode 100644 index 00000000..3c99747b --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt @@ -0,0 +1,348 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@file:OptIn(ExperimentalForeignApi::class) + +package org.spotube.spotube.core.audioplayer + +import kotlinx.cinterop.CValue +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import platform.AVFoundation.AVPlayer +import platform.AVFoundation.AVPlayerItem +import platform.AVFoundation.AVPlayerTimeControlStatus +import platform.AVFoundation.AVPlayerTimeControlStatusPaused +import platform.AVFoundation.AVPlayerTimeControlStatusPlaying +import platform.AVFoundation.AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate +import platform.AVFoundation.addPeriodicTimeObserverForInterval +import platform.AVFoundation.currentItem +import platform.AVFoundation.duration +import platform.AVFoundation.loadedTimeRanges +import platform.AVFoundation.pause +import platform.AVFoundation.play +import platform.AVFoundation.rate +import platform.AVFoundation.replaceCurrentItemWithPlayerItem +import platform.AVFoundation.seekToTime +import platform.AVFoundation.timeControlStatus +import platform.AVFoundation.volume +import platform.CoreMedia.CMTime +import platform.CoreMedia.CMTimeGetSeconds +import platform.Foundation.NSURL +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface { + + actual val context: Any = context + + private val avPlayer: AVPlayer = AVPlayer() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private var disposed = false + + private val currentPlaylist = mutableListOf() + private val urlIndexMap = mutableMapOf() + + private val _playerState = MutableStateFlow(PlayerState.IDLE) + private val _currentMediaItem = MutableStateFlow(null) + private val _playlist = MutableStateFlow>(emptyList()) + private val _duration = MutableStateFlow(Duration.ZERO) + private val _position = MutableStateFlow(Duration.ZERO) + private val _bufferingPosition = MutableStateFlow(Duration.ZERO) + private val _loopState = MutableStateFlow(LoopState.NONE) + private val _shuffleMode = MutableStateFlow(false) + private val _playbackSpeed = MutableStateFlow(1.0f) + private val _volume = MutableStateFlow(1.0f) + private val _completion = MutableSharedFlow(extraBufferCapacity = 1) + private val _error = MutableSharedFlow(extraBufferCapacity = 1) + + actual override val playerStateFlow: StateFlow = _playerState.asStateFlow() + actual override val currentMediaItemFlow: StateFlow = _currentMediaItem.asStateFlow() + actual override val playlistFlow: StateFlow> = _playlist.asStateFlow() + actual override val durationFlow: StateFlow = _duration.asStateFlow() + actual override val positionFlow: StateFlow = _position.asStateFlow() + actual override val bufferingPositionFlow: StateFlow = _bufferingPosition.asStateFlow() + actual override val loopStateFlow: StateFlow = _loopState.asStateFlow() + actual override val shuffleModeFlow: StateFlow = _shuffleMode.asStateFlow() + actual override val playbackSpeedFlow: StateFlow = _playbackSpeed.asStateFlow() + actual override val volumeFlow: StateFlow = _volume.asStateFlow() + actual override val completionFlow: Flow = _completion.asSharedFlow() + actual override val errorFlow: Flow = _error.asSharedFlow() + + private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null + + init { + avPlayer.addPeriodicTimeObserverForInterval( + interval = CMTimeMake(1, 4), + queue = null + ) { time -> + if (!disposed) { + val seconds = CMTimeGetSeconds(time) + _position.tryEmit(seconds.seconds) + + avPlayer.currentItem?.let { item -> + val durationSeconds = CMTimeGetSeconds(item.duration) + if (durationSeconds > 0) { + _duration.tryEmit(durationSeconds.seconds) + } + val loadedRanges = item.loadedTimeRanges +// if (loadedRanges.count() > 0u) { +// val range = loadedRanges.objectAtIndex(0u) +// } + } + } + } + + scope.launch { + while (!disposed) { + syncTimeControlStatus() + delay(500) + } + } + } + + private fun syncTimeControlStatus() { + val status = avPlayer.timeControlStatus + if (status == lastTimeControlStatus) return + lastTimeControlStatus = status + + val playerState = when (status) { + AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING + AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED + AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING + else -> PlayerState.IDLE + } + _playerState.tryEmit(playerState) + + if (avPlayer.currentItem == null) { + _playerState.tryEmit(PlayerState.IDLE) + } + } + + private fun resolveState(): PlayerState { + return when (avPlayer.timeControlStatus) { + AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING + AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED + AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING + else -> PlayerState.IDLE + } + } + + private fun buildAVPlayerItem(url: String): AVPlayerItem? { + val nsUrl = NSURL.URLWithString(url) ?: return null + return AVPlayerItem(nsUrl) + } + + actual override suspend fun play() { + avPlayer.play() + } + + actual override suspend fun pause() { + avPlayer.pause() + } + + actual override suspend fun stop() { + avPlayer.pause() + avPlayer.seekToTime(CMTimeMake(0, 1)) + _playerState.tryEmit(PlayerState.IDLE) + } + + actual override suspend fun seekTo(position: Duration) { + val seconds = position.inWholeMilliseconds / 1000.0 + val cmTime = CMTimeMakeWithSeconds(seconds, 1000) + avPlayer.seekToTime(cmTime) + _position.tryEmit(position) + } + + actual override suspend fun loop(state: LoopState) { + _loopState.tryEmit(state) + } + + actual override suspend fun shuffle(enabled: Boolean) { + _shuffleMode.tryEmit(enabled) + } + + actual override suspend fun load( + playlist: List, + autoPlay: Boolean, + startPosition: Int + ) { + currentPlaylist.clear() + currentPlaylist.addAll(playlist) + urlIndexMap.clear() + playlist.forEachIndexed { index, item -> + urlIndexMap[item.url] = index + } + _playlist.tryEmit(currentPlaylist.toList()) + + if (playlist.isEmpty()) { + _playerState.tryEmit(PlayerState.IDLE) + _currentMediaItem.tryEmit(null) + _position.tryEmit(Duration.ZERO) + _duration.tryEmit(Duration.ZERO) + return + } + + val safeIndex = startPosition.coerceIn(0, playlist.lastIndex) + val item = playlist[safeIndex] + val avItem = buildAVPlayerItem(item.url) + + if (avItem == null) { + _error.tryEmit(IllegalArgumentException("Invalid URL: ${item.url}")) + _playerState.tryEmit(PlayerState.IDLE) + return + } + + avPlayer.replaceCurrentItemWithPlayerItem(avItem) + _currentMediaItem.tryEmit(item) + + if (autoPlay) { + avPlayer.play() + } + } + + actual override suspend fun addMediaItem(mediaItem: MediaItem) { + currentPlaylist.add(mediaItem) + urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex + _playlist.tryEmit(currentPlaylist.toList()) + } + + actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) { + val currentIndex = currentPlaylist.indexOfFirst { + it.url == _currentMediaItem.value?.url + } + val insertIndex = if (currentIndex >= 0) currentIndex + 1 else currentPlaylist.size + currentPlaylist.add(insertIndex, mediaItem) + urlIndexMap.clear() + currentPlaylist.forEachIndexed { index, item -> + urlIndexMap[item.url] = index + } + _playlist.tryEmit(currentPlaylist.toList()) + } + + actual override suspend fun removeMediaItem(mediaItem: MediaItem) { + val index = urlIndexMap[mediaItem.url] ?: return + currentPlaylist.removeAt(index) + urlIndexMap.clear() + currentPlaylist.forEachIndexed { i, item -> + urlIndexMap[item.url] = i + } + _playlist.tryEmit(currentPlaylist.toList()) + } + + actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) { + if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return + val item = currentPlaylist.removeAt(fromIndex) + currentPlaylist.add(toIndex, item) + urlIndexMap.clear() + currentPlaylist.forEachIndexed { i, it -> + urlIndexMap[it.url] = i + } + _playlist.tryEmit(currentPlaylist.toList()) + } + + actual override suspend fun skipToNext() { + val currentIndex = currentPlaylist.indexOfFirst { + it.url == _currentMediaItem.value?.url + } + val nextIndex = currentIndex + 1 + if (nextIndex < currentPlaylist.size) { + val nextItem = currentPlaylist[nextIndex] + val avItem = buildAVPlayerItem(nextItem.url) + if (avItem != null) { + avPlayer.replaceCurrentItemWithPlayerItem(avItem) + _currentMediaItem.tryEmit(nextItem) + avPlayer.play() + } + } + } + + actual override suspend fun skipToPrevious() { + val currentIndex = currentPlaylist.indexOfFirst { + it.url == _currentMediaItem.value?.url + } + val prevIndex = currentIndex - 1 + if (prevIndex >= 0) { + val prevItem = currentPlaylist[prevIndex] + val avItem = buildAVPlayerItem(prevItem.url) + if (avItem != null) { + avPlayer.replaceCurrentItemWithPlayerItem(avItem) + _currentMediaItem.tryEmit(prevItem) + avPlayer.play() + } + } + } + + actual override suspend fun jumpTo(index: Int) { + if (index in currentPlaylist.indices) { + val item = currentPlaylist[index] + val avItem = buildAVPlayerItem(item.url) + if (avItem != null) { + avPlayer.replaceCurrentItemWithPlayerItem(avItem) + _currentMediaItem.tryEmit(item) + } + } + } + + actual override suspend fun setVolume(volume: Float) { + val clamped = volume.coerceIn(0f, 1f) + avPlayer.volume = clamped + _volume.tryEmit(clamped) + } + + actual override suspend fun setPlaybackSpeed(speed: Float) { + val clamped = speed.coerceIn(0.25f, 4f) + avPlayer.rate = clamped + _playbackSpeed.tryEmit(clamped) + } + + actual override fun isDisposed(): Boolean = disposed + + actual override fun dispose() { + disposed = true + avPlayer.pause() + avPlayer.replaceCurrentItemWithPlayerItem(null) + currentPlaylist.clear() + urlIndexMap.clear() + + _playerState.tryEmit(PlayerState.IDLE) + _currentMediaItem.tryEmit(null) + _playlist.tryEmit(emptyList()) + _duration.tryEmit(Duration.ZERO) + _position.tryEmit(Duration.ZERO) + _bufferingPosition.tryEmit(Duration.ZERO) + } +} + +private fun CMTimeMake(value: Long, timescale: Int): CValue { + return platform.CoreMedia.CMTimeMake(value, timescale) +} + +private fun CMTimeMakeWithSeconds(seconds: Double, preferredTimescale: Int): CValue { + return platform.CoreMedia.CMTimeMakeWithSeconds(seconds, preferredTimescale) +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/di/Modules.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/di/Modules.ios.kt new file mode 100644 index 00000000..412db38c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/di/Modules.ios.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.di + +import org.spotube.spotube.core.audioplayer.AudioPlayer +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.share.IosShareService +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.webview.WebViewController +import org.spotube.spotube.modules.library.local_tracks.media.IosLocalMediaDiscoveryService +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +actual val platformModules = module { + singleOf(::Paths) + singleOf(::WebViewController) + single { AudioPlayer(Unit) } + single { IosLocalMediaDiscoveryService() } + single { IosShareService() } +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.ios.kt new file mode 100644 index 00000000..2f73a824 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.ios.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.discovery + +// The OS's own service-discovery stack advertises on the right network. +internal actual fun preferredAdvertiseAddress(): String? = null diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.ios.kt new file mode 100644 index 00000000..17a4a805 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.ios.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.discovery + +import androidx.compose.runtime.Composable + +/** No runtime local-network permission needed on iOS. */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.ios.kt new file mode 100644 index 00000000..4ac27df9 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.ios.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.i18n + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.staticCompositionLocalOf +import platform.Foundation.NSUserDefaults + +// Compose (and Compose resources) read NSLocale.preferredLanguages, which honours the +// app-level AppleLanguages default. +private const val APPLE_LANGUAGES_KEY = "AppleLanguages" +private const val SYSTEM_LANGUAGE = "system" + +private val LocalAppLocale = staticCompositionLocalOf { SYSTEM_LANGUAGE } + +@Composable +internal actual fun provideAppLocale(languageTag: String?): ProvidedValue<*> { + val defaults = NSUserDefaults.standardUserDefaults + if (languageTag == null) { + defaults.removeObjectForKey(APPLE_LANGUAGES_KEY) + } else { + defaults.setObject(listOf(languageTag), APPLE_LANGUAGES_KEY) + } + return LocalAppLocale provides (languageTag ?: SYSTEM_LANGUAGE) +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt new file mode 100644 index 00000000..95eb42c1 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.newpipe + +import com.yushosei.newpipe.extractor.ServiceList.YouTube +import com.yushosei.newpipe.extractor.stream.AudioStream +import com.yushosei.newpipe.extractor.stream.StreamInfoItem +import com.yushosei.newpipe.extractor.youtube.linkHandler.YoutubeSearchQueryHandlerFactory +import io.ktor.http.Url +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext + +/** NewPipe reports durations in seconds (-1 when unknown). */ +private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0 + +actual class NewPipeService { + actual suspend fun searchVideos(query: String, kind: SearchKind): List { + return withContext(Dispatchers.IO) { + val extractor = YouTube.getSearchExtractor( + query, + mutableListOf( + when (kind) { + SearchKind.Songs -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS + SearchKind.Videos -> YoutubeSearchQueryHandlerFactory.VIDEOS + }), + "" + ) + extractor.fetchPage() + val results = extractor.initialPage + + results.items.mapNotNull { item -> + if (item is StreamInfoItem) { + VideoSearchResult( + title = item.name, + url = item.url, + uploader = item.uploaderName ?: "Unknown", + durationMs = secondsToMs(item.duration), + thumbnailUrl = item.thumbnails.first().url, + id = Url(item.url).parameters["v"] + ?: throw IllegalArgumentException("Invalid YouTube URL: ${item.url}") + ) + } else { + null + } + } + } + } + + actual suspend fun getVideoInfo(id: String): VideoInfo { + return withContext(Dispatchers.IO) { + val extractor = YouTube.getStreamExtractor("https://www.youtube.com/watch?v=$id") + extractor.fetchPage() + VideoInfo( + title = extractor.name ?: "Unknown", + url = extractor.url ?: "https://www.youtube.com/watch?v=$id", + uploader = extractor.uploaderName, + durationMs = secondsToMs(extractor.length), + thumbnailUrl = extractor.thumbnails.first().url, + audioStreams = extractor.audioStreams() + .filter { stream -> stream.isUrl() && stream.format != null } + .map { stream -> + org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream.Lossy( + url = stream.content, + codec = normalizeCodec(stream.codec ?: "unknown"), + bitrate = stream.bitrate, + container = normalizeContainer(stream.format?.suffix ?: "unknown")) + }, + videoStreams = listOf(), // Not yet supported + id = id + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/paths/Paths.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/paths/Paths.ios.kt new file mode 100644 index 00000000..662d3467 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/paths/Paths.ios.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.paths + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSCachesDirectory +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSDownloadsDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +@OptIn(ExperimentalForeignApi::class) +actual class Paths { + actual fun getApplicationCacheDirPath(): String { + // Return ios cache dir + val cacheDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory( + directory = NSCachesDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null) + + return requireNotNull(cacheDirectory?.path) + } + + actual fun getApplicationDataDirPath(): String { + // Return ios document dir + val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory( + directory = NSDocumentDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null) + + return requireNotNull(documentDirectory?.path) + } + + actual fun getUserDownloadsDirPath(): String { + val downloadsDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory( + directory = NSDownloadsDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null) + + return (downloadsDirectory?.path ?: getApplicationDataDirPath()) + "/Spotube" + } + + actual fun getMusicCacheDirPath(): String { + return getApplicationCacheDirPath() + "/music_cache" + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/share/IosShareService.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/share/IosShareService.kt new file mode 100644 index 00000000..6499c550 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/share/IosShareService.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.share + +import org.spotube.spotube.core.share.ShareService +import platform.Foundation.NSURL +import platform.UIKit.UIActivityViewController +import platform.UIKit.UIApplication + +class IosShareService : ShareService { + override fun share(url: String, title: String) { + val nsUrl = NSURL.URLWithString(url) ?: return + val rootViewController = UIApplication.sharedApplication.keyWindow?.rootViewController ?: return + val activityViewController = UIActivityViewController(listOf(title, url, nsUrl), null) + rootViewController.presentViewController(activityViewController, true, null) + } +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt new file mode 100644 index 00000000..c465732c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.webview + +import dev.nucleusframework.webview.web.WebViewState +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSDate +import platform.Foundation.NSHTTPCookieStorage +import platform.Foundation.distantPast +import platform.WebKit.WKWebsiteDataStore +import platform.WebKit.WKWebsiteDataTypeCookies +import platform.WebKit.WKWebsiteDataTypeLocalStorage + +actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) { + // Applied to the WKWebView itself, so it covers redirects and XHR too, not just the first request. + SafariUserAgent.value?.let { webView.webSettings.customUserAgentString = it } +} + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun platformClearWebviewData(pluginId: String?) { + if (pluginId == null) return + + val dataStore = WKWebsiteDataStore.defaultDataStore() + val dataTypes = setOf(WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage) + dataStore.removeDataOfTypes(dataTypes, NSDate.distantPast) {} + + NSHTTPCookieStorage.sharedHTTPCookieStorage.removeCookiesSinceDate(NSDate.distantPast) +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt new file mode 100644 index 00000000..4607265d --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.webview + +import co.touchlab.kermit.Logger +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.readValue +import platform.CoreGraphics.CGRectZero +import platform.UIKit.UIDevice +import platform.UIKit.UIUserInterfaceIdiomPad +import platform.WebKit.WKWebView +import platform.WebKit.WKWebViewConfiguration + +/** + * Mobile Safari's exact User-Agent on this device. + * + * WKWebView's default UA ends in `Mobile/15E148` without Safari's `Version/x … Safari/604.1`, + * which sites like accounts.spotify.com treat as an embedded browser ("Oops, something went + * wrong"). Hand-building the string isn't safe either: Safari freezes the OS part (iOS 26 + * reports `iPhone OS 18_7`). So WebKit builds it: a probe WKWebView gets Safari's application + * name and reports `navigator.userAgent`, which is cached for every web view. + */ +internal object SafariUserAgent { + var value: String? = null + private set + + // Kept alive until its async evaluation calls back. + private var probe: WKWebView? = null + + /** Must run on the main thread (WebKit); call once at startup. */ + @OptIn(ExperimentalForeignApi::class) + fun prime() { + if (value != null || probe != null) return + val config = WKWebViewConfiguration().apply { applicationNameForUserAgent = safariApplicationName() } + probe = WKWebView(frame = CGRectZero.readValue(), configuration = config).also { webView -> + webView.evaluateJavaScript("navigator.userAgent") { result, _ -> + value = result as? String + probe = null + Logger.withTag("SafariUserAgent").i { "WebView User-Agent: $value" } + } + } + } + + // What Safari appends after WebKit's base UA; iPad Safari sends the desktop-class form. + private fun safariApplicationName(): String { + val version = UIDevice.currentDevice.systemVersion.split('.').take(2).joinToString(".") + return if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) { + "Version/$version Safari/605.1.15" + } else { + "Version/$version Mobile/15E148 Safari/604.1" + } + } +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt new file mode 100644 index 00000000..c4a1559a --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking +import platform.Foundation.NSThread +import kotlin.coroutines.ContinuationInterceptor + +// Same as JVM/Android. Zipline lets QuickJS recurse up to ~6 MiB of native stack, but +// iOS gives secondary threads (incl. newSingleThreadContext's) only 512 KiB, so loading +// a plugin overflowed into the guard page and crashed the app with SIGBUS. +private const val ZIPLINE_THREAD_STACK_SIZE: ULong = 8_388_608u // 8 MiB + +actual fun createZiplineDispatcher(): ZiplineDispatcher { + val dispatcher = CompletableDeferred() + val stopped = CompletableDeferred() + // NSThread is the only way to choose a thread's stack size here; it hosts an event + // loop (runBlocking) whose dispatcher accepts work from any thread. + val thread = NSThread { + runBlocking { + dispatcher.complete(coroutineContext[ContinuationInterceptor] as CoroutineDispatcher) + stopped.await() + } + } + thread.name = "Zipline" + thread.stackSize = ZIPLINE_THREAD_STACK_SIZE + thread.start() + return ZiplineDispatcher(runBlocking { dispatcher.await() }) { + stopped.complete(Unit) + } +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.ios.kt new file mode 100644 index 00000000..a51ee801 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.ios.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.downloads + +// No system media index to update; files are picked up from the folder directly. +internal actual fun addToMediaLibrary(path: String) = Unit diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt new file mode 100644 index 00000000..76178dde --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.local_tracks.media + +class IosLocalMediaDiscoveryService : LocalMediaDiscoveryService { + override suspend fun discoverFolders(roots: List): List { + return emptyList() + } + + override fun observeChanges( + roots: List, + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { + return null + } +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt new file mode 100644 index 00000000..db540813 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.local_tracks.media + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState { + return LocalMediaPermissionState( + isGranted = true, + requestPermission = {}) +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/Platform.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/Platform.jvm.kt new file mode 100644 index 00000000..ad2c1b57 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/Platform.jvm.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube + +import java.awt.Desktop +import java.net.URI + +class JVMPlatform : Platform { + override val name: String = "Java ${System.getProperty("java.version")}" + override val type: PlatformType = System.getProperty("os.name").let { osName -> + when { + osName.contains("win", ignoreCase = true) -> PlatformType.Windows + osName.contains("mac", ignoreCase = true) -> PlatformType.MacOS + osName.contains("nix", ignoreCase = true) || osName.contains( + "nux", + ignoreCase = true + ) || osName.contains("aix", ignoreCase = true) -> PlatformType.Linux + + else -> PlatformType.Unknown + } + } +} + +actual fun getPlatform(): Platform = JVMPlatform() + +actual fun openUrlInBrowser(url: String) { + // The app runs on a native (Tao) window, not an AWT frame, so java.awt.Desktop is not a + // reliable way to hand a URL to the OS. Prefer the platform's own opener process and only + // fall back to AWT when no opener is available on PATH. + if (openWithSystemOpener(url)) return + runCatching { Desktop.getDesktop().browse(URI(url)) } +} + +private fun openWithSystemOpener(url: String): Boolean { + val osName = System.getProperty("os.name").lowercase() + val command = when { + osName.contains("win") -> listOf("rundll32", "url.dll,FileProtocolHandler", url) + osName.contains("mac") -> listOf("open", url) + osName.contains("nux") || osName.contains("nix") || osName.contains("aix") -> + listOf("xdg-open", url) + else -> return false + } + return runCatching { + ProcessBuilder(command).start() + true + }.getOrDefault(false) +} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt new file mode 100644 index 00000000..b2f77f13 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.appimage + +import com.sun.jna.Function +import com.sun.jna.NativeLibrary +import java.io.File + +/** + * Makes the GTK 3 and WebKitGTK bundled into the AppImage usable. The AppImage's AppRun already puts + * `$APPDIR/usr/lib` on `LD_LIBRARY_PATH`; the rest has to be done in-process, before any GTK/WebKit code + * loads, because the AppRun is generated and can't be customised: + * + * - WebKitGTK has its helper-process path compiled in. The build patches it to a path relative to the + * working directory (see `DownloadLinuxGtkBundleTask` in buildSrc), so the working directory is moved to + * `$APPDIR/usr`. + * - TLS in WebKit/libsoup needs glib-networking's GIO module, which lives in the bundle. + * - The bundled WebKitGTK 2.44 uses its dmabuf renderer by default. Against a newer host Mesa/compositor + * that path never paints the first frame (the webview stays see-through or black until the window is + * resized), and the bundled GTK/epoxy can even abort while creating the GL context. Disabling it makes + * the webview paint on native Wayland. Left alone if the user already set it. + * + * Does nothing outside the AppImage (deb/rpm/dev runs have no bundled `usr/lib/libgtk-3.so.0`). + */ +object AppImageEnvironment { + fun apply() { + if (!System.getProperty("os.name").lowercase().contains("linux")) return + val resourcesDir = System.getProperty("compose.application.resources.dir") ?: return + val usrDir = File(resourcesDir, "usr") + if (!File(usrDir, "lib/libgtk-3.so.0").isFile) return + + try { + val libc = NativeLibrary.getInstance("c") + val setenv = libc.getFunction("setenv") + val triplet = if (System.getProperty("os.arch").lowercase().let { "aarch64" in it || "arm64" in it }) { + "aarch64-linux-gnu" + } else { + "x86_64-linux-gnu" + } + setenv.set("GIO_EXTRA_MODULES", File(usrDir, "lib/$triplet/gio/modules").absolutePath) + if (System.getenv("WEBKIT_DISABLE_DMABUF_RENDERER").isNullOrBlank()) { + setenv.set("WEBKIT_DISABLE_DMABUF_RENDERER", "1") + } + libc.getFunction("chdir").invokeInt(arrayOf(usrDir.absolutePath)) + } catch (_: Throwable) { + // Falls back to the host's libraries, which only works where they are installed + } + } + + private fun Function.set(name: String, value: String) { + invokeInt(arrayOf(name, value, 1)) + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt new file mode 100644 index 00000000..37cb7a42 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt @@ -0,0 +1,609 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.audioplayer + +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.generated.VLCBundleLoaderGenerated +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import uk.co.caprica.vlcj.factory.MediaPlayerFactory +import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery +import uk.co.caprica.vlcj.medialist.MediaList +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.base.State +import uk.co.caprica.vlcj.player.component.AudioListPlayerComponent +import uk.co.caprica.vlcj.player.list.MediaListPlayer +import uk.co.caprica.vlcj.player.list.PlaybackMode +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface, KoinComponent { + actual val context: Any = context + + private val logger by injectLogger() + private val lock = ReentrantLock() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private var mediaPlayerFactory: MediaPlayerFactory + + private var audioListPlayerComponent: AudioListPlayerComponent + + private var mediaListPlayer: MediaListPlayer + private var mediaPlayer: MediaPlayer + private var mediaList: MediaList + + private val originalPlaylist = mutableListOf() + private val currentPlaylist = mutableListOf() + + private var currentIndex = -1 + private var pendingNextIndex: Int? = null + private var shuffleEnabled = false + private var disposed = false + private var hasStartedPlayback = false + private var positionPollingJob: Job? = null + + private val _playerState = MutableStateFlow(PlayerState.IDLE) + private val _currentMediaItem = MutableStateFlow(null) + private val _playlist = MutableStateFlow>(emptyList()) + private val _duration = MutableStateFlow(Duration.ZERO) + private val _position = MutableStateFlow(Duration.ZERO) + private val _bufferingPosition = MutableStateFlow(Duration.ZERO) + private val _loopState = MutableStateFlow(LoopState.NONE) + private val _shuffleMode = MutableStateFlow(false) + private val _playbackSpeed = MutableStateFlow(1.0f) + private val _volume = MutableStateFlow(1.0f) + private val _completion = MutableSharedFlow(extraBufferCapacity = 1) + private val _error = MutableSharedFlow(extraBufferCapacity = 1) + + actual override val playerStateFlow: StateFlow = _playerState.asStateFlow() + actual override val currentMediaItemFlow: StateFlow = _currentMediaItem.asStateFlow() + actual override val playlistFlow: StateFlow> = _playlist.asStateFlow() + actual override val durationFlow: StateFlow = _duration.asStateFlow() + actual override val positionFlow: StateFlow = _position.asStateFlow() + actual override val bufferingPositionFlow: StateFlow = _bufferingPosition.asStateFlow() + actual override val loopStateFlow: StateFlow = _loopState.asStateFlow() + actual override val shuffleModeFlow: StateFlow = _shuffleMode.asStateFlow() + actual override val playbackSpeedFlow: StateFlow = _playbackSpeed.asStateFlow() + actual override val volumeFlow: StateFlow = _volume.asStateFlow() + actual override val completionFlow: Flow = _completion.asSharedFlow() + actual override val errorFlow: Flow = _error.asSharedFlow() + + init { + VLCBundleLoaderGenerated.getVerifiedPath() + mediaPlayerFactory = MediaPlayerFactory( + null as NativeDiscovery?, + "--no-video", + "--vout=dummy", + "--aout=any", + "--no-osd", + "--no-snapshot-preview", +// "--verbose=2" +// "--quiet" + ) + audioListPlayerComponent = object : AudioListPlayerComponent(mediaPlayerFactory) { + override fun opening(mediaPlayer: MediaPlayer) { + _playerState.tryEmit(PlayerState.BUFFERING) + } + + override fun buffering( + mediaPlayer: MediaPlayer, + newCache: Float + ) { + val durationMs = mediaPlayer.status().length() + _bufferingPosition.tryEmit( + if (durationMs > 0) (durationMs * (newCache / 100f)).toLong().milliseconds else Duration.ZERO) + _playerState.tryEmit(if (newCache < 100f) PlayerState.BUFFERING else PlayerState.READY) + } + + override fun playing(mediaPlayer: MediaPlayer) { + hasStartedPlayback = true + _playerState.tryEmit(PlayerState.PLAYING) + } + + override fun paused(mediaPlayer: MediaPlayer) { + _playerState.tryEmit(PlayerState.PAUSED) + } + + override fun stopped(mediaPlayer: MediaPlayer) { + _playerState.tryEmit(PlayerState.IDLE) + } + + override fun finished(mediaPlayer: MediaPlayer) { + _playerState.tryEmit(PlayerState.COMPLETED) + _completion.tryEmit(Unit) + } + + override fun timeChanged( + mediaPlayer: MediaPlayer, + newTime: Long + ) { + if (newTime >= 0) { + _position.tryEmit(newTime.milliseconds) + } + } + + override fun mediaDurationChanged( + mediaPlayer: uk.co.caprica.vlcj.media.Media, + newDuration: Long + ) { + logger.d { "Media duration changed: ${newDuration}ms" } + if (newDuration > 0) { + _duration.tryEmit(newDuration.milliseconds) + } + } + + override fun lengthChanged(mediaPlayer: MediaPlayer?, newLength: Long) { + logger.d { "Media length changed: ${newLength}ms" } + if (newLength > 0) { + _duration.tryEmit(newLength.milliseconds) + } + } + + override fun volumeChanged( + mediaPlayer: MediaPlayer, + volume: Float + ) { + _volume.tryEmit(normalizeVlcVolume(volume)) + } + + override fun error(mediaPlayer: MediaPlayer) { + _playerState.tryEmit(PlayerState.IDLE) + _error.tryEmit(IllegalStateException("VLC encountered a playback error")) + } + + override fun nextItem( + mediaListPlayer: uk.co.caprica.vlcj.player.list.MediaListPlayer, + item: uk.co.caprica.vlcj.media.MediaRef) { + lock.withLock { + val targetIndex = pendingNextIndex ?: run { + val currentMrl = runCatching { + val media = item.newMedia() + try { + media.info().mrl() + } finally { + media.release() + } + }.getOrNull() + + if (currentMrl != null) { + currentPlaylist.indexOfFirst { it.toMrl() == currentMrl } + } else { + -1 + } + } + + if (targetIndex >= 0 && targetIndex < currentPlaylist.size) { + logger.d { "nextItem: updating currentIndex from $currentIndex to $targetIndex" } + currentIndex = targetIndex + _currentMediaItem.tryEmit(currentPlaylist[targetIndex]) + } else { + logger.w { "nextItem: invalid targetIndex=$targetIndex, currentIndex remains $currentIndex" } + } + + pendingNextIndex = null + } + } + + override fun mediaListPlayerFinished(mediaListPlayer: uk.co.caprica.vlcj.player.list.MediaListPlayer) { + _playerState.tryEmit(PlayerState.COMPLETED) + _completion.tryEmit(Unit) + } + + override fun stopped(mediaListPlayer: uk.co.caprica.vlcj.player.list.MediaListPlayer) { + _playerState.tryEmit(PlayerState.IDLE) + } + + override fun mediaPlayerReady(mediaPlayer: MediaPlayer?) { + _duration.tryEmit( + mediaPlayer?.media()?.info()?.duration()?.milliseconds ?: Duration.ZERO + ) + } + } + + mediaListPlayer = audioListPlayerComponent.mediaListPlayer() + mediaPlayer = audioListPlayerComponent.mediaPlayer() + mediaList = mediaPlayerFactory.media().newMediaList() + + mediaListPlayer.list().setMediaList(mediaList.newMediaListRef()) + startPositionPolling() + } + + actual override suspend fun play() { + lock.withLock { + if (disposed || currentPlaylist.isEmpty()) return + if (currentIndex !in currentPlaylist.indices) { + currentIndex = 0 + } + if (hasStartedPlayback) { + mediaListPlayer.controls().setPause(false) + } else { + mediaListPlayer.controls().play(currentIndex) + } + } + } + + actual override suspend fun pause() { + lock.withLock { + if (disposed) return + mediaListPlayer.controls().pause() + } + } + + actual override suspend fun stop() { + lock.withLock { + if (disposed) return + mediaListPlayer.controls().stop() + hasStartedPlayback = false + _position.tryEmit(Duration.ZERO) + } + } + + actual override suspend fun seekTo(position: Duration) { + lock.withLock { + if (disposed) return + val maxMs = _duration.value.inWholeMilliseconds + val targetMs = if (maxMs > 0) { + position.inWholeMilliseconds.coerceIn(0, maxMs) + } else { + position.inWholeMilliseconds.coerceAtLeast(0) + } + mediaPlayer.controls().setTime(targetMs) + if (maxMs > 0) { + mediaPlayer.controls() + .setPosition((targetMs.toFloat() / maxMs.toFloat()).coerceIn(0f, 1f)) + } + _position.tryEmit(targetMs.milliseconds) + } + } + + actual override suspend fun loop(state: LoopState) { + lock.withLock { + if (disposed) return + val vlcMode = when (state) { + LoopState.NONE -> PlaybackMode.DEFAULT + LoopState.ONE -> PlaybackMode.REPEAT + LoopState.ALL -> PlaybackMode.LOOP + } + mediaListPlayer.controls().setMode(vlcMode) + _loopState.tryEmit(state) + } + } + + actual override suspend fun shuffle(enabled: Boolean) { + lock.withLock { + if (disposed || currentPlaylist.isEmpty() || shuffleEnabled == enabled) return + + val currentItem = _currentMediaItem.value + + val rebuilt = if (enabled) { + if (currentItem != null) { + val rest = originalPlaylist.filterNot { it.url == currentItem.url }.shuffled() + listOf(currentItem) + rest + } else { + originalPlaylist.shuffled() + } + } else { + originalPlaylist.toList() + } + + currentPlaylist.clear() + currentPlaylist.addAll(rebuilt) + rebuildVlcMediaListLocked() + + shuffleEnabled = enabled + _shuffleMode.tryEmit(enabled) + _playlist.tryEmit(currentPlaylist.toList()) + + val nextIndex = when { + currentItem == null -> if (currentPlaylist.isEmpty()) -1 else 0 + else -> currentPlaylist.indexOfFirst { it.url == currentItem.url } + .takeIf { it >= 0 } ?: 0 + } + + currentIndex = nextIndex + _currentMediaItem.tryEmit(currentPlaylist.getOrNull(nextIndex)) + } + } + + actual override suspend fun load(playlist: List, autoPlay: Boolean, startPosition: Int) { + lock.withLock { + if (disposed) return + + originalPlaylist.clear() + originalPlaylist.addAll(playlist) + currentPlaylist.clear() + currentPlaylist.addAll(playlist) + shuffleEnabled = false + + _shuffleMode.tryEmit(false) + rebuildVlcMediaListLocked() + _playlist.tryEmit(currentPlaylist.toList()) + + if (currentPlaylist.isEmpty()) { + currentIndex = -1 + _currentMediaItem.tryEmit(null) + _playerState.tryEmit(PlayerState.IDLE) + _duration.tryEmit(Duration.ZERO) + _position.tryEmit(Duration.ZERO) + _bufferingPosition.tryEmit(Duration.ZERO) + return + } + + val normalizedStartPosition = startPosition.coerceIn(0, currentPlaylist.lastIndex) + currentIndex = normalizedStartPosition + _currentMediaItem.tryEmit(currentPlaylist[normalizedStartPosition]) + + hasStartedPlayback = false + if (autoPlay) { + mediaListPlayer.controls().play(normalizedStartPosition) + } else { + _playerState.tryEmit(PlayerState.PAUSED) + } + } + } + + actual override suspend fun addMediaItem(mediaItem: MediaItem) { + lock.withLock { + if (disposed) return + originalPlaylist.add(mediaItem) + currentPlaylist.add(mediaItem) + mediaList.media().add(mediaItem.toMrl()) + _playlist.tryEmit(currentPlaylist.toList()) + if (currentIndex == -1) { + currentIndex = 0 + _currentMediaItem.tryEmit(currentPlaylist.firstOrNull()) + } + } + } + + actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) { + lock.withLock { + if (disposed) return + val insertIndex = if (currentIndex >= 0) currentIndex + 1 else 0 + originalPlaylist.add(insertIndex, mediaItem) + currentPlaylist.add(insertIndex, mediaItem) + mediaList.media().clear() + currentPlaylist.forEach { item -> + mediaList.media().add(item.toMrl()) + } + _playlist.tryEmit(currentPlaylist.toList()) + if (currentIndex == -1) { + currentIndex = 0 + _currentMediaItem.tryEmit(currentPlaylist.firstOrNull()) + } + } + } + + actual override suspend fun removeMediaItem(mediaItem: MediaItem) { + lock.withLock { + if (disposed || currentPlaylist.isEmpty()) return + + originalPlaylist.removeAll { it.url == mediaItem.url } + val removalIndices = currentPlaylist.withIndex() + .filter { it.value.url == mediaItem.url } + .map { it.index } + + if (removalIndices.isEmpty()) return + + removalIndices.asReversed().forEach { index -> + mediaList.media().remove(index) + currentPlaylist.removeAt(index) + } + + if (currentPlaylist.isEmpty()) { + currentIndex = -1 + _currentMediaItem.tryEmit(null) + mediaListPlayer.controls().stop() + _playerState.tryEmit(PlayerState.IDLE) + } else { + currentIndex = currentIndex.coerceIn(0, currentPlaylist.lastIndex) + _currentMediaItem.tryEmit(currentPlaylist.getOrNull(currentIndex)) + } + + _playlist.tryEmit(currentPlaylist.toList()) + } + } + + actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) { + lock.withLock { + if (disposed) return + if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return + + val item = currentPlaylist.removeAt(fromIndex) + currentPlaylist.add(toIndex, item) + + if (!shuffleEnabled && fromIndex in originalPlaylist.indices && toIndex in originalPlaylist.indices) { + val originalItem = originalPlaylist.removeAt(fromIndex) + originalPlaylist.add(toIndex, originalItem) + } + + val currentItemUrl = _currentMediaItem.value?.url + rebuildVlcMediaListLocked() + currentIndex = currentItemUrl?.let { url -> + currentPlaylist.indexOfFirst { it.url == url }.takeIf { it >= 0 } + } ?: currentIndex.coerceIn(0, currentPlaylist.lastIndex) + + _playlist.tryEmit(currentPlaylist.toList()) + _currentMediaItem.tryEmit(currentPlaylist.getOrNull(currentIndex)) + } + } + + actual override suspend fun skipToNext() { + lock.withLock { + if (disposed || currentPlaylist.isEmpty()) return + val nextIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.lastIndex) + logger.d { "skipToNext: currentIndex=$currentIndex, targetIndex=$nextIndex" } + currentIndex = nextIndex + _currentMediaItem.value = currentPlaylist[nextIndex] + pendingNextIndex = nextIndex + mediaListPlayer.controls().play(nextIndex) + } + } + + actual override suspend fun skipToPrevious() { + lock.withLock { + if (disposed || currentPlaylist.isEmpty()) return + val prevIndex = (currentIndex - 1).coerceAtLeast(0) + logger.d { "skipToPrevious: currentIndex=$currentIndex, targetIndex=$prevIndex" } + currentIndex = prevIndex + _currentMediaItem.value = currentPlaylist[prevIndex] + pendingNextIndex = prevIndex + mediaListPlayer.controls().play(prevIndex) + } + } + + actual override suspend fun jumpTo(index: Int) { + lock.withLock { + if (disposed || index !in currentPlaylist.indices) return + currentIndex = index + _currentMediaItem.tryEmit(currentPlaylist[index]) + pendingNextIndex = index + mediaListPlayer.controls().play(index) + } + } + + actual override suspend fun setVolume(volume: Float) { + lock.withLock { + if (disposed) return + val clamped = volume.coerceIn(0f, 1f) + mediaPlayer.audio().setVolume((clamped * 100).toInt()) + _volume.tryEmit(clamped) + } + } + + actual override suspend fun setPlaybackSpeed(speed: Float) { + lock.withLock { + if (disposed) return + val clamped = speed.coerceIn(0.25f, 4f) + val updated = mediaPlayer.controls().setRate(clamped) + if (updated) { + _playbackSpeed.tryEmit(clamped) + } else { + logger.w { "Failed to set VLC playback speed to $clamped" } + } + } + } + + actual override fun isDisposed(): Boolean = disposed + + actual override fun dispose() { + lock.withLock { + if (disposed) return + disposed = true + } + + positionPollingJob?.cancel() + scope.cancel() + + runCatching { mediaListPlayer.controls().stop() } + runCatching { mediaList.release() } + runCatching { audioListPlayerComponent.release() } + runCatching { mediaPlayerFactory.release() } + + lock.withLock { + originalPlaylist.clear() + currentPlaylist.clear() + currentIndex = -1 + hasStartedPlayback = false + } + + _playerState.tryEmit(PlayerState.IDLE) + _currentMediaItem.tryEmit(null) + _playlist.tryEmit(emptyList()) + _duration.tryEmit(Duration.ZERO) + _position.tryEmit(Duration.ZERO) + _bufferingPosition.tryEmit(Duration.ZERO) + } + + private fun rebuildVlcMediaListLocked() { + val oldList = mediaList + mediaList = mediaPlayerFactory.media().newMediaList() + currentPlaylist.forEach { mediaItem -> + mediaList.media().add(mediaItem.toMrl()) + } + mediaListPlayer.list().setMediaList(mediaList.newMediaListRef()) + oldList.release() + } + + private fun startPositionPolling() { + positionPollingJob = scope.launch { + try { + while (isActive) { + if (!disposed) { + val status = mediaPlayer.status() + val time = status.time() + val duration = status.length() + if (time >= 0) { + _position.tryEmit(time.milliseconds) + } + if (duration > 0) { + _duration.tryEmit(duration.milliseconds) + } + _playerState.tryEmit(status.state().toPlayerState()) + } + delay(250.milliseconds) + } + } catch (e: Throwable) { + if (!disposed) { + logger.e(e) { "Error in position polling loop" } + } + } + } + } + + private fun State.toPlayerState(): PlayerState { + return when (this) { + State.NOTHING_SPECIAL -> PlayerState.IDLE + State.OPENING -> PlayerState.BUFFERING + State.BUFFERING -> PlayerState.BUFFERING + State.PLAYING -> PlayerState.PLAYING + State.PAUSED -> PlayerState.PAUSED + State.STOPPED -> PlayerState.IDLE + State.ENDED -> PlayerState.COMPLETED + State.ERROR -> PlayerState.IDLE + } + } + + private fun MediaItem.toMrl(): String { + return url + } + + private fun normalizeVlcVolume(rawVolume: Float): Float { + return if (rawVolume <= 1f) { + rawVolume.coerceIn(0f, 1f) + } else { + (rawVolume / 100f).coerceIn(0f, 1f) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt new file mode 100644 index 00000000..bc7fc177 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.di + +import org.spotube.spotube.core.audioplayer.AudioPlayer +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.mediacontrol.SystemMediaControlService +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.share.JvmShareService +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.systemtray.SystemTrayService +import org.spotube.spotube.modules.library.local_tracks.media.JvmLocalMediaDiscoveryService +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService +import org.koin.core.module.dsl.createdAtStart +import org.koin.core.module.dsl.singleOf +import org.koin.core.module.dsl.withOptions +import org.koin.dsl.module +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.window.WindowStateRepository + +actual val platformModules = module { + singleOf(::Paths) + single { WindowStateRepository(get()) } + single { AudioPlayer(Unit) } + single { JvmLocalMediaDiscoveryService() } + single { JvmShareService() } + single { SystemTrayService(get(), get(), get()) } + single { SystemMediaControlService(get()) } withOptions { createdAtStart() } +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.jvm.kt new file mode 100644 index 00000000..94376d20 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.jvm.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.discovery + +import java.net.DatagramSocket +import java.net.Inet4Address +import java.net.InetAddress +import java.net.NetworkInterface + +// Interface name prefixes for container/VM/VPN links other LAN devices can't reach. +private val VirtualInterfacePrefixes = listOf( + "docker", "br-", "veth", "virbr", "vmnet", "vboxnet", "cni", "flannel", "podman", + "lxc", "lxd", "tun", "tap", "wg", "zt", "tailscale", "utun", "ham") + +internal actual fun preferredAdvertiseAddress(): String? = + (routedLanAddress() ?: firstPhysicalLanAddress())?.hostAddress + +/** + * The source address the OS would use to reach the outside world, i.e. the interface that + * owns the default route. A connected UDP socket sends nothing; it only resolves the route. + */ +private fun routedLanAddress(): InetAddress? = runCatching { + DatagramSocket().use { socket -> + socket.connect(InetAddress.getByName("192.0.2.1"), 9) // TEST-NET-1, never routed anywhere + socket.localAddress + } +}.getOrNull()?.takeIf { it is Inet4Address && it.isSiteLocalAddress } + +/** Fallback for networks without a default route (e.g. an offline LAN). */ +private fun firstPhysicalLanAddress(): InetAddress? = runCatching { + NetworkInterface.getNetworkInterfaces().toList() + .filter { iface -> + iface.isUp && !iface.isLoopback && !iface.isVirtual && !iface.isPointToPoint && + VirtualInterfacePrefixes.none { iface.name.startsWith(it) } + } + .flatMap { it.inetAddresses.toList() } + .firstOrNull { it is Inet4Address && it.isSiteLocalAddress } +}.getOrNull() diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.jvm.kt new file mode 100644 index 00000000..62b8f80c --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.jvm.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.discovery + +import androidx.compose.runtime.Composable + +/** No runtime local-network permission needed on the JVM. */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.jvm.kt new file mode 100644 index 00000000..9740ce31 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.jvm.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.i18n + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.staticCompositionLocalOf +import java.util.Locale + +// Captured before the first override so "System default" can restore it. +private val systemLocale: Locale = Locale.getDefault() + +private val LocalAppLocale = staticCompositionLocalOf { systemLocale.toLanguageTag() } + +@Composable +internal actual fun provideAppLocale(languageTag: String?): ProvidedValue<*> { + val locale = languageTag?.let(Locale::forLanguageTag) ?: systemLocale + // Compose resources read Locale.getDefault() both in composition and in getString(). + Locale.setDefault(locale) + return LocalAppLocale provides locale.toLanguageTag() +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/mediacontrol/SystemMediaControlService.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/mediacontrol/SystemMediaControlService.kt new file mode 100644 index 00000000..d5e2a2dc --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/mediacontrol/SystemMediaControlService.kt @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.mediacontrol + +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.MediaItem +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.di.injectLogger +import dev.nucleusframework.media.control.MediaControlEvent +import dev.nucleusframework.media.control.MediaControlService +import dev.nucleusframework.media.control.MediaMetadata +import dev.nucleusframework.media.control.MediaPlaybackState +import dev.nucleusframework.media.control.MediaPlaybackStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import kotlin.time.Duration.Companion.milliseconds + +/** + * Publishes now-playing metadata and playback state to the OS media center + * (MPRIS on Linux, Now Playing on macOS, SMTC on Windows) and forwards media + * key commands back into the [AudioPlayerInterface]. + * + * Events from the OS are delivered on the Swing EDT; playback commands are + * dispatched onto [scope] so they reach the player's suspend API. + */ +class SystemMediaControlService( + private val audioPlayer: AudioPlayerInterface) : KoinComponent, AutoCloseable { + + private val logger by injectLogger() + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + private val enabled: Boolean = MediaControlService.isAvailable() + + init { + if (enabled) { + MediaControlService.configure() + MediaControlService.attach(::onMediaControlEvent) + observePlayer() + } else { + logger.i { "System media control backend unavailable; skipping integration" } + } + } + + private fun onMediaControlEvent(event: MediaControlEvent) { + when (event) { + MediaControlEvent.Play -> scope.launch { audioPlayer.play() } + MediaControlEvent.Pause -> scope.launch { audioPlayer.pause() } + MediaControlEvent.Toggle -> scope.launch { + if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) { + audioPlayer.pause() + } else { + audioPlayer.play() + } + } + MediaControlEvent.Next -> scope.launch { audioPlayer.skipToNext() } + MediaControlEvent.Previous -> scope.launch { audioPlayer.skipToPrevious() } + MediaControlEvent.Stop -> scope.launch { audioPlayer.stop() } + is MediaControlEvent.SeekBy -> scope.launch { + audioPlayer.seekTo(audioPlayer.positionFlow.value + event.offsetMs.milliseconds) + } + is MediaControlEvent.SetPosition -> scope.launch { + audioPlayer.seekTo(event.positionMs.milliseconds) + } + is MediaControlEvent.SetVolume -> scope.launch { + audioPlayer.setVolume(event.volume.toFloat()) + } + is MediaControlEvent.OpenUri, + MediaControlEvent.Raise, + MediaControlEvent.Quit, + -> Unit + } + } + + private fun observePlayer() { + scope.launch { + audioPlayer.currentMediaItemFlow.collect { item -> + if (item != null) { + MediaControlService.setMetadata(item.toMediaMetadata()) + } + } + } + + scope.launch { + combine( + audioPlayer.playerStateFlow, + audioPlayer.positionFlow) { state, position -> + MediaPlaybackState( + status = state.toMediaPlaybackStatus(), + positionMs = position.inWholeMilliseconds) + }.collect { MediaControlService.setPlaybackState(it) } + } + + scope.launch { + audioPlayer.volumeFlow.collect { volume -> + MediaControlService.setVolume(volume.toDouble()) + } + } + } + + private fun MediaItem.toMediaMetadata(): MediaMetadata { + return MediaMetadata( + title = title, + artist = artist, + album = album, + coverUrl = coverURL.ifBlank { null }, + duration = duration.inWholeMilliseconds) + } + + private fun PlayerState.toMediaPlaybackStatus(): MediaPlaybackStatus { + return when (this) { + PlayerState.PLAYING -> MediaPlaybackStatus.PLAYING + PlayerState.PAUSED -> MediaPlaybackStatus.PAUSED + else -> MediaPlaybackStatus.STOPPED + } + } + + override fun close() { + if (enabled) { + MediaControlService.detach() + } + scope.cancel() + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/paths/Paths.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/paths/Paths.jvm.kt new file mode 100644 index 00000000..54d70521 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/paths/Paths.jvm.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.paths + +import net.harawata.appdirs.AppDirsFactory + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +actual class Paths { + actual fun getApplicationCacheDirPath(): String { + return AppDirsFactory.getInstance().getUserCacheDir("Spotube", null, "Spotube") + } + + actual fun getApplicationDataDirPath(): String { + return AppDirsFactory.getInstance().getUserDataDir("Spotube", null, "Spotube") + } + + actual fun getUserDownloadsDirPath(): String { + return AppDirsFactory.getInstance().getUserDownloadsDir("Spotube", null, null) + } + + actual fun getMusicCacheDirPath(): String { + return AppDirsFactory.getInstance().getUserCacheDir("Spotube", null, "Spotube") + "/music_cache" + } +} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/share/JvmShareService.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/share/JvmShareService.kt new file mode 100644 index 00000000..096e23d0 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/share/JvmShareService.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.share + +import org.spotube.spotube.core.share.ShareService +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection + +class JvmShareService : ShareService { + override fun share(url: String, title: String) { + val selection = StringSelection(url) + Toolkit.getDefaultToolkit().systemClipboard.setContents(selection, null) + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTray.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTray.kt new file mode 100644 index 00000000..50f55bf5 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTray.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.systemtray + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import dev.nucleusframework.application.NucleusApplicationScope +import dev.nucleusframework.composenativetray.tray.api.Tray +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.tray_exit +import spotube.composeapp.generated.resources.tray_hide_window +import spotube.composeapp.generated.resources.tray_like_track +import spotube.composeapp.generated.resources.tray_loop +import spotube.composeapp.generated.resources.tray_loop_all +import spotube.composeapp.generated.resources.tray_loop_off +import spotube.composeapp.generated.resources.tray_loop_one +import spotube.composeapp.generated.resources.tray_mute +import spotube.composeapp.generated.resources.tray_next_track +import spotube.composeapp.generated.resources.tray_previous_track +import spotube.composeapp.generated.resources.tray_show_window +import spotube.composeapp.generated.resources.tray_shuffle +import spotube.composeapp.generated.resources.tray_unlike_track +import spotube.composeapp.generated.resources.tray_unmute +import spotube.composeapp.generated.resources.tray_volume +import spotube.composeapp.generated.resources.tray_volume_down +import spotube.composeapp.generated.resources.tray_volume_up + +/** + * Renders the system tray icon + menu via Compose Native Tray (no AWT/Swing). + * + * Must be composed inside `nucleusApplication { }` and kept in composition + * while the tray should be visible (the menu is fully reactive via + * [SystemTrayService.state]). + */ +@Composable +fun NucleusApplicationScope.SystemTray( + isWindowVisible: Boolean, + onToggleWindowVisibility: () -> Unit, + onExit: () -> Unit) { + val trayService = koinInject() + val state by trayService.state.collectAsState() + + // Tray menu builders aren't composable, so resolve every label up front. + val hideWindowLabel = stringResource(Res.string.tray_hide_window) + val showWindowLabel = stringResource(Res.string.tray_show_window) + val playLabel = stringResource(Res.string.player_play_content_description) + val pauseLabel = stringResource(Res.string.player_pause_content_description) + val nextTrackLabel = stringResource(Res.string.tray_next_track) + val previousTrackLabel = stringResource(Res.string.tray_previous_track) + val shuffleLabel = stringResource(Res.string.tray_shuffle) + val loopLabel = stringResource(Res.string.tray_loop) + val loopOffLabel = stringResource(Res.string.tray_loop_off) + val loopOneLabel = stringResource(Res.string.tray_loop_one) + val loopAllLabel = stringResource(Res.string.tray_loop_all) + val volumeLabel = stringResource(Res.string.tray_volume, (state.volume * 100).toInt()) + val volumeUpLabel = stringResource(Res.string.tray_volume_up) + val volumeDownLabel = stringResource(Res.string.tray_volume_down) + val muteLabel = stringResource(Res.string.tray_mute) + val unmuteLabel = stringResource(Res.string.tray_unmute) + val likeTrackLabel = stringResource(Res.string.tray_like_track) + val unlikeTrackLabel = stringResource(Res.string.tray_unlike_track) + val exitLabel = stringResource(Res.string.tray_exit) + + Tray( + icon = Iconsax.IconsaxMusic, + tooltip = "Spotube", + primaryAction = onToggleWindowVisibility) { + Item(label = if (isWindowVisible) hideWindowLabel else showWindowLabel) { + onToggleWindowVisibility() + } + Divider() + + Item(label = if (state.isPlaying) pauseLabel else playLabel) { + trayService.togglePlayPause() + } + Item(label = nextTrackLabel) { trayService.skipToNext() } + Item(label = previousTrackLabel) { trayService.skipToPrevious() } + Divider() + + CheckableItem( + label = shuffleLabel, + checked = state.shuffleEnabled, + onCheckedChange = trayService::setShuffle) + SubMenu(label = loopLabel) { + Item(label = loopOffLabel) { trayService.setLoop(LoopState.NONE) } + Item(label = loopOneLabel) { trayService.setLoop(LoopState.ONE) } + Item(label = loopAllLabel) { trayService.setLoop(LoopState.ALL) } + } + Divider() + + Item(label = volumeLabel) + Item(label = volumeUpLabel) { trayService.increaseVolume() } + Item(label = volumeDownLabel) { trayService.decreaseVolume() } + Item(label = if (state.volume <= 0f) unmuteLabel else muteLabel) { trayService.toggleMute() } + Divider() + + if (state.currentTrackId != null) { + Item(label = if (state.isCurrentTrackLiked) unlikeTrackLabel else likeTrackLabel) { + trayService.toggleLike() + } + } + Divider() + + Item(label = exitLabel) { onExit() } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTrayService.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTrayService.kt new file mode 100644 index 00000000..9c5615ea --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTrayService.kt @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.systemtray + +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.MediaItem +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * State holder for the system tray. Owns the audio state projected into the + * tray menu and exposes the actions the menu items trigger. It no longer + * touches AWT/Swing — the tray itself is rendered by the [SystemTray] + * composable via Compose Native Tray, so it stays compatible with the + * no-AWT Tao backend. + */ +class SystemTrayService( + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val savedTracksRepository: SavedTracksRepository) : AutoCloseable { + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + private val volumeStep = 0.05f + + private var onToggleWindowVisibility: () -> Unit = {} + + val state: StateFlow = + combine( + audioPlayer.playerStateFlow, + audioPlayer.currentMediaItemFlow, + audioPlayer.loopStateFlow, + audioPlayer.shuffleModeFlow, + audioPlayer.volumeFlow, + audioPlayerQueue.currentQueueEntryFlow, + savedTracksRepository.savedTracksIdsFlow) { values -> + TrayState( + playerState = values[0] as PlayerState, + mediaItem = values[1] as MediaItem?, + loopState = values[2] as LoopState, + shuffleEnabled = values[3] as Boolean, + volume = values[4] as Float, + currentEntry = values[5] as QueueEntry?, + savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set)) + }.stateIn(scope, SharingStarted.Eagerly, TrayState.Initial) + + fun setCallbacks(onToggleWindowVisibility: () -> Unit) { + this.onToggleWindowVisibility = onToggleWindowVisibility + } + + fun hideWindow() = onToggleWindowVisibility() + + fun togglePlayPause() { + scope.launch { + if (state.value.playerState == PlayerState.PLAYING) audioPlayer.pause() else audioPlayer.play() + } + } + + fun skipToNext() { + scope.launch { audioPlayer.skipToNext() } + } + + fun skipToPrevious() { + scope.launch { audioPlayer.skipToPrevious() } + } + + fun setShuffle(enabled: Boolean) { + scope.launch { audioPlayer.shuffle(enabled) } + } + + fun setLoop(loopState: LoopState) { + scope.launch { audioPlayer.loop(loopState) } + } + + fun increaseVolume() { + scope.launch { audioPlayer.setVolume((state.value.volume + volumeStep).coerceAtMost(1f)) } + } + + fun decreaseVolume() { + scope.launch { audioPlayer.setVolume((state.value.volume - volumeStep).coerceAtLeast(0f)) } + } + + fun toggleMute() { + scope.launch { audioPlayer.setVolume(if (state.value.volume <= 0f) 0.5f else 0f) } + } + + fun toggleLike() { + scope.launch { + val trackId = (state.value.currentEntry as? QueueEntry.StreamingTrack)?.track?.id ?: return@launch + val isLiked = state.value.savedTrackIds.contains(trackId) + if (isLiked) { + savedTracksRepository.removeSavedTracks(listOf(trackId)) + } else { + savedTracksRepository.saveTracks(listOf(trackId)) + } + } + } + + override fun close() { + scope.cancel() + } + + data class TrayState( + val playerState: PlayerState, + val mediaItem: MediaItem?, + val loopState: LoopState, + val shuffleEnabled: Boolean, + val volume: Float, + val currentEntry: QueueEntry?, + val savedTrackIds: Set) { + val isPlaying: Boolean get() = playerState == PlayerState.PLAYING + + val currentTrackId: String? get() = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id + + val isCurrentTrackLiked: Boolean + get() = currentTrackId != null && savedTrackIds.contains(currentTrackId) + + companion object { + val Initial = + TrayState( + playerState = PlayerState.IDLE, + mediaItem = null, + loopState = LoopState.NONE, + shuffleEnabled = false, + volume = 1f, + currentEntry = null, + savedTrackIds = emptySet()) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.jvm.kt new file mode 100644 index 00000000..7e007e12 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.jvm.kt @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.foundation.window.WindowDraggableArea + +import org.spotube.millennium.composables.MillenniumTopAppBar +import androidx.compose.ui.graphics.Color +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import org.spotube.spotube.core.systemtray.SystemTrayService +import org.spotube.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.resources.iconsax.FluentDismiss +import org.spotube.spotube.resources.iconsax.FluentMaximize +import org.spotube.spotube.resources.iconsax.FluentMinus +import org.spotube.spotube.resources.iconsax.FluentSquareMultiple +import org.spotube.spotube.resources.iconsax.Iconsax +import dev.nucleusframework.window.WindowControls +import dev.nucleusframework.window.WindowControlsRenderer +import dev.nucleusframework.window.WindowDoubleClickAction +import dev.nucleusframework.window.windowDragArea +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.app_window_close +import spotube.composeapp.generated.resources.app_window_maximize +import spotube.composeapp.generated.resources.app_window_minimize +import spotube.composeapp.generated.resources.app_window_restore + +private val WindowControlsReservedWidth = 132.dp + +@Composable +actual fun ApplicationMainBar( + title: @Composable (() -> Unit), + subtitle: @Composable () -> Unit, + actions: @Composable (RowScope.() -> Unit), + backButton: Boolean, + transparent: Boolean) { + Box( + modifier = Modifier.fillMaxWidth() + .windowDragArea(doubleClickAction = WindowDoubleClickAction.ToggleMaximize) + ) { + MillenniumTopAppBar( + title = title, + actions = { + actions() + // Reserve room for the platform window controls instead of shrinking the + // whole bar, so it spans the full width with no seam under the controls. + Spacer(Modifier.width(WindowControlsReservedWidth)) + }, + navigationIcon = { + if (backButton) { + ApplicationBackButton() + } + }, + subtitle = subtitle, + background = if (transparent) SolidColor(Color.Transparent) else null, + separatorColor = if (transparent) Color.Transparent else null, + modifier = Modifier.fillMaxWidth() + ) + LocalWindowScope.current.WindowControls( + modifier = Modifier.align(Alignment.TopEnd).heightIn(max = 48.dp), + renderer = WindowControlsRenderer.Platform, // default + ) + } +} + +@Composable +private fun WindowButtons(modifier: Modifier = Modifier) { + val windowState = LocalWindowState.current + val applicationScope = LocalApplicationScope.current + val settingsProvider = koinInject() + val trayService = koinInject() + val settings by settingsProvider.settingsState.collectAsState(initial = null) + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween + ) { + MillenniumIconButton( + onClick = { windowState.isMinimized = true }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.FluentMinus, stringResource(Res.string.app_window_minimize), modifier = Modifier.size(14.dp)) + } + MillenniumIconButton( + onClick = { + windowState.placement = if (windowState.placement == WindowPlacement.Maximized) + WindowPlacement.Floating else WindowPlacement.Maximized + }, + style = MillenniumIconButtonStyle.Quiet) { + if (windowState.placement == WindowPlacement.Floating) { + MillenniumIcon(Iconsax.FluentMaximize, stringResource(Res.string.app_window_maximize), modifier = Modifier.size(14.dp)) + } else { + MillenniumIcon(Iconsax.FluentSquareMultiple, stringResource(Res.string.app_window_restore), modifier = Modifier.size(14.dp)) + } + } + MillenniumIconButton( + onClick = { + if (settings?.minimizeToTray == true) { + trayService.hideWindow() + } else { + applicationScope.exitApplication() + } + }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.FluentDismiss, stringResource(Res.string.app_window_close), modifier = Modifier.size(14.dp)) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalApplication.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalApplication.kt new file mode 100644 index 00000000..5f5eb7ed --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalApplication.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.window.ApplicationScope +import dev.nucleusframework.application.NucleusApplicationScope + +val LocalApplicationScope = staticCompositionLocalOf< NucleusApplicationScope> { + error("No ApplicationScope found in LocalApplicationScope") +} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalWindow.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalWindow.kt new file mode 100644 index 00000000..b9912cbc --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalWindow.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.window.WindowState +import dev.nucleusframework.application.NucleusDecoratedWindowScope + +// LocalWindow is a LocalComposition that provides access to the current Window instance in the composition hierarchy. +val LocalWindowScope = compositionLocalOf { + error("No Window found in LocalWindowScope") +} +val LocalWindowState = compositionLocalOf { + error("No WindowState found in LocalWindowState") +} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.jvm.kt new file mode 100644 index 00000000..4adf2c8d --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.jvm.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.foundation.LocalScrollbarStyle +import androidx.compose.foundation.ScrollbarStyle +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.spotube.spotube.core.ui.theming.AppTheme + +@Composable +actual fun VerticalScrollbar( + listState: LazyListState, + modifier: Modifier +) { + val colors = AppTheme.colors + CompositionLocalProvider( + LocalScrollbarStyle provides ScrollbarStyle( + minimalHeight = 16.dp, + thickness = 8.dp, + shape = RoundedCornerShape(4.dp), + hoverDurationMillis = 300, + unhoverColor = colors.onSurface.copy(alpha = 0.18f), + hoverColor = colors.onSurface.copy(alpha = 0.4f))) { + VerticalScrollbar( + adapter = rememberScrollbarAdapter(listState), + modifier = modifier.fillMaxHeight() + .padding(end = 2.dp) + .padding(vertical = 16.dp) + .width(12.dp)) + } +} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.jvm.kt new file mode 100644 index 00000000..08f1f802 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.jvm.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.webview + +import org.spotube.spotube.core.paths.Paths +import dev.nucleusframework.webview.web.WebViewState +import okio.FileSystem +import okio.Path.Companion.toPath +import org.koin.core.context.GlobalContext + +actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) { + val paths = GlobalContext.get().get() + + val baseDir = "${paths.getApplicationCacheDirPath()}/webview_data".toPath() + val dataDir = if (pluginId != null) baseDir / pluginId else baseDir + webView.webSettings.desktopWebSettings.dataDirectory = dataDir.toString() +} + +actual suspend fun platformClearWebviewData(pluginId: String?) { + if (pluginId == null) return + val paths = GlobalContext.get().get() + val dataDirStr = "${paths.getApplicationCacheDirPath()}/webview_data/$pluginId" + val dataDir = dataDirStr.toPath() + if (FileSystem.SYSTEM.exists(dataDir)) { + FileSystem.SYSTEM.deleteRecursively(dataDir) + } +} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/window/WindowStateRepository.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/window/WindowStateRepository.kt new file mode 100644 index 00000000..6bddb90a --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/window/WindowStateRepository.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +package org.spotube.spotube.core.window + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.flow.first +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.db.DatabaseKeys + +/** + * What survives a restart: the last *normal* (floating) size, and whether the window was + * maximized. The size is kept while maximized so un-maximizing after a restart restores it. + */ +@Serializable +data class PersistedWindowState( + val widthDp: Float, + val heightDp: Float, + val maximized: Boolean +) { + val size: DpSize get() = DpSize(widthDp.dp, heightDp.dp) + val placement: WindowPlacement + get() = if (maximized) WindowPlacement.Maximized else WindowPlacement.Floating + + companion object { + fun of(size: DpSize, placement: WindowPlacement) = PersistedWindowState( + widthDp = size.width.value, + heightDp = size.height.value, + maximized = placement == WindowPlacement.Maximized) + } +} + +class WindowStateRepository internal constructor(private val dataStore: DataStore) { + constructor(database: Database) : this(database.windowStateDataStore) + + private val json = Json { ignoreUnknownKeys = true } + + /** Returns null when nothing was saved yet or the stored value is unreadable. */ + suspend fun load(): PersistedWindowState? = runCatching { + val raw = dataStore.data.first()[DatabaseKeys.WINDOW_STATE_KEY] ?: return null + json.decodeFromString(raw) + }.getOrNull() + + suspend fun save(state: PersistedWindowState) { + runCatching { + dataStore.edit { prefs -> + prefs[DatabaseKeys.WINDOW_STATE_KEY] = json.encodeToString(PersistedWindowState.serializer(), state) + } + } + } +} + +/** + * Keeps a restored [size] usable: never smaller than [min], and never bigger than the + * available screen ([maxWidth] x [maxHeight]) so a size saved on a larger monitor can't open + * off-screen. A null max means the screen size is unknown, so only [min] is enforced. + */ +fun DpSize.coerceForScreen( + min: DpSize, + maxWidth: Dp?, + maxHeight: Dp? +): DpSize { + val width = width.coerceAtLeast(min.width).let { if (maxWidth != null) it.coerceAtMost(maxOf(maxWidth, min.width)) else it } + val height = height.coerceAtLeast(min.height).let { if (maxHeight != null) it.coerceAtMost(maxOf(maxHeight, min.height)) else it } + return DpSize(width, height) +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.jvm.kt new file mode 100644 index 00000000..3591c937 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.jvm.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.zipline + +import kotlinx.coroutines.asCoroutineDispatcher +import java.util.concurrent.Executors + +actual fun createZiplineDispatcher(): ZiplineDispatcher { + val executor = Executors.newSingleThreadExecutor { runnable -> + Thread(null, runnable, "Zipline", 8L * 1024 * 1024) // 8 MiB stack for QuickJS compile() + } + val dispatcher = executor.asCoroutineDispatcher() + return ZiplineDispatcher(dispatcher) { + dispatcher.close() + executor.shutdown() + } +} + diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt new file mode 100644 index 00000000..a8d10f0e --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt @@ -0,0 +1,225 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.rememberWindowState +import org.jetbrains.compose.resources.painterResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.spotube_logo +import dev.nucleusframework.application.DecoratedWindow +import dev.nucleusframework.application.NucleusBackend +import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.graalvm.GraalVmInitializer +import dev.nucleusframework.window.tao.TaoScreenGeometry +import io.github.vinceglb.filekit.FileKit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.spotube.spotube.core.appimage.AppImageEnvironment +import org.spotube.spotube.core.di.initKoin +import org.spotube.spotube.core.i18n.AppLocaleProvider +import org.spotube.spotube.core.newpipe.NewPipeDownloader +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.systemtray.SystemTray +import org.spotube.spotube.core.systemtray.SystemTrayService +import org.spotube.spotube.core.ui.component.LocalApplicationScope +import org.spotube.spotube.core.ui.component.LocalWindowScope +import org.spotube.spotube.core.ui.component.LocalWindowState +import org.spotube.spotube.core.window.PersistedWindowState +import org.spotube.spotube.core.window.WindowStateRepository +import org.spotube.spotube.core.window.coerceForScreen +import org.spotube.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.modules.settings.SupportedLanguages + +object KoinPathsProvider : KoinComponent { + val paths: Paths get() = get() +} + +private object KoinServicesProvider : KoinComponent { + val systemTrayService: SystemTrayService get() = get() + val settingsProvider: SettingsProvider get() = get() + val windowStateRepository: WindowStateRepository get() = get() +} + +private const val WINDOW_SCREEN_FRACTION = 0.85f +private const val WINDOW_ASPECT_RATIO = 16f / 10f +private val MAX_WINDOW_WIDTH = 1440.dp +private val MAX_WINDOW_HEIGHT = 960.dp +private val MIN_WINDOW_SIZE = DpSize(800.dp, 500.dp) +private const val WINDOW_STATE_SAVE_DEBOUNCE_MS = 500L + +/** Largest [WINDOW_ASPECT_RATIO] size that fits inside [maxWidth] x [maxHeight]. */ +private fun fitAspectRatio(maxWidth: Dp, maxHeight: Dp): DpSize { + val width = minOf(maxWidth, maxHeight * WINDOW_ASPECT_RATIO) + return DpSize(width, width / WINDOW_ASPECT_RATIO) +} + +/** + * A 16:10 window that fits in 85% of the primary monitor's work area (screen minus + * taskbar/dock), capped at 1440x960. + * + * Uses Nucleus' Tao screen geometry rather than AWT, which doesn't exist on the Tao runtime. + * The work area is in physical pixels, so it's divided by the monitor scale factor to get dp. + * Falls back to the cap box when the platform bridge can't report a monitor. + */ +private fun initialWindowSize(): DpSize { + val workArea = availableScreenSize() + ?: return fitAspectRatio(MAX_WINDOW_WIDTH, MAX_WINDOW_HEIGHT) + + return fitAspectRatio( + maxWidth = (workArea.width * WINDOW_SCREEN_FRACTION).coerceAtMost(MAX_WINDOW_WIDTH), + maxHeight = (workArea.height * WINDOW_SCREEN_FRACTION).coerceAtMost(MAX_WINDOW_HEIGHT)) +} + +/** The primary monitor's work area in dp, or null when the platform bridge can't report it. */ +private fun availableScreenSize(): DpSize? { + val workArea = runCatching { TaoScreenGeometry.primaryMonitorWorkAreaPx() }.getOrNull() + if (workArea == null || workArea.size < 4 || workArea[2] <= 0 || workArea[3] <= 0) return null + + val scale = runCatching { TaoScreenGeometry.primaryMonitorScaleFactor() } + .getOrDefault(1f) + .takeIf { it > 0f } ?: 1f + + return DpSize((workArea[2] / scale).dp, (workArea[3] / scale).dp) +} + +/** Saved size if there is one (clamped to this screen), otherwise the default for a first launch. */ +private fun restoreWindowState(saved: PersistedWindowState?): PersistedWindowState { + if (saved == null || saved.widthDp <= 0f || saved.heightDp <= 0f) { + return PersistedWindowState.of(initialWindowSize(), WindowPlacement.Floating) + } + val screen = availableScreenSize() + val size = saved.size.coerceForScreen( + min = MIN_WINDOW_SIZE, + maxWidth = screen?.width, + maxHeight = screen?.height) + return PersistedWindowState.of(size, saved.placement) +} + +@OptIn(ExperimentalComposeUiApi::class, FlowPreview::class) +fun main() { + // Sets compose.application.resources.dir in the native image. initKoin() creates the audio + // player, whose VLC loader reads it, so it can't wait for nucleusApplication() (which calls + // this again, harmlessly). Without it the bundled VLC is skipped for the system one. + GraalVmInitializer.initialize() + AppImageEnvironment.apply() + FileKit.init(appId = "org.spotube.spotube") + initKoin() + NewPipeDownloader.init(KoinPathsProvider.paths) + val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + nucleusApplication(backend = NucleusBackend.Tao) { + val windowStateRepository = remember { KoinServicesProvider.windowStateRepository } + // Needed before the window is created, so the one-time read at startup is blocking + val restored = remember { restoreWindowState(runBlocking { windowStateRepository.load() }) } + val windowState = rememberWindowState(placement = restored.placement, size = restored.size) + + // The size to restore into when un-maximizing; only floating windows update it + var normalSize by remember { mutableStateOf(restored.size) } + val currentWindowState = { PersistedWindowState.of(normalSize, windowState.placement) } + // Exit paths can't wait on a coroutine that is about to be cancelled + val persistWindowStateBlocking = { runBlocking { windowStateRepository.save(currentWindowState()) } } + + LaunchedEffect(windowState) { + snapshotFlow { windowState.placement to windowState.size } + .onEach { (placement, size) -> + if (placement == WindowPlacement.Floating && size.isSpecified && + size.width > 0.dp && size.height > 0.dp) { + normalSize = size + } + } + .debounce(WINDOW_STATE_SAVE_DEBOUNCE_MS) + .collect { windowStateRepository.save(currentWindowState()) } + } + + val settingsProvider = KoinServicesProvider.settingsProvider + val settings by settingsProvider.settingsState.collectAsState(initial = null) + val minimizeToTray = settings?.minimizeToTray ?: false + + val trayService = KoinServicesProvider.systemTrayService + + var isWindowVisible by remember { mutableStateOf(true) } + + val onToggleWindowVisibility = { isWindowVisible = !isWindowVisible } + val onExit = { + persistWindowStateBlocking() + trayService.close() + appScope.cancel() + exitApplication() + } + + trayService.setCallbacks(onToggleWindowVisibility) + + if (minimizeToTray) { + // The tray lives outside App(), so it needs its own provider to follow the app language. + AppLocaleProvider(settings?.language ?: SupportedLanguages.SYSTEM) { + SystemTray( + isWindowVisible = isWindowVisible, + onToggleWindowVisibility = onToggleWindowVisibility, + onExit = onExit + ) + } + } + + DecoratedWindow( + state = windowState, + onCloseRequest = { + if (minimizeToTray) { + isWindowVisible = false + } else { + persistWindowStateBlocking() + trayService.close() + appScope.cancel() + exitApplication() + } + }, + title = "Spotube", + icon = painterResource(Res.drawable.spotube_logo), + visible = isWindowVisible + ) { + CompositionLocalProvider( + LocalApplicationScope provides this@nucleusApplication, + LocalWindowScope provides this@DecoratedWindow, + LocalWindowState provides windowState + ) { + App() + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.jvm.kt new file mode 100644 index 00000000..a51ee801 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.jvm.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.downloads + +// No system media index to update; files are picked up from the folder directly. +internal actual fun addToMediaLibrary(path: String) = Unit diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt new file mode 100644 index 00000000..d7dd7ab0 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.local_tracks.media + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds +import java.nio.file.WatchKey +import java.util.Locale +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong +import kotlin.io.path.absolutePathString +import kotlin.io.path.exists +import kotlin.io.path.extension +import kotlin.io.path.isDirectory +import kotlin.io.path.nameWithoutExtension + +class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { + override suspend fun discoverFolders(roots: List): List = withContext(Dispatchers.IO) { + val resolvedRoots = resolveRoots(roots) + if (resolvedRoots.isEmpty()) { + return@withContext emptyList() + } + + val folders = linkedMapOf>() + val scanned = AtomicLong(0) + + resolvedRoots.forEach { root -> + try { + Files.walk(root).use { stream -> + stream + .filter { path -> !Files.isDirectory(path) } + .filter { path -> isSupportedAudioExtension(path.extension) } + .forEach { filePath -> + if (scanned.incrementAndGet() > MAX_FILES_PER_SCAN) return@forEach + val parent = filePath.parent?.absolutePathString() ?: return@forEach + val trackPath = filePath.toAbsolutePath().toString() + + val metadata = try { + uniffi.compose_app.readAudioMetadata(trackPath) + } catch (_: Exception) { + null + } + + val track = LocalMediaTrack( + path = trackPath, + name = metadata?.title?.takeIf { it.isNotBlank() } + ?: filePath.nameWithoutExtension.ifBlank { + filePath.fileName.toString() + }, + artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(), + durationMs = metadata?.durationMs ?: 0L, + album = metadata?.album, + coverBytes = metadata?.coverBytes) + folders.getOrPut(parent) { mutableListOf() }.add(track) + } + } + } catch (e: Exception) { + println("Debug: Skipping unreadable directory ${root}: ${e.message}") + } + } + + folders.entries + .map { (path, tracks) -> + LocalMediaFolder( + path = path, + name = Path.of(path).fileName?.toString().orEmpty().ifBlank { path }, + tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) + } + .sortedBy { it.name.lowercase(Locale.getDefault()) } + } + + override fun observeChanges( + roots: List, + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { + val resolvedRoots = resolveRoots(roots) + if (resolvedRoots.isEmpty()) return null + + val watchService = FileSystems.getDefault().newWatchService() + val watchedDirs = mutableSetOf() + resolvedRoots.forEach { root -> + try { + Files.walk(root).use { stream -> + stream.filter { it.isDirectory() }.forEach { dir -> + if (watchedDirs.add(dir)) { + dir.register( + watchService, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY) + } + } + } + } catch (e: Exception) { + println("Debug: Skipping unwatchable directory ${root}: ${e.message}") + } + } + + val executor = Executors.newSingleThreadExecutor() + val lastTriggeredAt = AtomicLong(0) + executor.submit { + while (!Thread.currentThread().isInterrupted) { + val key: WatchKey = try { + watchService.poll(500, TimeUnit.MILLISECONDS) ?: continue + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + break + } + + if (key.pollEvents().isNotEmpty()) { + val now = System.currentTimeMillis() + val previous = lastTriggeredAt.get() + if (now - previous >= WATCH_DEBOUNCE_MS && lastTriggeredAt.compareAndSet(previous, now)) { + onChanged() + } + } + + if (!key.reset()) { + break + } + } + } + + return LocalMediaObservation { + executor.shutdownNow() + runCatching { watchService.close() } + } + } + + private fun isSupportedAudioExtension(extension: String): Boolean { + return extension.lowercase(Locale.getDefault()) in SUPPORTED_EXTENSIONS + } + + private fun resolveRoots(roots: List): List { + val customRoots = roots + .asSequence() + .map { it.trim() } + .filter { it.isNotBlank() } + .map { Path.of(it) } + .distinct() + .toList() + + val home = System.getProperty("user.home").orEmpty() + val defaults = if (home.isBlank()) { + emptyList() + } else { + listOf( + Path.of(home, "Music"), + Path.of(home, "Downloads")) + } + + return (defaults + customRoots) + .distinct() + .filter { it.exists() && it.isDirectory() && Files.isReadable(it) } + } + + companion object { + private const val WATCH_DEBOUNCE_MS = 2_500L + private const val MAX_FILES_PER_SCAN = 100_000L + private val SUPPORTED_EXTENSIONS = setOf( + "mp3", + "m4a", + "aac", + "flac", + "wav", + "ogg", + "opus", + "wma") + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt new file mode 100644 index 00000000..db540813 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.library.local_tracks.media + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState { + return LocalMediaPermissionState( + isGranted = true, + requestPermission = {}) +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerTest.kt new file mode 100644 index 00000000..f78eb438 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerTest.kt @@ -0,0 +1,444 @@ +package org.spotube.spotube.core.audioplayer + +import co.touchlab.kermit.Logger +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class AudioPlayerTest { + private lateinit var player: AudioPlayer + private lateinit var testAudioDir: File + private val audioFiles = mutableListOf() + + @BeforeTest + fun setup() { + runCatching { stopKoin() } + startKoin { + modules(module { + factory { (tag: String?) -> Logger.withTag(tag ?: "test") } + }) + } + val vlcNativesDir = File("build/vlc-natives/windows-x64") + if (vlcNativesDir.isDirectory) { + System.setProperty("compose.application.resources.dir", vlcNativesDir.absolutePath) + } + testAudioDir = findTestAudioDir() + val files = testAudioDir.listFiles() + ?.filter { it.isFile && it.extension in setOf("mp3", "flac", "wav", "ogg", "m4a") } + ?.sortedBy { it.name } + ?: emptyList() + require(files.size >= 5) { + "Need at least 5 audio files in $testAudioDir, found ${files.size}. " + + "Place .mp3/.flac/.wav/.ogg/.m4a files in composeApp/src/jvmTest/resources/test-audio/" + } + audioFiles.addAll(files) + player = AudioPlayer(Any()) + } + + @AfterTest + fun teardown() { + runCatching { if (::player.isInitialized) player.dispose() } + runCatching { stopKoin() } + } + + private fun findTestAudioDir(): File { + val candidates = listOf( + File("src/jvmTest/resources/test-audio"), + File("../composeApp/src/jvmTest/resources/test-audio"), + File(System.getProperty("user.dir"), "src/jvmTest/resources/test-audio")) + for (dir in candidates) { + if (dir.isDirectory) return dir + } + return candidates.first() + } + + private fun mediaItem(url: String, title: String): MediaItem { + return MediaItem( + title = title, + artist = "Test Artist", + album = "Test Album", + duration = 10.seconds, + coverURL = "", + url = url, + protocol = StreamProtocol.PROGRESSIVE) + } + + private fun testItems(count: Int = 3): List { + return (1..count).map { i -> + mediaItem("file:///test/unique-$i.wav", "Track $i") + } + } + + private fun realAudioItems(count: Int = 1): List { + return (1..count).map { i -> + val file = audioFiles.getOrNull(i - 1) ?: audioFiles.first() + mediaItem(file.absolutePath, file.nameWithoutExtension) + } + } + + // Wait for player to reach a target state + private fun awaitState(target: PlayerState, timeoutMs: Long = 4000) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + if (player.playerStateFlow.value == target) return + runBlocking { delay(50.milliseconds) } + } + } + + // --- Playback --- + + @Test + fun `play transitions to PLAYING`() = runBlocking { + withTimeout(10000.milliseconds) { + player.load(realAudioItems(1), autoPlay = false, startPosition = 0) + assertEquals(PlayerState.PAUSED, player.playerStateFlow.value) + + player.play() + awaitState(PlayerState.PLAYING) + + assertTrue(player.playerStateFlow.value == PlayerState.PLAYING || + player.playerStateFlow.value == PlayerState.COMPLETED, + "Expected PLAYING but got ${player.playerStateFlow.value}") + } + } + + @Test + fun `pause transitions to PAUSED`() = runBlocking { + withTimeout(10000.milliseconds) { + player.load(realAudioItems(1), autoPlay = false, startPosition = 0) + player.play() + delay(100.milliseconds) + val state = player.playerStateFlow.value + if (state == PlayerState.COMPLETED) return@withTimeout + awaitState(PlayerState.PLAYING) + player.pause() + delay(200.milliseconds) + + assertTrue(player.playerStateFlow.value == PlayerState.PAUSED || + player.playerStateFlow.value == PlayerState.COMPLETED, + "Expected PAUSED but got ${player.playerStateFlow.value}") + } + } + + @Test + fun `stop resets position and state`() = runBlocking { + withTimeout(10000.milliseconds) { + player.load(realAudioItems(1), autoPlay = false, startPosition = 0) + player.play() + awaitState(PlayerState.PLAYING) + delay(200.milliseconds) + player.stop() + delay(200.milliseconds) + + assertEquals(PlayerState.IDLE, player.playerStateFlow.value) + assertEquals(0L, player.positionFlow.value.inWholeMilliseconds) + } + } + + @Test + fun `seekTo changes position`() = runBlocking { + withTimeout(10000.milliseconds) { + player.load(realAudioItems(1), autoPlay = false, startPosition = 0) + player.play() + awaitState(PlayerState.PLAYING) + delay(200.milliseconds) + + player.seekTo(1000.milliseconds) + delay(300.milliseconds) + + val pos = player.positionFlow.value.inWholeMilliseconds + assertTrue(pos >= 800 && pos <= 2000 || pos == 0L && player.playerStateFlow.value == PlayerState.COMPLETED, + "Position $pos should be near 1000ms (state=${player.playerStateFlow.value})") + } + } + + // --- Shuffle (the bug fix) --- + + @Test + fun `shuffle enabled reorders playlist`() = runBlocking { + val items = testItems(3) + player.load(items, autoPlay = false, startPosition = 0) + val originalOrder = player.playlistFlow.value.map { it.url } + + player.shuffle(true) + + assertEquals(true, player.shuffleModeFlow.value) + val shuffledOrder = player.playlistFlow.value.map { it.url } + assertEquals(items.size, shuffledOrder.size) + assertTrue(shuffledOrder.containsAll(originalOrder)) + } + + @Test + fun `shuffle does NOT restart current track`() = runBlocking { + withTimeout(15000.milliseconds) { + val items = realAudioItems(2) + player.load(items, autoPlay = false, startPosition = 0) + player.play() + awaitState(PlayerState.PLAYING) + delay(500.milliseconds) + val posBefore = player.positionFlow.value.inWholeMilliseconds + if (posBefore <= 100 && player.playerStateFlow.value == PlayerState.COMPLETED) return@withTimeout + assertTrue(posBefore > 100, "Track should have progressed past 100ms (state=${player.playerStateFlow.value})") + + player.shuffle(true) + delay(200.milliseconds) + val posAfter = player.positionFlow.value.inWholeMilliseconds + + assertTrue(posAfter >= posBefore - 200, + "Position after shuffle ($posAfter) dropped vs before ($posBefore)") + } + } + + @Test + fun `shuffle disable restores original order`() = runBlocking { + val items = testItems(3) + player.load(items, autoPlay = false, startPosition = 0) + val originalOrder = player.playlistFlow.value.map { it.url } + + player.shuffle(true) + player.shuffle(false) + + assertEquals(false, player.shuffleModeFlow.value) + val restoredOrder = player.playlistFlow.value.map { it.url } + assertEquals(originalOrder, restoredOrder) + } + + @Test + fun `shuffle no-op when already shuffled`() = runBlocking { + val items = testItems(3) + player.load(items, autoPlay = false, startPosition = 0) + player.shuffle(true) + val orderAfterFirst = player.playlistFlow.value.map { it.url } + + player.shuffle(true) + + val orderAfterSecond = player.playlistFlow.value.map { it.url } + assertEquals(orderAfterFirst, orderAfterSecond) + } + + @Test + fun `shuffle preserves current item as first in shuffled list`() = runBlocking { + val items = testItems(4) + player.load(items, autoPlay = false, startPosition = 0) + + player.shuffle(true) + + val shuffled = player.playlistFlow.value + assertEquals(items[0].url, shuffled.first().url, + "Current item should be first in shuffled playlist") + } + + // --- Playlist management --- + + @Test + fun `load populates playlist and sets current item`() = runBlocking { + val items = testItems(2) + player.load(items, autoPlay = false, startPosition = 0) + + assertEquals(2, player.playlistFlow.value.size) + assertNotNull(player.currentMediaItemFlow.value) + assertEquals(items[0].url, player.currentMediaItemFlow.value?.url) + } + + @Test + fun `load with empty playlist clears state`() = runBlocking { + player.load(testItems(1), autoPlay = false, startPosition = 0) + + player.load(emptyList(), autoPlay = false, startPosition = 0) + + assertEquals(PlayerState.IDLE, player.playerStateFlow.value) + assertTrue(player.playlistFlow.value.isEmpty()) + } + + @Test + fun `load with autoPlay false stays paused`() = runBlocking { + player.load(testItems(1), autoPlay = false, startPosition = 0) + delay(200.milliseconds) + + assertEquals(PlayerState.PAUSED, player.playerStateFlow.value) + } + + @Test + fun `addMediaItem appends to playlist`() = runBlocking { + player.load(testItems(1), autoPlay = false, startPosition = 0) + val newItem = mediaItem(audioFiles.first().absolutePath, "Appended Track") + + player.addMediaItem(newItem) + + assertEquals(2, player.playlistFlow.value.size) + assertEquals("Appended Track", player.playlistFlow.value.last().title) + } + + @Test + fun `removeMediaItem removes by url`() = runBlocking { + val items = testItems(2) + player.load(items, autoPlay = false, startPosition = 0) + + player.removeMediaItem(items[0]) + + assertEquals(1, player.playlistFlow.value.size) + assertNotEquals(items[0].url, player.playlistFlow.value.first().url) + } + + @Test + fun `moveMediaItem reorders playlist`() = runBlocking { + val items = testItems(3) + player.load(items, autoPlay = false, startPosition = 0) + + player.moveMediaItem(0, 2) + + assertEquals(items[1].url, player.playlistFlow.value[0].url) + assertEquals(items[2].url, player.playlistFlow.value[1].url) + assertEquals(items[0].url, player.playlistFlow.value[2].url) + } + + // --- Navigation --- + + @Test + fun `skipToNext advances track`() = runBlocking { + withTimeout(15000.milliseconds) { + val items = realAudioItems(2) + player.load(items, autoPlay = false, startPosition = 0) + player.play() + awaitState(PlayerState.PLAYING) + delay(500.milliseconds) + val firstUrl = player.currentMediaItemFlow.value?.url + + player.skipToNext() + delay(1000.milliseconds) + val secondUrl = player.currentMediaItemFlow.value?.url + + assertNotEquals(firstUrl, secondUrl) + assertEquals(items[1].url, secondUrl) + } + } + + @Test + fun `jumpTo plays specific index`() = runBlocking { + withTimeout(10000.milliseconds) { + val items = realAudioItems(3) + player.load(items, autoPlay = true, startPosition = 0) + awaitState(PlayerState.PLAYING) + delay(500.milliseconds) + + player.jumpTo(2) + delay(1000.milliseconds) + + assertEquals(items[2].url, player.currentMediaItemFlow.value?.url) + } + } + + @Test + fun `jumpTo out of bounds is no-op`() = runBlocking { + withTimeout(10000.milliseconds) { + val items = testItems(2) + player.load(items, autoPlay = true, startPosition = 0) + awaitState(PlayerState.PLAYING) + delay(500.milliseconds) + val beforeUrl = player.currentMediaItemFlow.value?.url + + player.jumpTo(99) + delay(100.milliseconds) + + assertEquals(beforeUrl, player.currentMediaItemFlow.value?.url) + } + } + + // --- Other controls --- + + @Test + fun `loop sets repeat mode`() = runBlocking { + player.load(testItems(1), autoPlay = false, startPosition = 0) + + player.loop(LoopState.ONE) + assertEquals(LoopState.ONE, player.loopStateFlow.value) + + player.loop(LoopState.ALL) + assertEquals(LoopState.ALL, player.loopStateFlow.value) + + player.loop(LoopState.NONE) + assertEquals(LoopState.NONE, player.loopStateFlow.value) + } + + @Test + fun `setVolume changes volume`() = runBlocking { + player.setVolume(0.5f) + delay(100.milliseconds) + + assertTrue(player.volumeFlow.value in 0.45f..0.55f) + } + + @Test + fun `setPlaybackSpeed changes rate`() = runBlocking { + player.setPlaybackSpeed(2.0f) + delay(100.milliseconds) + + assertEquals(2.0f, player.playbackSpeedFlow.value) + } + + @Test + fun `dispose cleans up and marks disposed`() = runBlocking { + player.load(testItems(1), autoPlay = false, startPosition = 0) + player.dispose() + + assertTrue(player.isDisposed()) + assertEquals(PlayerState.IDLE, player.playerStateFlow.value) + assertNull(player.currentMediaItemFlow.value) + } + + // --- Edge cases --- + + @Test + fun `shuffle on empty playlist is no-op`() = runBlocking { + player.shuffle(true) + + assertEquals(false, player.shuffleModeFlow.value) + } + + @Test + fun `shuffle on empty playlist after load with empty`() = runBlocking { + player.load(emptyList(), autoPlay = false, startPosition = 0) + + player.shuffle(true) + + assertEquals(false, player.shuffleModeFlow.value) + } + + @Test + fun `multiple back-to-back shuffle toggles`() = runBlocking { + val items = testItems(3) + player.load(items, autoPlay = false, startPosition = 0) + + player.shuffle(true) + player.shuffle(false) + player.shuffle(true) + player.shuffle(false) + + assertEquals(false, player.shuffleModeFlow.value) + assertEquals(items.size, player.playlistFlow.value.size) + } + + @Test + fun `removeMediaItem on empty playlist does nothing`() = runBlocking { + val item = mediaItem(audioFiles.first().absolutePath, "Test") + + player.removeMediaItem(item) + + assertTrue(player.playlistFlow.value.isEmpty()) + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/i18n/AppLocaleProviderTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/i18n/AppLocaleProviderTest.kt new file mode 100644 index 00000000..9e30c275 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/i18n/AppLocaleProviderTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.i18n + +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.use +import org.spotube.spotube.modules.settings.SupportedLanguages +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_cancel +import spotube.composeapp.generated.resources.plugin_installed_plugins_count +import java.util.Locale +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalComposeUiApi::class) +class AppLocaleProviderTest { + @Test + fun switchingLanguageUpdatesStringsWithoutRestart() { + val systemLocale = Locale.getDefault() + var language by mutableStateOf(SupportedLanguages.DE) + var cancel = "" + var plugins = "" + var direction = LayoutDirection.Ltr + + try { + ImageComposeScene(width = 100, height = 100) { + AppLocaleProvider(language) { + val cancelText = stringResource(Res.string.common_cancel) + val pluginsText = pluralStringResource(Res.plurals.plugin_installed_plugins_count, 3, 3) + val layoutDirection = LocalLayoutDirection.current + SideEffect { + cancel = cancelText + plugins = pluginsText + direction = layoutDirection + } + } + }.use { scene -> + scene.render() + assertEquals("Abbrechen", cancel) + assertEquals("3 Plugins installiert", plugins) + // Non-composable lookups (ViewModels, services) follow the override too. + assertEquals("Abbrechen", runBlocking { getString(Res.string.common_cancel) }) + + language = SupportedLanguages.ZH_TW + scene.render() + assertEquals("取消", cancel) + assertEquals("已安裝 3 個外掛程式", plugins) + + language = SupportedLanguages.RU + scene.render() + assertEquals("3 плагина установлено", plugins) + + language = SupportedLanguages.AR + scene.render() + assertEquals(LayoutDirection.Rtl, direction) + + language = SupportedLanguages.SYSTEM + scene.render() + assertEquals(systemLocale, Locale.getDefault()) + } + } finally { + Locale.setDefault(systemLocale) + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/ServerBinderTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/ServerBinderTest.kt new file mode 100644 index 00000000..79e3a4c2 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/ServerBinderTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +package org.spotube.spotube.core.server + +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import kotlinx.coroutines.runBlocking +import java.net.InetAddress +import java.net.ServerSocket +import java.net.URI +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class ServerBinderTest { + private val host = "127.0.0.1" + + private fun health(port: Int) = + URI("http://$host:$port/health").toURL().readText() + + @Test + fun usesPreferredPortWhenFree() = runBlocking { + val free = ServerSocket(0, 1, InetAddress.getByName(host)).use { it.localPort } + val bound = startServerWithPortFallback(host, free) { + routing { get("/health") { call.respondText("ok") } } + } + try { + assertEquals(free, bound.port) + assertTrue(!bound.usedFallback) + assertEquals("ok", health(bound.port)) + } finally { + bound.engine.stop(0, 500) + } + } + + @Test + fun fallsBackToAnotherPortWhenPreferredIsTaken() = runBlocking { + ServerSocket(0, 1, InetAddress.getByName(host)).use { blocker -> + val taken = blocker.localPort + val failures = mutableListOf() + val bound = startServerWithPortFallback(host, taken, { port, _ -> failures += port }) { + routing { get("/health") { call.respondText("ok") } } + } + try { + assertNotEquals(taken, bound.port) + assertTrue(bound.usedFallback) + assertEquals(listOf(taken), failures) + assertEquals("ok", health(bound.port)) + } finally { + bound.engine.stop(0, 500) + } + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/window/WindowStateRepositoryTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/window/WindowStateRepositoryTest.kt new file mode 100644 index 00000000..6a8ded1d --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/window/WindowStateRepositoryTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.window + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import kotlinx.coroutines.runBlocking +import okio.Path.Companion.toPath +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WindowStateRepositoryTest { + private fun repository(): WindowStateRepository { + val dir = Files.createTempDirectory("window-state-test").toFile() + val file = File(dir, "window.preferences_pb") + return WindowStateRepository( + PreferenceDataStoreFactory.createWithPath(produceFile = { file.absolutePath.toPath() })) + } + + @Test + fun missingValueLoadsNull() = runBlocking { + assertNull(repository().load()) + } + + @Test + fun roundTripsNormalState() = runBlocking { + val store = repository() + store.save(PersistedWindowState.of(DpSize(1200.dp, 750.dp), WindowPlacement.Floating)) + + val loaded = store.load()!! + assertEquals(DpSize(1200.dp, 750.dp), loaded.size) + assertEquals(WindowPlacement.Floating, loaded.placement) + } + + @Test + fun roundTripsMaximizedStateKeepingNormalSize() = runBlocking { + val store = repository() + store.save(PersistedWindowState.of(DpSize(1100.dp, 700.dp), WindowPlacement.Maximized)) + + val loaded = store.load()!! + assertEquals(WindowPlacement.Maximized, loaded.placement) + assertEquals(DpSize(1100.dp, 700.dp), loaded.size) + } + + @Test + fun oversizedSavedSizeIsClampedToScreen() { + val clamped = DpSize(3000.dp, 2000.dp) + .coerceForScreen(DpSize(800.dp, 500.dp), maxWidth = 1800.dp, maxHeight = 1000.dp) + assertEquals(DpSize(1800.dp, 1000.dp), clamped) + } + + @Test + fun tinySavedSizeIsRaisedToMinimum() { + val clamped = DpSize(100.dp, 100.dp) + .coerceForScreen(DpSize(800.dp, 500.dp), maxWidth = 1800.dp, maxHeight = 1000.dp) + assertEquals(DpSize(800.dp, 500.dp), clamped) + } + + @Test + fun unknownScreenOnlyEnforcesMinimum() { + val clamped = DpSize(3000.dp, 2000.dp) + .coerceForScreen(DpSize(800.dp, 500.dp), maxWidth = null, maxHeight = null) + assertEquals(DpSize(3000.dp, 2000.dp), clamped) + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/jam/JamLayoutTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/jam/JamLayoutTest.kt new file mode 100644 index 00000000..7a67d3bb --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/jam/JamLayoutTest.kt @@ -0,0 +1,192 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.jam + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.unit.dp +import androidx.compose.ui.use +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.navigation.TOP_LEVEL_ROUTES +import org.spotube.spotube.core.navigation.rememberNavigationState +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.shell.AppShellViewModel +import org.spotube.spotube.modules.shell.FloatingPlayerContent +import org.spotube.spotube.modules.shell.FloatingPlayerBottomBleed +import org.spotube.spotube.modules.shell.PlayerUiState +import org.spotube.spotube.modules.shell.AppBottombar +import org.spotube.spotube.modules.shell.CompactPlayerOverlay +import org.spotube.millennium.composables.MillenniumTopAppBar +import org.jetbrains.compose.resources.stringResource +import com.composeunstyled.Text +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_screen_title +import org.spotube.millennium.theme.MillenniumTheme +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class JamLayoutTest { + @Test + fun measuredDockHeightIncludesSystemInsetExactlyOnce() { + val viewModel = AppShellViewModel() + viewModel.onBottomBarMeasured(76.dp) + assertEquals( + 140.dp, + viewModel.bottomOverlayInset(false, 0.dp, viewModel.compactChromeUiState.value) + ) + viewModel.onBottomBarMeasured(100.dp) + assertEquals( + 164.dp, + viewModel.bottomOverlayInset(false, 24.dp, viewModel.compactChromeUiState.value) + ) + viewModel.onBottomBarMeasured(0.dp) + assertEquals(100.dp, viewModel.compactChromeUiState.value.measuredBottomBarHeight) + } + + /** Actual Compose controls rendered without native-window capture or an MQTT connection. */ + @OptIn(ExperimentalComposeUiApi::class) + @Test + fun renderRoomControlsAtNarrowAndWideSizes() { + val output = File("build/reports/jam-ui").apply { mkdirs() } + val states = mapOf( + "create" to JamUiState( + brokerConfigured = true, + brokerHost = "test.mosquitto.org", + canCreate = true + ), + "join" to JamUiState( + brokerConfigured = true, + brokerHost = "test.mosquitto.org", + setupMode = JamSetupMode.Join + ), + "host" to JamUiState( + isActive = true, + isConnected = true, + isHost = true, + roomCode = "RC5FJ8", + brokerConfigured = true, + brokerHost = "test.mosquitto.org", + participants = listOf( + JamParticipantUi("host", "Host-8316", "H", true, false), + JamParticipantUi("guest", "Guest-6684", "G", false, true) + ) + ), + "guest" to JamUiState( + isActive = true, + isConnected = true, + roomCode = "RC5FJ8", + brokerConfigured = true, + brokerHost = "test.mosquitto.org", + participants = listOf(JamParticipantUi("host", "Host-8316", "H", true, false)) + ) + ) + for (width in listOf(360, 920)) { + for ((name, state) in states) { + var playerBottom = Float.NaN + var navigationTop = Float.NaN + var navigationBottom = Float.NaN + ImageComposeScene(width, 760) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + Column( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background), + horizontalAlignment = Alignment.CenterHorizontally + ) { + MillenniumTopAppBar(title = { Text(stringResource(Res.string.jam_screen_title)) }) + val navigation = rememberNavigationState(Routes.Home, TOP_LEVEL_ROUTES) + val navigator = remember(navigation) { Navigator(navigation) } + Box(Modifier.weight(1f).fillMaxWidth()) { + CompactPlayerOverlay( + navigator = navigator, + navigationState = navigation, + viewModel = remember { AppShellViewModel() }, + onQueue = {}, onAlternativeSource = {}, onExpandLyrics = {}, + floatingPlayer = { modifier -> + FloatingPlayerContent( + PlayerUiState(), + modifier = modifier.onGloballyPositioned { + playerBottom = it.boundsInRoot().bottom + }, + onPlayPause = {}, + heartButton = {}) + }, + bottomBar = { modifier -> + AppBottombar( + navigator, + navigation, + modifier = modifier.onGloballyPositioned { + navigationTop = it.boundsInRoot().top + navigationBottom = it.boundsInRoot().bottom + }, + libraryBadge = {}) + } + ) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + Modifier.widthIn(max = 520.dp).fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (state.isActive) SessionPanel( + state, + {}, + {}, + {}, + {}, + {}, + {}) + else SetupPanel(state, {}, {}, {}) + BrokerStatus(state, {}) + } + } + } + } + } + } + }.use { scene -> + repeat(8) { scene.render().close() } + assertTrue(playerBottom.isFinite()) + assertEquals( + playerBottom - FloatingPlayerBottomBleed.value, + navigationTop, + "Only the player's bottom surface bleed should overlap navigation" + ) + assertEquals( + 760f, + navigationBottom, + "Navigation must reach the window's bottom edge" + ) + scene.render().use { image -> + val data = assertNotNull(image.encodeToData()) + data.use { File(output, "$name-$width.png").writeBytes(it.bytes) } + } + } + } + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/LibraryToolbarTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/LibraryToolbarTest.kt new file mode 100644 index 00000000..4f397642 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/LibraryToolbarTest.kt @@ -0,0 +1,230 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.unit.dp +import androidx.compose.ui.use +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.spotube.millennium.composables.MillenniumTopAppBar +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class LibraryToolbarTest { + private lateinit var library: LibraryState + + @BeforeTest + fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + library = LibraryState() + } + + @AfterTest + fun cleanup() { + library.viewModelScope.cancel() + Dispatchers.resetMain() + } + + @Test + fun closingSearchClearsTheActiveFilter() = runTest { + library.openSearch() + library.uiState.value.searchState.setTextAndPlaceCursorAtEnd("Muse") + Snapshot.sendApplyNotifications() + runCurrent() + assertTrue(library.uiState.value.searchMode) + assertEquals("Muse", library.queryFlow(LibraryTab.Playlists).first()) + + library.closeSearch() + Snapshot.sendApplyNotifications() + runCurrent() + assertFalse(library.uiState.value.searchMode) + assertEquals("", library.uiState.value.searchState.text.toString()) + } + + @Test + fun eachTabRetainsItsOwnFilterAndActions() = runTest { + library.setQuery(LibraryTab.Playlists, "Rock") + library.onTabSelected(LibraryTab.Albums) + library.setQuery(LibraryTab.Albums, "Jazz") + Snapshot.sendApplyNotifications() + runCurrent() + assertEquals("Jazz", library.uiState.value.searchState.text.toString()) + assertFalse(library.uiState.value.canCreatePlaylist) + + library.onTabSelected(LibraryTab.Playlists) + runCurrent() + assertEquals("Rock", library.uiState.value.searchState.text.toString()) + assertTrue(library.uiState.value.canCreatePlaylist) + library.showCreatePlaylist() + runCurrent() + assertTrue(library.uiState.value.showCreatePlaylist) + + library.onTabSelected(LibraryTab.LocalTracks) + runCurrent() + assertTrue(library.uiState.value.canRefresh) + assertFalse(library.uiState.value.showCreatePlaylist) + library.onTabSelected(LibraryTab.Downloads) + runCurrent() + assertFalse(library.uiState.value.canFilter) + assertFalse(library.uiState.value.searchMode) + } + + @Test + fun anInlineDesktopQueryRemainsVisibleWhenTheLayoutBecomesCompact() = runTest { + library.setQuery(LibraryTab.Artists, "Radiohead") + library.onTabSelected(LibraryTab.Artists) + Snapshot.sendApplyNotifications() + runCurrent() + assertTrue(library.uiState.value.searchMode) + assertEquals("Radiohead", library.uiState.value.searchState.text.toString()) + } + + @Test + fun viewModeDefaultsAndOverridesArePerTab() = runTest { + assertEquals(LibraryViewMode.List, defaultLibraryViewMode(true)) + assertEquals(LibraryViewMode.Grid, defaultLibraryViewMode(false)) + library.toggleViewMode(isCompact = true) + runCurrent() + assertEquals(LibraryViewMode.Grid, library.uiState.value.viewModeOverride) + library.onTabSelected(LibraryTab.Albums) + runCurrent() + assertNull(library.uiState.value.viewModeOverride) + library.toggleViewMode(isCompact = false) + runCurrent() + assertEquals(LibraryViewMode.List, library.uiState.value.viewModeOverride) + library.onTabSelected(LibraryTab.Playlists) + runCurrent() + assertEquals(LibraryViewMode.Grid, library.uiState.value.viewModeOverride) + library.onTabSelected(LibraryTab.LocalTracks) + library.toggleViewMode(isCompact = true) + runCurrent() + assertFalse(library.uiState.value.canChangeView) + assertNull(library.uiState.value.viewModeOverride) + } + + @OptIn(ExperimentalComposeUiApi::class) + @Test + fun renderExpandedCompactAndSearchToolbars() { + val output = File("build/reports/library-toolbar").apply { mkdirs() } + for (tab in listOf(LibraryTab.Playlists, LibraryTab.Albums, LibraryTab.LocalTracks)) { + for ((mode, width) in listOf( + "desktop" to 575, + "mobile" to 360, + "search" to 360, + "compact-desktop" to 392, + "compact-desktop-search" to 392 + )) { + val expandedDesktop = mode == "desktop" + val searching = mode.contains("search") + val state = LibraryToolbarUiState( + currentTab = tab, + searchMode = searching, + searchState = TextFieldState(if (searching) "Muse" else ""), + canCreatePlaylist = tab == LibraryTab.Playlists, + canRefresh = tab == LibraryTab.LocalTracks, + canChangeView = tab != LibraryTab.LocalTracks + ) + ImageComposeScene(width, 88) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + MillenniumTopAppBar( + title = { + LibraryToolbarTitle( + state, + expandedDesktop, + mode.startsWith("compact-desktop") + ) + }, + actions = { + LibraryToolbarActions( + state, + expandedDesktop, + {}, + {}, + {}, + {}, + compactDesktop = mode.startsWith("compact-desktop") + ) + if (expandedDesktop || mode.startsWith("compact-desktop")) Spacer( + Modifier.width(132.dp) + ) + } + ) + } + }.use { scene -> + repeat(8) { scene.render().close() } + fun flatten(node: SemanticsNode): List = + listOf(node) + node.children.flatMap(::flatten) + + val nodes = + scene.semanticsOwners.flatMap { flatten(it.unmergedRootSemanticsNode) } + val descriptions = nodes.flatMap { + it.config.getOrNull(SemanticsProperties.ContentDescription).orEmpty() + } + val textFields = + nodes.filter { it.config.getOrNull(SemanticsProperties.EditableText) != null } + assertEquals(if (expandedDesktop || searching) 1 else 0, textFields.size) + if (searching) { + assertTrue("Close filter" in descriptions) + assertFalse("Create playlist" in descriptions) + assertFalse("Refresh local tracks" in descriptions) + assertEquals( + "Muse", + textFields.single().config[SemanticsProperties.EditableText].text + ) + } else { + assertEquals(tab == LibraryTab.Playlists, "Create playlist" in descriptions) + assertEquals( + tab == LibraryTab.LocalTracks, + "Refresh local tracks" in descriptions + ) + } + textFields.forEach { node -> + assertTrue( + node.boundsInRoot.left >= 0f && node.boundsInRoot.right <= width, + "Filter must fit beside desktop window controls" + ) + } + scene.render().use { image -> + assertNotNull(image.encodeToData()).use { + File( + output, + "${tab.name}-$mode.png" + ).writeBytes(it.bytes) + } + } + } + } + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt new file mode 100644 index 00000000..05d2aa04 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt @@ -0,0 +1,415 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.input.pointer.PointerButtons +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.unit.dp +import androidx.compose.ui.use +import org.spotube.spotube.core.ui.component.cards.AvatarCard +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.core.ui.component.cards.PlayableListTile +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.downloads.DownloadItem +import org.spotube.spotube.modules.downloads.DownloadRowAction +import org.spotube.spotube.modules.downloads.DownloadStatus +import org.spotube.spotube.modules.downloads.DownloadsContent +import org.spotube.spotube.modules.downloads.DownloadsToolbarTitle +import org.spotube.spotube.modules.downloads.DownloadsToolbarSummary +import org.spotube.spotube.modules.downloads.DownloadsToolbarActions +import org.spotube.spotube.modules.downloads.downloadsUiState +import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksState +import org.spotube.spotube.modules.library.local_tracks.LocalTracksContent +import org.spotube.spotube.modules.library.local_tracks.LocalTracksBreadcrumb +import org.spotube.spotube.modules.library.local_tracks.localTracksUiState +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFolder +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import java.io.File +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.composables.MillenniumTopAppBar +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.media_artist +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalComposeUiApi::class) +class MediaLibraryUiTest { + private val tracks = listOf( + LocalMediaTrack("/music/one.mp3", "Nishongota", listOf("Aurora"), 185_000, "Clancy"), + LocalMediaTrack( + "/music/two.mp3", + "Amay Prashna Kare Neel Dhrubatara", + listOf("Hemanta Mukherjee"), + 195_000 + ), + LocalMediaTrack("/downloads/three.mp3", "Whatever It Takes", emptyList(), 0) + ) + private val folders = listOf( + LocalMediaFolder("/music", "Music", tracks.take(2)), + LocalMediaFolder("/downloads", "Downloads", tracks.takeLast(1)) + ) + private val artists = listOf("Aurora", "Hemanta Mukherjee", "Imagine Dragons") + private val downloadItems = listOf( + DownloadItem("1", tracks[0].name, "Aurora", null, DownloadStatus.Downloading, 0.48f), + DownloadItem("2", tracks[1].name, "Hemanta Mukherjee", null, DownloadStatus.Completed), + DownloadItem( + "3", + tracks[2].name, + "Imagine Dragons", + null, + DownloadStatus.Failed("Connection lost") + ), + DownloadItem("4", "Clancy", "Twenty One Pilots", null, DownloadStatus.Queued) + ) + + @Test + fun downloadRowsHaveReadyProgressAndCorrectActions() { + val state = downloadsUiState(downloadItems) + assertEquals(2, state.activeCount) + assertEquals(1, state.completedCount) + assertTrue(state.canClearCompleted) + assertEquals(48, state.items[0].percent) + assertEquals(DownloadRowAction.Cancel, state.items[0].action) + assertEquals(DownloadRowAction.Remove, state.items[1].action) + assertEquals(DownloadRowAction.Retry, state.items[2].action) + assertEquals("Connection lost", state.items[2].error) + assertEquals( + 0f, + downloadsUiState(listOf(downloadItems[0].copy(progress = Float.NaN))).items.single().progress + ) + } + + @Test + fun localMediaFilteringAndMetadataAreComputedBeforeRendering() { + val base = LibraryLocalTracksState(folders = folders) + val root = localTracksUiState(base, "aurora") + assertEquals(listOf("Music"), root.visibleFolders.map { it.name }) + assertEquals(3, root.totalTrackCount) + val inFolder = localTracksUiState(base.copy(currentFolderPath = "/music"), "clancy") + assertEquals(listOf(tracks[0].name), inFolder.visibleTracks.map { it.title }) + assertEquals("3:05", inFolder.visibleTracks.single().durationLabel) + assertEquals("Aurora • Clancy", inFolder.visibleTracks.single().subtitle) + assertTrue(inFolder.canPlayFolder) + assertFalse( + localTracksUiState( + base.copy( + currentFolderPath = "/music", + canAccessMedia = false + ), "" + ).canPlayFolder + ) + } + + @Test + fun expandedDownloadsHasOneHeaderAndOneClearAction() { + var cleared = 0 + val state = downloadsUiState(downloadItems) + ImageComposeScene(960, 660) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + Column(Modifier.fillMaxSize()) { + MillenniumTopAppBar( + title = { DownloadsToolbarTitle() }, + subtitle = { DownloadsToolbarSummary(state) }, + actions = { DownloadsToolbarActions(state) { cleared++ } } + ) + DownloadsContent(state, {}, {}, {}, {}, Modifier.weight(1f), showHeader = false) + } + } + }.use { scene -> + repeat(6) { scene.render().close() } + val nodes = allNodes(scene) + val texts = nodes.flatMap { it.config.getOrNull(SemanticsProperties.Text).orEmpty() } + .map { it.text } + assertEquals(1, texts.count { it == "Downloads" }) + assertEquals(1, texts.count { it == "Clear completed" }) + val action = nodes.first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.Text) + ?.any { text -> text.text == "Clear completed" } == true + } + action.config[SemanticsActions.OnClick].action?.invoke() + assertEquals(1, cleared) + } + } + + @Test + fun breadcrumbIsCompactAndItsParentLinkNavigatesUp() { + var height = 0 + var navigatedUp = 0 + val state = localTracksUiState( + LibraryLocalTracksState( + folders = folders, + currentFolderPath = "/music" + ), "" + ) + ImageComposeScene(360, 48) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + LocalTracksBreadcrumb( + state, + { navigatedUp++ }, + {}, + Modifier.onGloballyPositioned { height = it.size.height }) + } + }.use { scene -> + repeat(6) { scene.render().close() } + assertEquals(40, height) + val parent = allNodes(scene).first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.ContentDescription) + ?.contains("Back to folders") == true + } + parent.config[SemanticsActions.OnClick].action?.invoke() + assertEquals(1, navigatedUp) + } + } + + @Test + fun renderScreensAndCollectionModesInLightAndDark() { + val output = File("build/reports/media-library-ui").apply { mkdirs() } + for (theme in listOf(Theme.DARK, Theme.LIGHT)) { + for (width in listOf(360, 960)) { + render(output, "downloads-$theme-$width", width, theme) { + val state = downloadsUiState(downloadItems) + if (width >= 840) Column(Modifier.fillMaxSize()) { + MillenniumTopAppBar( + title = { DownloadsToolbarTitle() }, + subtitle = { DownloadsToolbarSummary(state) }, + actions = { DownloadsToolbarActions(state, {}) }) + DownloadsContent( + state, + {}, + {}, + {}, + {}, + Modifier.weight(1f).padding(horizontal = 12.dp), + showHeader = false + ) + } else DownloadsContent( + state, + {}, + {}, + {}, + {}, + Modifier.padding(horizontal = 12.dp) + ) + } + render(output, "folders-$theme-$width", width, theme) { + LocalTracksContent( + localTracksUiState( + LibraryLocalTracksState(folders = folders), + "" + ), {}, {}, {}, {}, {}, Modifier.padding(horizontal = 12.dp) + ) + } + render(output, "tracks-$theme-$width", width, theme) { + LocalTracksContent( + localTracksUiState( + LibraryLocalTracksState( + folders = folders, + currentFolderPath = "/music" + ), "" + ), {}, {}, {}, {}, {}, Modifier.padding(horizontal = 12.dp) + ) + } + for (mode in LibraryViewMode.entries) render( + output, + "collection-$mode-$theme-$width", + width, + theme + ) { + LibraryMediaCollection( + items = tracks, + itemKey = { it.path }, + viewMode = mode, + onViewportChanged = { _, _ -> }, + itemContent = { item, list -> + PlayableCard( + item.name, + item.artists.joinToString(), + onClick = {}, + onPlay = {}, + onAddToQueue = {}, + listMode = list + ) + }, + skeleton = {}, + modifier = Modifier.padding(horizontal = 12.dp) + ) + } + for (mode in LibraryViewMode.entries) render( + output, + "artists-$mode-$theme-$width", + width, + theme + ) { + LibraryMediaCollection( + items = artists, + itemKey = { it }, + viewMode = mode, + onViewportChanged = { _, _ -> }, + itemContent = { artist, list -> + AvatarCard( + artist, + stringResource(Res.string.media_artist), + onClick = {}, + listMode = list + ) + }, + skeleton = {}, + modifier = Modifier.padding(horizontal = 12.dp) + ) + } + } + } + } + + @Test + fun bothCardTypesRespondToHoverAndClick() { + val output = File("build/reports/media-library-ui").apply { mkdirs() } + for (avatar in listOf(false, true)) { + var clicks = 0 + ImageComposeScene(192, 260) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + Box( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background) + .padding(16.dp) + ) { + if (avatar) AvatarCard( + tracks[0].artists.first(), + onClick = { clicks++ }, + modifier = Modifier.testTag("card") + ) + else PlayableCard( + tracks[0].name, + onClick = { clicks++ }, + modifier = Modifier.testTag("card") + ) + } + } + }.use { scene -> + repeat(4) { scene.render().close() } + val before = bytes(scene, 0) + scene.sendPointerEvent(PointerEventType.Enter, Offset(80f, 80f), timeMillis = 0) + scene.sendPointerEvent(PointerEventType.Move, Offset(80f, 80f), timeMillis = 1) + repeat(12) { scene.render((it + 1) * 16_000_000L).close() } + val hovered = bytes(scene, 250_000_000) + assertFalse( + before.contentEquals(hovered), + "Hover should visibly change the ${if (avatar) "avatar" else "playable"} card" + ) + File(output, "card-${if (avatar) "avatar" else "playable"}-hover.png").writeBytes( + hovered + ) + scene.sendPointerEvent( + PointerEventType.Press, + Offset(80f, 80f), + timeMillis = 260, + buttons = PointerButtons(isPrimaryPressed = true), + button = PointerButton.Primary + ) + scene.render(300_000_000).close() + scene.sendPointerEvent( + PointerEventType.Release, Offset(80f, 80f), timeMillis = 310, + buttons = PointerButtons(), button = PointerButton.Primary + ) + scene.render(350_000_000).close() + assertEquals(1, clicks) + } + } + } + + @Test + fun listPlaybackActionsDoNotTriggerNavigation() { + var navigations = 0 + var plays = 0 + ImageComposeScene(360, 100) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + PlayableListTile(tracks[0].name, onClick = { navigations++ }, onPlay = { plays++ }) + } + }.use { scene -> + repeat(4) { scene.render().close() } + val play = allNodes(scene).first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.ContentDescription) + ?.contains("Play") == true + } + val center = play.boundsInRoot.center + scene.sendPointerEvent( + PointerEventType.Press, + center, + buttons = PointerButtons(isPrimaryPressed = true), + button = PointerButton.Primary + ) + scene.sendPointerEvent( + PointerEventType.Release, + center, + buttons = PointerButtons(), + button = PointerButton.Primary + ) + scene.render().close() + assertEquals(1, plays) + assertEquals(0, navigations) + } + } + + private fun render( + output: File, + name: String, + width: Int, + theme: Theme, + content: @Composable () -> Unit + ) { + ImageComposeScene(width, 660) { + SpotubeTheme(UserSettings(theme = theme)) { + Box( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background) + ) { content() } + } + }.use { scene -> + repeat(6) { scene.render().close() } + if (name.startsWith("downloads")) { + val texts = allNodes(scene).flatMap { + it.config.getOrNull(SemanticsProperties.Text).orEmpty() + }.map { it.text } + assertTrue("Downloading 48%" in texts) + } + File(output, "$name.png").writeBytes(bytes(scene, 0)) + } + } + + private fun bytes(scene: ImageComposeScene, time: Long): ByteArray = + scene.render(time).use { image -> + assertNotNull(image.encodeToData()).use { it.bytes } + } + + private fun allNodes(scene: ImageComposeScene): List { + fun flatten(node: SemanticsNode): List = + listOf(node) + node.children.flatMap(::flatten) + return scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersEditorTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersEditorTest.kt new file mode 100644 index 00000000..1d34b589 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersEditorTest.kt @@ -0,0 +1,217 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.use +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.settings.components.LocalMediaFoldersEditorContent +import java.io.File +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.theme.MillenniumTheme +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_local_media_folders_title +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class LocalMediaFoldersEditorTest { + private val models = mutableListOf() + + @BeforeTest + fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun cleanup() { + models.forEach { it.viewModelScope.cancel() } + models.clear() + Dispatchers.resetMain() + } + + private fun model( + source: MutableStateFlow, + persist: suspend (List) -> Unit = {} + ) = + LocalMediaFoldersViewModel(source, persist).also { models.add(it) } + + @Test + fun addingFoldersDeduplicatesPathsAndCancelDiscardsTheDraft() = runTest { + val source = MutableStateFlow(UserSettings(localMediaFolders = listOf("/home/user/Music"))) + var writes = 0 + val editor = model(source) { writes++ } + editor.open() + editor.addFolder(" /home/user/Music/// ") + editor.addFolder(" ") + assertFalse(editor.uiState.value.canSave) + editor.addFolder("/home/user/Documents/Music Production/") + runCurrent() + assertEquals( + listOf("Music", "Music Production"), + editor.uiState.value.folders.map { it.name }) + assertTrue(editor.uiState.value.canSave) + editor.dismiss() + editor.addFolder("/late-picker-result") + editor.open() + runCurrent() + assertEquals(listOf("/home/user/Music"), editor.uiState.value.folders.map { it.path }) + assertEquals(0, writes) + } + + @Test + fun settingsUpdatesDoNotResetAnOpenDraft() = runTest { + val source = MutableStateFlow(UserSettings(localMediaFolders = listOf("/music"))) + val editor = model(source) + editor.open() + editor.addFolder("/new-music") + source.value = source.value.copy(overloadedDownloadFolder = "/downloads") + runCurrent() + assertEquals(listOf("/music", "/new-music"), editor.uiState.value.folders.map { it.path }) + assertTrue(editor.uiState.value.canSave) + } + + @Test + fun saveLocksTheDraftUntilPersistenceCompletes() = runTest { + val source = MutableStateFlow(UserSettings(localMediaFolders = listOf("/music"))) + val gate = CompletableDeferred() + var writes = 0 + val editor = model(source) { paths -> + writes++ + gate.await() + source.value = source.value.copy(localMediaFolders = paths) + } + editor.open() + editor.addFolder("/new-music") + editor.save() + editor.save() + editor.addFolder("/not-allowed-during-save") + editor.dismiss() + runCurrent() + assertTrue(editor.uiState.value.isSaving) + assertTrue(editor.uiState.value.isOpen) + assertFalse(editor.uiState.value.canSave) + assertEquals(2, editor.uiState.value.folders.size) + gate.complete(Unit) + runCurrent() + assertFalse(editor.uiState.value.isOpen) + assertEquals(listOf("/music", "/new-music"), source.value.localMediaFolders) + assertEquals(1, writes) + } + + @Test + fun failedSaveKeepsChangesForRetry() = runTest { + val source = MutableStateFlow(UserSettings()) + val editor = model(source) { throw IllegalStateException("Test storage failure") } + editor.open() + editor.addFolder("/music") + editor.save() + runCurrent() + assertTrue(editor.uiState.value.isOpen) + assertTrue(editor.uiState.value.saveFailed) + assertTrue(editor.uiState.value.canSave) + assertEquals(listOf("/music"), editor.uiState.value.folders.map { it.path }) + } + + @Test + fun rootPathsStayValid() { + assertEquals("/", normalizeMediaFolderPath(" / ")) + assertEquals("C:\\", normalizeMediaFolderPath("C:\\")) + assertEquals("C:/", normalizeMediaFolderPath("C:///")) + } + + @OptIn(ExperimentalComposeUiApi::class) + @Test + fun renderDialogAtCompactAndWideSizes() { + val output = File("build/reports/local-media-folders").apply { mkdirs() } + val source = + MutableStateFlow(UserSettings(localMediaFolders = listOf("/home/krtirtho/Documents/Music Production"))) + val editor = model(source) + editor.open() + editor.addFolder("/home/krtirtho/Music") + val oneFolder = editor.uiState.value.copy(folders = editor.uiState.value.folders.take(1)) + val cases = mapOf( + "empty" to oneFolder.copy(folders = emptyList()), + "one" to oneFolder, + "many" to oneFolder.copy(folders = (1..8).map { + LocalMediaFolderRowUi( + "/home/user/Music/Collection-$it", + "Collection-$it" + ) + }) + ) + for (theme in listOf(Theme.DARK, Theme.LIGHT)) { + for (width in listOf(360, 640)) { + for ((name, state) in cases) { + var added = 0 + ImageComposeScene(width, 660) { + SpotubeTheme(UserSettings(theme = theme)) { + Box( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background) + ) { + MillenniumDialog( + visible = true, + onDismissRequest = {}, + title = stringResource(Res.string.settings_local_media_folders_title) + ) { + LocalMediaFoldersEditorContent(state, { added++ }, {}, {}, {}) + } + } + } + }.use { scene -> + repeat(24) { scene.render(it * 16_000_000L).close() } + fun flatten(node: SemanticsNode): List = + listOf(node) + node.children.flatMap(::flatten) + + val nodes = scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) } + val add = nodes.first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.Text) + ?.any { text -> text.text == "Add folder" } == true + } + add.config[SemanticsActions.OnClick].action?.invoke() + assertEquals(1, added) + assertTrue( + add.boundsInRoot.right <= width, + "Actions must fit inside compact dialogs" + ) + scene.render(400_000_000).use { image -> + image.encodeToData()!!.use { + File( + output, + "$name-$theme-$width.png" + ).writeBytes(it.bytes) + } + } + } + } + } + } + } +} diff --git a/composeApp/src/jvmTest/resources/test-audio/.gitkeep b/composeApp/src/jvmTest/resources/test-audio/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.mobile.kt b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.mobile.kt new file mode 100644 index 00000000..9a812193 --- /dev/null +++ b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.mobile.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.statusBars +import org.spotube.millennium.composables.MillenniumTopAppBar +import androidx.compose.ui.graphics.Color +import androidx.compose.runtime.Composable + +@Composable +actual fun ApplicationMainBar( + title: @Composable (() -> Unit), + subtitle: @Composable () -> Unit, + actions: @Composable (RowScope.() -> Unit), + backButton: Boolean, + transparent: Boolean) { + return MillenniumTopAppBar( + title = title, + subtitle = subtitle, + actions = actions, + windowInsets = WindowInsets.statusBars, + navigationIcon = { + if (backButton) { + ApplicationBackButton() + } + } + ) +} diff --git a/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.mobile.kt b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.mobile.kt new file mode 100644 index 00000000..174696b7 --- /dev/null +++ b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.mobile.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +actual fun VerticalScrollbar( + listState: LazyListState, + modifier: Modifier +) { + // No-op on mobile targets. +} \ No newline at end of file diff --git a/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher.png b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..0c944468 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher.png differ diff --git a/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_foreground.png b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..cc0dfc4e Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_monochrome.png b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..aeac4559 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_round.png b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..aef4bb59 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher.png b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..b293b0c7 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher.png differ diff --git a/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_foreground.png b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..5af74804 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_monochrome.png b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..092f0ad8 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_round.png b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..bc1f2887 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher.png b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..3c50965a Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_foreground.png b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..41ff3ec9 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_monochrome.png b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..9214b754 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_round.png b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..b5a3aaf9 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher.png b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..6230862c Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_foreground.png b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..e828dd07 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..52bad80d Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_round.png b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..2383e45a Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..6542cf1d Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..47b16fcb Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..9e132a08 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_round.png b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..d67288fb Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/nightly/res/values/strings.xml b/composeApp/src/nightly/res/values/strings.xml new file mode 100644 index 00000000..18731555 --- /dev/null +++ b/composeApp/src/nightly/res/values/strings.xml @@ -0,0 +1,21 @@ + + + + + Spotube Nightly + diff --git a/devtools_options.yaml b/devtools_options.yaml deleted file mode 100644 index 7e7e7f67..00000000 --- a/devtools_options.yaml +++ /dev/null @@ -1 +0,0 @@ -extensions: diff --git a/distribute_options.yaml b/distribute_options.yaml deleted file mode 100644 index 153677e9..00000000 --- a/distribute_options.yaml +++ /dev/null @@ -1,41 +0,0 @@ -output: dist/ - -releases: - - name: dev - jobs: - # Generating a debian binary - - name: release-dev-linux-zip - package: - platform: linux - target: zip - build_args: - dart-define: - APP_ENV: dev - - name: release-dev-linux-deb - package: - platform: linux - target: deb - build_args: - dart-define: - APP_ENV: dev - - name: release-dev-linux-appimage - package: - platform: linux - target: appimage - build_args: - dart-define: - APP_ENV: dev - - name: release-dev-windows-exe - package: - platform: windows - target: exe - build_args: - dart-define: - APP_ENV: dev - - name: release-dev-macos-dmg - package: - platform: macos - target: dmg - build_args: - dart-define: - APP_ENV: dev diff --git a/drift_schemas/app_db/drift_schema_v1.json b/drift_schemas/app_db/drift_schema_v1.json deleted file mode 100644 index b894446c..00000000 --- a/drift_schemas/app_db/drift_schema_v1.json +++ /dev/null @@ -1 +0,0 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"authentication_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"cookie","getter_name":"cookie","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"access_token","getter_name":"accessToken","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"expiration","getter_name":"expiration","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"blacklist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"element_type","getter_name":"elementType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BlacklistedType.values)","dart_type_name":"BlacklistedType"}},{"name":"element_id","getter_name":"elementId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"preferences_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_quality","getter_name":"audioQuality","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceQualities.high.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceQualities.values)","dart_type_name":"SourceQualities"}},{"name":"album_color_sync","getter_name":"albumColorSync","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"album_color_sync\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"album_color_sync\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"amoled_dark_theme","getter_name":"amoledDarkTheme","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"amoled_dark_theme\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"amoled_dark_theme\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"check_update","getter_name":"checkUpdate","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"check_update\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"check_update\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"normalize_audio","getter_name":"normalizeAudio","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"normalize_audio\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"normalize_audio\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"show_system_tray_icon","getter_name":"showSystemTrayIcon","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"show_system_tray_icon\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"show_system_tray_icon\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"system_title_bar","getter_name":"systemTitleBar","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"system_title_bar\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"system_title_bar\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"skip_non_music","getter_name":"skipNonMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"skip_non_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"skip_non_music\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"close_behavior","getter_name":"closeBehavior","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(CloseBehavior.close.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(CloseBehavior.values)","dart_type_name":"CloseBehavior"}},{"name":"accent_color_scheme","getter_name":"accentColorScheme","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"Blue:0xFF2196F3\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SpotubeColorConverter()","dart_type_name":"SpotubeColor"}},{"name":"layout_mode","getter_name":"layoutMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(LayoutMode.adaptive.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LayoutMode.values)","dart_type_name":"LayoutMode"}},{"name":"locale","getter_name":"locale","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant('{\"languageCode\":\"system\",\"countryCode\":\"system\"}')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const LocaleConverter()","dart_type_name":"Locale"}},{"name":"market","getter_name":"market","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(Market.US.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(Market.values)","dart_type_name":"Market"}},{"name":"search_mode","getter_name":"searchMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SearchMode.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SearchMode.values)","dart_type_name":"SearchMode"}},{"name":"download_location","getter_name":"downloadLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[]},{"name":"local_library_location","getter_name":"localLibraryLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"piped_instance","getter_name":"pipedInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://pipedapi.kavin.rocks\")","default_client_dart":null,"dsl_features":[]},{"name":"theme_mode","getter_name":"themeMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(ThemeMode.system.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeMode.values)","dart_type_name":"ThemeMode"}},{"name":"audio_source","getter_name":"audioSource","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(AudioSource.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(AudioSource.values)","dart_type_name":"AudioSource"}},{"name":"stream_music_codec","getter_name":"streamMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.weba.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"download_music_codec","getter_name":"downloadMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.m4a.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"discord_presence","getter_name":"discordPresence","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"discord_presence\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"discord_presence\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"endless_playback","getter_name":"endlessPlayback","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"endless_playback\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"endless_playback\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"enable_connect","getter_name":"enableConnect","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"enable_connect\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"enable_connect\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":3,"references":[],"type":"table","data":{"name":"scrobbler_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"username","getter_name":"username","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"password_hash","getter_name":"passwordHash","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":4,"references":[],"type":"table","data":{"name":"skip_segment_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"start","getter_name":"start","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"end","getter_name":"end","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":5,"references":[],"type":"table","data":{"name":"source_match_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_id","getter_name":"sourceId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceType.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceType.values)","dart_type_name":"SourceType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":6,"references":[],"type":"table","data":{"name":"audio_player_state_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playing","getter_name":"playing","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"playing\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"playing\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"loop_mode","getter_name":"loopMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(PlaylistMode.values)","dart_type_name":"PlaylistMode"}},{"name":"shuffled","getter_name":"shuffled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"shuffled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"shuffled\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"collections","getter_name":"collections","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":7,"references":[6],"type":"table","data":{"name":"playlist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_player_state_id","getter_name":"audioPlayerStateId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES audio_player_state_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES audio_player_state_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"index","getter_name":"index","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":8,"references":[7],"type":"table","data":{"name":"playlist_media_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playlist_id","getter_name":"playlistId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES playlist_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES playlist_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"uri","getter_name":"uri","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"extras","getter_name":"extras","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}},{"name":"http_headers","getter_name":"httpHeaders","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":9,"references":[],"type":"table","data":{"name":"history_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(HistoryEntryType.values)","dart_type_name":"HistoryEntryType"}},{"name":"item_id","getter_name":"itemId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":10,"references":[],"type":"table","data":{"name":"lyrics_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"SubtitleTypeConverter()","dart_type_name":"SubtitleSimple"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":11,"references":[1],"type":"index","data":{"on":1,"name":"unique_blacklist","sql":null,"unique":true,"columns":["element_type","element_id"]}},{"id":12,"references":[5],"type":"index","data":{"on":5,"name":"uniq_track_match","sql":null,"unique":true,"columns":["track_id","source_id","source_type"]}}]} \ No newline at end of file diff --git a/drift_schemas/app_db/drift_schema_v10.json b/drift_schemas/app_db/drift_schema_v10.json deleted file mode 100644 index 5fb86d25..00000000 --- a/drift_schemas/app_db/drift_schema_v10.json +++ /dev/null @@ -1 +0,0 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"authentication_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"cookie","getter_name":"cookie","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"access_token","getter_name":"accessToken","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"expiration","getter_name":"expiration","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"blacklist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"element_type","getter_name":"elementType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BlacklistedType.values)","dart_type_name":"BlacklistedType"}},{"name":"element_id","getter_name":"elementId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"preferences_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"album_color_sync","getter_name":"albumColorSync","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"album_color_sync\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"album_color_sync\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"amoled_dark_theme","getter_name":"amoledDarkTheme","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"amoled_dark_theme\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"amoled_dark_theme\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"check_update","getter_name":"checkUpdate","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"check_update\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"check_update\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"normalize_audio","getter_name":"normalizeAudio","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"normalize_audio\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"normalize_audio\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"show_system_tray_icon","getter_name":"showSystemTrayIcon","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"show_system_tray_icon\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"show_system_tray_icon\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"system_title_bar","getter_name":"systemTitleBar","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"system_title_bar\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"system_title_bar\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"skip_non_music","getter_name":"skipNonMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"skip_non_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"skip_non_music\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"close_behavior","getter_name":"closeBehavior","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(CloseBehavior.close.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(CloseBehavior.values)","dart_type_name":"CloseBehavior"}},{"name":"accent_color_scheme","getter_name":"accentColorScheme","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"Slate:0xff64748b\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SpotubeColorConverter()","dart_type_name":"SpotubeColor"}},{"name":"layout_mode","getter_name":"layoutMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(LayoutMode.adaptive.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LayoutMode.values)","dart_type_name":"LayoutMode"}},{"name":"locale","getter_name":"locale","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant('{\"languageCode\":\"system\",\"countryCode\":\"system\"}')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const LocaleConverter()","dart_type_name":"Locale"}},{"name":"market","getter_name":"market","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(Market.US.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(Market.values)","dart_type_name":"Market"}},{"name":"search_mode","getter_name":"searchMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SearchMode.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SearchMode.values)","dart_type_name":"SearchMode"}},{"name":"download_location","getter_name":"downloadLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[]},{"name":"local_library_location","getter_name":"localLibraryLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"theme_mode","getter_name":"themeMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(ThemeMode.system.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeMode.values)","dart_type_name":"ThemeMode"}},{"name":"audio_source_id","getter_name":"audioSourceId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"youtube_client_engine","getter_name":"youtubeClientEngine","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(YoutubeClientEngine.youtubeExplode.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(YoutubeClientEngine.values)","dart_type_name":"YoutubeClientEngine"}},{"name":"discord_presence","getter_name":"discordPresence","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"discord_presence\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"discord_presence\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"endless_playback","getter_name":"endlessPlayback","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"endless_playback\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"endless_playback\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"enable_connect","getter_name":"enableConnect","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"enable_connect\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"enable_connect\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"connect_port","getter_name":"connectPort","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const Constant(-1)","default_client_dart":null,"dsl_features":[]},{"name":"cache_music","getter_name":"cacheMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"cache_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"cache_music\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":3,"references":[],"type":"table","data":{"name":"scrobbler_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"username","getter_name":"username","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"password_hash","getter_name":"passwordHash","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":4,"references":[],"type":"table","data":{"name":"skip_segment_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"start","getter_name":"start","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"end","getter_name":"end","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":5,"references":[],"type":"table","data":{"name":"source_match_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_info","getter_name":"sourceInfo","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"{}\")","default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":6,"references":[],"type":"table","data":{"name":"audio_player_state_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playing","getter_name":"playing","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"playing\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"playing\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"loop_mode","getter_name":"loopMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(PlaylistMode.values)","dart_type_name":"PlaylistMode"}},{"name":"shuffled","getter_name":"shuffled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"shuffled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"shuffled\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"collections","getter_name":"collections","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"tracks","getter_name":"tracks","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"[]\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SpotubeTrackObjectListConverter()","dart_type_name":"List"}},{"name":"current_index","getter_name":"currentIndex","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const Constant(0)","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":7,"references":[],"type":"table","data":{"name":"history_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(HistoryEntryType.values)","dart_type_name":"HistoryEntryType"}},{"name":"item_id","getter_name":"itemId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":8,"references":[],"type":"table","data":{"name":"lyrics_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"SubtitleTypeConverter()","dart_type_name":"SubtitleSimple"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":9,"references":[],"type":"table","data":{"name":"plugins_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[{"allowed-lengths":{"min":1,"max":50}}]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"version","getter_name":"version","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"author","getter_name":"author","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"entry_point","getter_name":"entryPoint","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"apis","getter_name":"apis","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"abilities","getter_name":"abilities","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"selected_for_metadata","getter_name":"selectedForMetadata","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"selected_for_metadata\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"selected_for_metadata\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"selected_for_audio_source","getter_name":"selectedForAudioSource","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"selected_for_audio_source\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"selected_for_audio_source\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"repository","getter_name":"repository","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"plugin_api_version","getter_name":"pluginApiVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant('2.0.0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":10,"references":[1],"type":"index","data":{"on":1,"name":"unique_blacklist","sql":null,"unique":true,"columns":["element_type","element_id"]}},{"id":11,"references":[5],"type":"index","data":{"on":5,"name":"uniq_track_match","sql":null,"unique":true,"columns":["track_id","source_info","source_type"]}}]} \ No newline at end of file diff --git a/drift_schemas/app_db/drift_schema_v2.json b/drift_schemas/app_db/drift_schema_v2.json deleted file mode 100644 index 668afb3f..00000000 --- a/drift_schemas/app_db/drift_schema_v2.json +++ /dev/null @@ -1 +0,0 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"authentication_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"cookie","getter_name":"cookie","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"access_token","getter_name":"accessToken","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"expiration","getter_name":"expiration","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"blacklist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"element_type","getter_name":"elementType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BlacklistedType.values)","dart_type_name":"BlacklistedType"}},{"name":"element_id","getter_name":"elementId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"preferences_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_quality","getter_name":"audioQuality","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceQualities.high.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceQualities.values)","dart_type_name":"SourceQualities"}},{"name":"album_color_sync","getter_name":"albumColorSync","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"album_color_sync\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"album_color_sync\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"amoled_dark_theme","getter_name":"amoledDarkTheme","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"amoled_dark_theme\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"amoled_dark_theme\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"check_update","getter_name":"checkUpdate","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"check_update\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"check_update\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"normalize_audio","getter_name":"normalizeAudio","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"normalize_audio\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"normalize_audio\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"show_system_tray_icon","getter_name":"showSystemTrayIcon","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"show_system_tray_icon\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"show_system_tray_icon\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"system_title_bar","getter_name":"systemTitleBar","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"system_title_bar\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"system_title_bar\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"skip_non_music","getter_name":"skipNonMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"skip_non_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"skip_non_music\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"close_behavior","getter_name":"closeBehavior","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(CloseBehavior.close.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(CloseBehavior.values)","dart_type_name":"CloseBehavior"}},{"name":"accent_color_scheme","getter_name":"accentColorScheme","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"Blue:0xFF2196F3\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SpotubeColorConverter()","dart_type_name":"SpotubeColor"}},{"name":"layout_mode","getter_name":"layoutMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(LayoutMode.adaptive.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LayoutMode.values)","dart_type_name":"LayoutMode"}},{"name":"locale","getter_name":"locale","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant('{\"languageCode\":\"system\",\"countryCode\":\"system\"}')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const LocaleConverter()","dart_type_name":"Locale"}},{"name":"market","getter_name":"market","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(Market.US.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(Market.values)","dart_type_name":"Market"}},{"name":"search_mode","getter_name":"searchMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SearchMode.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SearchMode.values)","dart_type_name":"SearchMode"}},{"name":"download_location","getter_name":"downloadLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[]},{"name":"local_library_location","getter_name":"localLibraryLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"piped_instance","getter_name":"pipedInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://pipedapi.kavin.rocks\")","default_client_dart":null,"dsl_features":[]},{"name":"invidious_instance","getter_name":"invidiousInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://inv.nadeko.net\")","default_client_dart":null,"dsl_features":[]},{"name":"theme_mode","getter_name":"themeMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(ThemeMode.system.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeMode.values)","dart_type_name":"ThemeMode"}},{"name":"audio_source","getter_name":"audioSource","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(AudioSource.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(AudioSource.values)","dart_type_name":"AudioSource"}},{"name":"stream_music_codec","getter_name":"streamMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.weba.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"download_music_codec","getter_name":"downloadMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.m4a.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"discord_presence","getter_name":"discordPresence","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"discord_presence\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"discord_presence\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"endless_playback","getter_name":"endlessPlayback","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"endless_playback\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"endless_playback\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"enable_connect","getter_name":"enableConnect","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"enable_connect\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"enable_connect\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":3,"references":[],"type":"table","data":{"name":"scrobbler_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"username","getter_name":"username","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"password_hash","getter_name":"passwordHash","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":4,"references":[],"type":"table","data":{"name":"skip_segment_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"start","getter_name":"start","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"end","getter_name":"end","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":5,"references":[],"type":"table","data":{"name":"source_match_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_id","getter_name":"sourceId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceType.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceType.values)","dart_type_name":"SourceType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":6,"references":[],"type":"table","data":{"name":"audio_player_state_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playing","getter_name":"playing","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"playing\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"playing\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"loop_mode","getter_name":"loopMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(PlaylistMode.values)","dart_type_name":"PlaylistMode"}},{"name":"shuffled","getter_name":"shuffled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"shuffled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"shuffled\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"collections","getter_name":"collections","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":7,"references":[6],"type":"table","data":{"name":"playlist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_player_state_id","getter_name":"audioPlayerStateId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES audio_player_state_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES audio_player_state_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"index","getter_name":"index","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":8,"references":[7],"type":"table","data":{"name":"playlist_media_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playlist_id","getter_name":"playlistId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES playlist_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES playlist_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"uri","getter_name":"uri","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"extras","getter_name":"extras","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}},{"name":"http_headers","getter_name":"httpHeaders","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":9,"references":[],"type":"table","data":{"name":"history_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(HistoryEntryType.values)","dart_type_name":"HistoryEntryType"}},{"name":"item_id","getter_name":"itemId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":10,"references":[],"type":"table","data":{"name":"lyrics_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"SubtitleTypeConverter()","dart_type_name":"SubtitleSimple"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":11,"references":[1],"type":"index","data":{"on":1,"name":"unique_blacklist","sql":null,"unique":true,"columns":["element_type","element_id"]}},{"id":12,"references":[5],"type":"index","data":{"on":5,"name":"uniq_track_match","sql":null,"unique":true,"columns":["track_id","source_id","source_type"]}}]} \ No newline at end of file diff --git a/drift_schemas/app_db/drift_schema_v3.json b/drift_schemas/app_db/drift_schema_v3.json deleted file mode 100644 index 93e0ef1b..00000000 --- a/drift_schemas/app_db/drift_schema_v3.json +++ /dev/null @@ -1 +0,0 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"authentication_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"cookie","getter_name":"cookie","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"access_token","getter_name":"accessToken","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"expiration","getter_name":"expiration","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"blacklist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"element_type","getter_name":"elementType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BlacklistedType.values)","dart_type_name":"BlacklistedType"}},{"name":"element_id","getter_name":"elementId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"preferences_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_quality","getter_name":"audioQuality","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceQualities.high.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceQualities.values)","dart_type_name":"SourceQualities"}},{"name":"album_color_sync","getter_name":"albumColorSync","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"album_color_sync\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"album_color_sync\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"amoled_dark_theme","getter_name":"amoledDarkTheme","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"amoled_dark_theme\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"amoled_dark_theme\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"check_update","getter_name":"checkUpdate","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"check_update\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"check_update\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"normalize_audio","getter_name":"normalizeAudio","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"normalize_audio\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"normalize_audio\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"show_system_tray_icon","getter_name":"showSystemTrayIcon","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"show_system_tray_icon\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"show_system_tray_icon\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"system_title_bar","getter_name":"systemTitleBar","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"system_title_bar\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"system_title_bar\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"skip_non_music","getter_name":"skipNonMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"skip_non_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"skip_non_music\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"close_behavior","getter_name":"closeBehavior","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(CloseBehavior.close.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(CloseBehavior.values)","dart_type_name":"CloseBehavior"}},{"name":"accent_color_scheme","getter_name":"accentColorScheme","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"Blue:0xFF2196F3\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SpotubeColorConverter()","dart_type_name":"SpotubeColor"}},{"name":"layout_mode","getter_name":"layoutMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(LayoutMode.adaptive.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LayoutMode.values)","dart_type_name":"LayoutMode"}},{"name":"locale","getter_name":"locale","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant('{\"languageCode\":\"system\",\"countryCode\":\"system\"}')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const LocaleConverter()","dart_type_name":"Locale"}},{"name":"market","getter_name":"market","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(Market.US.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(Market.values)","dart_type_name":"Market"}},{"name":"search_mode","getter_name":"searchMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SearchMode.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SearchMode.values)","dart_type_name":"SearchMode"}},{"name":"download_location","getter_name":"downloadLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[]},{"name":"local_library_location","getter_name":"localLibraryLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"piped_instance","getter_name":"pipedInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://pipedapi.kavin.rocks\")","default_client_dart":null,"dsl_features":[]},{"name":"invidious_instance","getter_name":"invidiousInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://inv.nadeko.net\")","default_client_dart":null,"dsl_features":[]},{"name":"theme_mode","getter_name":"themeMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(ThemeMode.system.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeMode.values)","dart_type_name":"ThemeMode"}},{"name":"audio_source","getter_name":"audioSource","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(AudioSource.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(AudioSource.values)","dart_type_name":"AudioSource"}},{"name":"stream_music_codec","getter_name":"streamMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.weba.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"download_music_codec","getter_name":"downloadMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.m4a.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"discord_presence","getter_name":"discordPresence","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"discord_presence\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"discord_presence\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"endless_playback","getter_name":"endlessPlayback","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"endless_playback\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"endless_playback\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"enable_connect","getter_name":"enableConnect","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"enable_connect\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"enable_connect\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"cache_music","getter_name":"cacheMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"cache_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"cache_music\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":3,"references":[],"type":"table","data":{"name":"scrobbler_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"username","getter_name":"username","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"password_hash","getter_name":"passwordHash","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":4,"references":[],"type":"table","data":{"name":"skip_segment_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"start","getter_name":"start","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"end","getter_name":"end","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":5,"references":[],"type":"table","data":{"name":"source_match_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_id","getter_name":"sourceId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceType.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceType.values)","dart_type_name":"SourceType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":6,"references":[],"type":"table","data":{"name":"audio_player_state_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playing","getter_name":"playing","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"playing\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"playing\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"loop_mode","getter_name":"loopMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(PlaylistMode.values)","dart_type_name":"PlaylistMode"}},{"name":"shuffled","getter_name":"shuffled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"shuffled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"shuffled\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"collections","getter_name":"collections","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":7,"references":[6],"type":"table","data":{"name":"playlist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_player_state_id","getter_name":"audioPlayerStateId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES audio_player_state_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES audio_player_state_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"index","getter_name":"index","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":8,"references":[7],"type":"table","data":{"name":"playlist_media_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playlist_id","getter_name":"playlistId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES playlist_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES playlist_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"uri","getter_name":"uri","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"extras","getter_name":"extras","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}},{"name":"http_headers","getter_name":"httpHeaders","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":9,"references":[],"type":"table","data":{"name":"history_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(HistoryEntryType.values)","dart_type_name":"HistoryEntryType"}},{"name":"item_id","getter_name":"itemId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":10,"references":[],"type":"table","data":{"name":"lyrics_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"SubtitleTypeConverter()","dart_type_name":"SubtitleSimple"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":11,"references":[1],"type":"index","data":{"on":1,"name":"unique_blacklist","sql":null,"unique":true,"columns":["element_type","element_id"]}},{"id":12,"references":[5],"type":"index","data":{"on":5,"name":"uniq_track_match","sql":null,"unique":true,"columns":["track_id","source_id","source_type"]}}]} \ No newline at end of file diff --git a/drift_schemas/app_db/drift_schema_v4.json b/drift_schemas/app_db/drift_schema_v4.json deleted file mode 100644 index fc50a6f8..00000000 --- a/drift_schemas/app_db/drift_schema_v4.json +++ /dev/null @@ -1 +0,0 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"authentication_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"cookie","getter_name":"cookie","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"access_token","getter_name":"accessToken","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"expiration","getter_name":"expiration","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"blacklist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"element_type","getter_name":"elementType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BlacklistedType.values)","dart_type_name":"BlacklistedType"}},{"name":"element_id","getter_name":"elementId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"preferences_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_quality","getter_name":"audioQuality","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceQualities.high.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceQualities.values)","dart_type_name":"SourceQualities"}},{"name":"album_color_sync","getter_name":"albumColorSync","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"album_color_sync\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"album_color_sync\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"amoled_dark_theme","getter_name":"amoledDarkTheme","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"amoled_dark_theme\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"amoled_dark_theme\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"check_update","getter_name":"checkUpdate","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"check_update\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"check_update\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"normalize_audio","getter_name":"normalizeAudio","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"normalize_audio\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"normalize_audio\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"show_system_tray_icon","getter_name":"showSystemTrayIcon","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"show_system_tray_icon\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"show_system_tray_icon\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"system_title_bar","getter_name":"systemTitleBar","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"system_title_bar\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"system_title_bar\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"skip_non_music","getter_name":"skipNonMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"skip_non_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"skip_non_music\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"close_behavior","getter_name":"closeBehavior","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(CloseBehavior.close.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(CloseBehavior.values)","dart_type_name":"CloseBehavior"}},{"name":"accent_color_scheme","getter_name":"accentColorScheme","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"Blue:0xFF2196F3\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SpotubeColorConverter()","dart_type_name":"SpotubeColor"}},{"name":"layout_mode","getter_name":"layoutMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(LayoutMode.adaptive.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LayoutMode.values)","dart_type_name":"LayoutMode"}},{"name":"locale","getter_name":"locale","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant('{\"languageCode\":\"system\",\"countryCode\":\"system\"}')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const LocaleConverter()","dart_type_name":"Locale"}},{"name":"market","getter_name":"market","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(Market.US.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(Market.values)","dart_type_name":"Market"}},{"name":"search_mode","getter_name":"searchMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SearchMode.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SearchMode.values)","dart_type_name":"SearchMode"}},{"name":"download_location","getter_name":"downloadLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[]},{"name":"local_library_location","getter_name":"localLibraryLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"piped_instance","getter_name":"pipedInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://pipedapi.kavin.rocks\")","default_client_dart":null,"dsl_features":[]},{"name":"invidious_instance","getter_name":"invidiousInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://inv.nadeko.net\")","default_client_dart":null,"dsl_features":[]},{"name":"theme_mode","getter_name":"themeMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(ThemeMode.system.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeMode.values)","dart_type_name":"ThemeMode"}},{"name":"audio_source","getter_name":"audioSource","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(AudioSource.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(AudioSource.values)","dart_type_name":"AudioSource"}},{"name":"youtube_client_engine","getter_name":"youtubeClientEngine","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(YoutubeClientEngine.youtubeExplode.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(YoutubeClientEngine.values)","dart_type_name":"YoutubeClientEngine"}},{"name":"stream_music_codec","getter_name":"streamMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.weba.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"download_music_codec","getter_name":"downloadMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.m4a.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"discord_presence","getter_name":"discordPresence","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"discord_presence\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"discord_presence\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"endless_playback","getter_name":"endlessPlayback","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"endless_playback\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"endless_playback\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"enable_connect","getter_name":"enableConnect","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"enable_connect\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"enable_connect\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"cache_music","getter_name":"cacheMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"cache_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"cache_music\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":3,"references":[],"type":"table","data":{"name":"scrobbler_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"username","getter_name":"username","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"password_hash","getter_name":"passwordHash","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":4,"references":[],"type":"table","data":{"name":"skip_segment_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"start","getter_name":"start","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"end","getter_name":"end","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":5,"references":[],"type":"table","data":{"name":"source_match_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_id","getter_name":"sourceId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceType.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceType.values)","dart_type_name":"SourceType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":6,"references":[],"type":"table","data":{"name":"audio_player_state_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playing","getter_name":"playing","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"playing\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"playing\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"loop_mode","getter_name":"loopMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(PlaylistMode.values)","dart_type_name":"PlaylistMode"}},{"name":"shuffled","getter_name":"shuffled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"shuffled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"shuffled\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"collections","getter_name":"collections","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":7,"references":[6],"type":"table","data":{"name":"playlist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_player_state_id","getter_name":"audioPlayerStateId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES audio_player_state_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES audio_player_state_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"index","getter_name":"index","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":8,"references":[7],"type":"table","data":{"name":"playlist_media_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playlist_id","getter_name":"playlistId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES playlist_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES playlist_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"uri","getter_name":"uri","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"extras","getter_name":"extras","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}},{"name":"http_headers","getter_name":"httpHeaders","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":9,"references":[],"type":"table","data":{"name":"history_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(HistoryEntryType.values)","dart_type_name":"HistoryEntryType"}},{"name":"item_id","getter_name":"itemId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":10,"references":[],"type":"table","data":{"name":"lyrics_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"SubtitleTypeConverter()","dart_type_name":"SubtitleSimple"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":11,"references":[1],"type":"index","data":{"on":1,"name":"unique_blacklist","sql":null,"unique":true,"columns":["element_type","element_id"]}},{"id":12,"references":[5],"type":"index","data":{"on":5,"name":"uniq_track_match","sql":null,"unique":true,"columns":["track_id","source_id","source_type"]}}]} \ No newline at end of file diff --git a/drift_schemas/app_db/drift_schema_v5.json b/drift_schemas/app_db/drift_schema_v5.json deleted file mode 100644 index eefe0205..00000000 --- a/drift_schemas/app_db/drift_schema_v5.json +++ /dev/null @@ -1 +0,0 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"authentication_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"cookie","getter_name":"cookie","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"access_token","getter_name":"accessToken","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"expiration","getter_name":"expiration","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"blacklist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"element_type","getter_name":"elementType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BlacklistedType.values)","dart_type_name":"BlacklistedType"}},{"name":"element_id","getter_name":"elementId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"preferences_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_quality","getter_name":"audioQuality","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceQualities.high.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceQualities.values)","dart_type_name":"SourceQualities"}},{"name":"album_color_sync","getter_name":"albumColorSync","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"album_color_sync\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"album_color_sync\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"amoled_dark_theme","getter_name":"amoledDarkTheme","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"amoled_dark_theme\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"amoled_dark_theme\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"check_update","getter_name":"checkUpdate","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"check_update\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"check_update\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"normalize_audio","getter_name":"normalizeAudio","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"normalize_audio\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"normalize_audio\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"show_system_tray_icon","getter_name":"showSystemTrayIcon","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"show_system_tray_icon\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"show_system_tray_icon\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"system_title_bar","getter_name":"systemTitleBar","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"system_title_bar\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"system_title_bar\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"skip_non_music","getter_name":"skipNonMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"skip_non_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"skip_non_music\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"close_behavior","getter_name":"closeBehavior","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(CloseBehavior.close.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(CloseBehavior.values)","dart_type_name":"CloseBehavior"}},{"name":"accent_color_scheme","getter_name":"accentColorScheme","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"Orange:0xFFf97315\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SpotubeColorConverter()","dart_type_name":"SpotubeColor"}},{"name":"layout_mode","getter_name":"layoutMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(LayoutMode.adaptive.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LayoutMode.values)","dart_type_name":"LayoutMode"}},{"name":"locale","getter_name":"locale","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant('{\"languageCode\":\"system\",\"countryCode\":\"system\"}')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const LocaleConverter()","dart_type_name":"Locale"}},{"name":"market","getter_name":"market","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(Market.US.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(Market.values)","dart_type_name":"Market"}},{"name":"search_mode","getter_name":"searchMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SearchMode.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SearchMode.values)","dart_type_name":"SearchMode"}},{"name":"download_location","getter_name":"downloadLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[]},{"name":"local_library_location","getter_name":"localLibraryLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"piped_instance","getter_name":"pipedInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://pipedapi.kavin.rocks\")","default_client_dart":null,"dsl_features":[]},{"name":"invidious_instance","getter_name":"invidiousInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://inv.nadeko.net\")","default_client_dart":null,"dsl_features":[]},{"name":"theme_mode","getter_name":"themeMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(ThemeMode.system.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeMode.values)","dart_type_name":"ThemeMode"}},{"name":"audio_source","getter_name":"audioSource","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(AudioSource.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(AudioSource.values)","dart_type_name":"AudioSource"}},{"name":"youtube_client_engine","getter_name":"youtubeClientEngine","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(YoutubeClientEngine.youtubeExplode.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(YoutubeClientEngine.values)","dart_type_name":"YoutubeClientEngine"}},{"name":"stream_music_codec","getter_name":"streamMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.weba.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"download_music_codec","getter_name":"downloadMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.m4a.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"discord_presence","getter_name":"discordPresence","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"discord_presence\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"discord_presence\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"endless_playback","getter_name":"endlessPlayback","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"endless_playback\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"endless_playback\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"enable_connect","getter_name":"enableConnect","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"enable_connect\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"enable_connect\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"cache_music","getter_name":"cacheMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"cache_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"cache_music\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":3,"references":[],"type":"table","data":{"name":"scrobbler_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"username","getter_name":"username","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"password_hash","getter_name":"passwordHash","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":4,"references":[],"type":"table","data":{"name":"skip_segment_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"start","getter_name":"start","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"end","getter_name":"end","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":5,"references":[],"type":"table","data":{"name":"source_match_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_id","getter_name":"sourceId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceType.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceType.values)","dart_type_name":"SourceType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":6,"references":[],"type":"table","data":{"name":"audio_player_state_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playing","getter_name":"playing","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"playing\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"playing\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"loop_mode","getter_name":"loopMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(PlaylistMode.values)","dart_type_name":"PlaylistMode"}},{"name":"shuffled","getter_name":"shuffled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"shuffled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"shuffled\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"collections","getter_name":"collections","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":7,"references":[6],"type":"table","data":{"name":"playlist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_player_state_id","getter_name":"audioPlayerStateId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES audio_player_state_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES audio_player_state_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"index","getter_name":"index","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":8,"references":[7],"type":"table","data":{"name":"playlist_media_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playlist_id","getter_name":"playlistId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES playlist_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES playlist_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"uri","getter_name":"uri","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"extras","getter_name":"extras","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}},{"name":"http_headers","getter_name":"httpHeaders","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":9,"references":[],"type":"table","data":{"name":"history_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(HistoryEntryType.values)","dart_type_name":"HistoryEntryType"}},{"name":"item_id","getter_name":"itemId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":10,"references":[],"type":"table","data":{"name":"lyrics_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"SubtitleTypeConverter()","dart_type_name":"SubtitleSimple"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":11,"references":[1],"type":"index","data":{"on":1,"name":"unique_blacklist","sql":null,"unique":true,"columns":["element_type","element_id"]}},{"id":12,"references":[5],"type":"index","data":{"on":5,"name":"uniq_track_match","sql":null,"unique":true,"columns":["track_id","source_id","source_type"]}}]} \ No newline at end of file diff --git a/drift_schemas/app_db/drift_schema_v6.json b/drift_schemas/app_db/drift_schema_v6.json deleted file mode 100644 index 8a646be1..00000000 --- a/drift_schemas/app_db/drift_schema_v6.json +++ /dev/null @@ -1 +0,0 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"authentication_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"cookie","getter_name":"cookie","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"access_token","getter_name":"accessToken","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"expiration","getter_name":"expiration","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"blacklist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"element_type","getter_name":"elementType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BlacklistedType.values)","dart_type_name":"BlacklistedType"}},{"name":"element_id","getter_name":"elementId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"preferences_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_quality","getter_name":"audioQuality","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceQualities.high.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceQualities.values)","dart_type_name":"SourceQualities"}},{"name":"album_color_sync","getter_name":"albumColorSync","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"album_color_sync\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"album_color_sync\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"amoled_dark_theme","getter_name":"amoledDarkTheme","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"amoled_dark_theme\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"amoled_dark_theme\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"check_update","getter_name":"checkUpdate","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"check_update\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"check_update\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"normalize_audio","getter_name":"normalizeAudio","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"normalize_audio\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"normalize_audio\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"show_system_tray_icon","getter_name":"showSystemTrayIcon","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"show_system_tray_icon\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"show_system_tray_icon\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"system_title_bar","getter_name":"systemTitleBar","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"system_title_bar\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"system_title_bar\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"skip_non_music","getter_name":"skipNonMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"skip_non_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"skip_non_music\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"close_behavior","getter_name":"closeBehavior","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(CloseBehavior.close.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(CloseBehavior.values)","dart_type_name":"CloseBehavior"}},{"name":"accent_color_scheme","getter_name":"accentColorScheme","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"Orange:0xFFf97315\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SpotubeColorConverter()","dart_type_name":"SpotubeColor"}},{"name":"layout_mode","getter_name":"layoutMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(LayoutMode.adaptive.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LayoutMode.values)","dart_type_name":"LayoutMode"}},{"name":"locale","getter_name":"locale","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant('{\"languageCode\":\"system\",\"countryCode\":\"system\"}')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const LocaleConverter()","dart_type_name":"Locale"}},{"name":"market","getter_name":"market","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(Market.US.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(Market.values)","dart_type_name":"Market"}},{"name":"search_mode","getter_name":"searchMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SearchMode.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SearchMode.values)","dart_type_name":"SearchMode"}},{"name":"download_location","getter_name":"downloadLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[]},{"name":"local_library_location","getter_name":"localLibraryLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"piped_instance","getter_name":"pipedInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://pipedapi.kavin.rocks\")","default_client_dart":null,"dsl_features":[]},{"name":"invidious_instance","getter_name":"invidiousInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://inv.nadeko.net\")","default_client_dart":null,"dsl_features":[]},{"name":"theme_mode","getter_name":"themeMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(ThemeMode.system.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeMode.values)","dart_type_name":"ThemeMode"}},{"name":"audio_source","getter_name":"audioSource","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(AudioSource.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(AudioSource.values)","dart_type_name":"AudioSource"}},{"name":"youtube_client_engine","getter_name":"youtubeClientEngine","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(YoutubeClientEngine.youtubeExplode.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(YoutubeClientEngine.values)","dart_type_name":"YoutubeClientEngine"}},{"name":"stream_music_codec","getter_name":"streamMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.weba.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"download_music_codec","getter_name":"downloadMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.m4a.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"discord_presence","getter_name":"discordPresence","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"discord_presence\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"discord_presence\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"endless_playback","getter_name":"endlessPlayback","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"endless_playback\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"endless_playback\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"enable_connect","getter_name":"enableConnect","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"enable_connect\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"enable_connect\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"connect_port","getter_name":"connectPort","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const Constant(-1)","default_client_dart":null,"dsl_features":[]},{"name":"cache_music","getter_name":"cacheMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"cache_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"cache_music\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":3,"references":[],"type":"table","data":{"name":"scrobbler_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"username","getter_name":"username","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"password_hash","getter_name":"passwordHash","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":4,"references":[],"type":"table","data":{"name":"skip_segment_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"start","getter_name":"start","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"end","getter_name":"end","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":5,"references":[],"type":"table","data":{"name":"source_match_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_id","getter_name":"sourceId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceType.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceType.values)","dart_type_name":"SourceType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":6,"references":[],"type":"table","data":{"name":"audio_player_state_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playing","getter_name":"playing","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"playing\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"playing\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"loop_mode","getter_name":"loopMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(PlaylistMode.values)","dart_type_name":"PlaylistMode"}},{"name":"shuffled","getter_name":"shuffled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"shuffled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"shuffled\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"collections","getter_name":"collections","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":7,"references":[6],"type":"table","data":{"name":"playlist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_player_state_id","getter_name":"audioPlayerStateId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES audio_player_state_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES audio_player_state_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"index","getter_name":"index","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":8,"references":[7],"type":"table","data":{"name":"playlist_media_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playlist_id","getter_name":"playlistId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES playlist_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES playlist_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"uri","getter_name":"uri","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"extras","getter_name":"extras","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}},{"name":"http_headers","getter_name":"httpHeaders","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":9,"references":[],"type":"table","data":{"name":"history_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(HistoryEntryType.values)","dart_type_name":"HistoryEntryType"}},{"name":"item_id","getter_name":"itemId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":10,"references":[],"type":"table","data":{"name":"lyrics_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"SubtitleTypeConverter()","dart_type_name":"SubtitleSimple"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":11,"references":[1],"type":"index","data":{"on":1,"name":"unique_blacklist","sql":null,"unique":true,"columns":["element_type","element_id"]}},{"id":12,"references":[5],"type":"index","data":{"on":5,"name":"uniq_track_match","sql":null,"unique":true,"columns":["track_id","source_id","source_type"]}}]} \ No newline at end of file diff --git a/drift_schemas/app_db/drift_schema_v7.json b/drift_schemas/app_db/drift_schema_v7.json deleted file mode 100644 index d6644857..00000000 --- a/drift_schemas/app_db/drift_schema_v7.json +++ /dev/null @@ -1 +0,0 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"authentication_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"cookie","getter_name":"cookie","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"access_token","getter_name":"accessToken","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"expiration","getter_name":"expiration","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"blacklist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"element_type","getter_name":"elementType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BlacklistedType.values)","dart_type_name":"BlacklistedType"}},{"name":"element_id","getter_name":"elementId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"preferences_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_quality","getter_name":"audioQuality","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceQualities.high.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceQualities.values)","dart_type_name":"SourceQualities"}},{"name":"album_color_sync","getter_name":"albumColorSync","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"album_color_sync\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"album_color_sync\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"amoled_dark_theme","getter_name":"amoledDarkTheme","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"amoled_dark_theme\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"amoled_dark_theme\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"check_update","getter_name":"checkUpdate","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"check_update\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"check_update\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"normalize_audio","getter_name":"normalizeAudio","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"normalize_audio\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"normalize_audio\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"show_system_tray_icon","getter_name":"showSystemTrayIcon","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"show_system_tray_icon\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"show_system_tray_icon\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"system_title_bar","getter_name":"systemTitleBar","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"system_title_bar\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"system_title_bar\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"skip_non_music","getter_name":"skipNonMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"skip_non_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"skip_non_music\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"close_behavior","getter_name":"closeBehavior","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(CloseBehavior.close.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(CloseBehavior.values)","dart_type_name":"CloseBehavior"}},{"name":"accent_color_scheme","getter_name":"accentColorScheme","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"Orange:0xFFf97315\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SpotubeColorConverter()","dart_type_name":"SpotubeColor"}},{"name":"layout_mode","getter_name":"layoutMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(LayoutMode.adaptive.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LayoutMode.values)","dart_type_name":"LayoutMode"}},{"name":"locale","getter_name":"locale","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant('{\"languageCode\":\"system\",\"countryCode\":\"system\"}')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const LocaleConverter()","dart_type_name":"Locale"}},{"name":"market","getter_name":"market","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(Market.US.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(Market.values)","dart_type_name":"Market"}},{"name":"search_mode","getter_name":"searchMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SearchMode.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SearchMode.values)","dart_type_name":"SearchMode"}},{"name":"download_location","getter_name":"downloadLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[]},{"name":"local_library_location","getter_name":"localLibraryLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"piped_instance","getter_name":"pipedInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://pipedapi.kavin.rocks\")","default_client_dart":null,"dsl_features":[]},{"name":"invidious_instance","getter_name":"invidiousInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://inv.nadeko.net\")","default_client_dart":null,"dsl_features":[]},{"name":"theme_mode","getter_name":"themeMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(ThemeMode.system.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeMode.values)","dart_type_name":"ThemeMode"}},{"name":"audio_source","getter_name":"audioSource","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(AudioSource.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(AudioSource.values)","dart_type_name":"AudioSource"}},{"name":"youtube_client_engine","getter_name":"youtubeClientEngine","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(YoutubeClientEngine.youtubeExplode.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(YoutubeClientEngine.values)","dart_type_name":"YoutubeClientEngine"}},{"name":"stream_music_codec","getter_name":"streamMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.weba.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"download_music_codec","getter_name":"downloadMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.m4a.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"discord_presence","getter_name":"discordPresence","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"discord_presence\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"discord_presence\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"endless_playback","getter_name":"endlessPlayback","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"endless_playback\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"endless_playback\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"enable_connect","getter_name":"enableConnect","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"enable_connect\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"enable_connect\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"connect_port","getter_name":"connectPort","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const Constant(-1)","default_client_dart":null,"dsl_features":[]},{"name":"cache_music","getter_name":"cacheMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"cache_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"cache_music\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":3,"references":[],"type":"table","data":{"name":"scrobbler_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"username","getter_name":"username","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"password_hash","getter_name":"passwordHash","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":4,"references":[],"type":"table","data":{"name":"skip_segment_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"start","getter_name":"start","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"end","getter_name":"end","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":5,"references":[],"type":"table","data":{"name":"source_match_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_id","getter_name":"sourceId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceType.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceType.values)","dart_type_name":"SourceType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":6,"references":[],"type":"table","data":{"name":"audio_player_state_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playing","getter_name":"playing","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"playing\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"playing\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"loop_mode","getter_name":"loopMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(PlaylistMode.values)","dart_type_name":"PlaylistMode"}},{"name":"shuffled","getter_name":"shuffled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"shuffled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"shuffled\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"collections","getter_name":"collections","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"tracks","getter_name":"tracks","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"[]\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SpotubeTrackObjectListConverter()","dart_type_name":"List"}},{"name":"current_index","getter_name":"currentIndex","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const Constant(0)","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":7,"references":[],"type":"table","data":{"name":"history_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(HistoryEntryType.values)","dart_type_name":"HistoryEntryType"}},{"name":"item_id","getter_name":"itemId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":8,"references":[],"type":"table","data":{"name":"lyrics_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"SubtitleTypeConverter()","dart_type_name":"SubtitleSimple"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":9,"references":[],"type":"table","data":{"name":"metadata_plugins_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[{"allowed-lengths":{"min":1,"max":50}}]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"version","getter_name":"version","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"author","getter_name":"author","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"entry_point","getter_name":"entryPoint","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"apis","getter_name":"apis","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"abilities","getter_name":"abilities","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"selected","getter_name":"selected","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"selected\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"selected\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"repository","getter_name":"repository","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"plugin_api_version","getter_name":"pluginApiVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":10,"references":[1],"type":"index","data":{"on":1,"name":"unique_blacklist","sql":null,"unique":true,"columns":["element_type","element_id"]}},{"id":11,"references":[5],"type":"index","data":{"on":5,"name":"uniq_track_match","sql":null,"unique":true,"columns":["track_id","source_id","source_type"]}}]} \ No newline at end of file diff --git a/drift_schemas/app_db/drift_schema_v8.json b/drift_schemas/app_db/drift_schema_v8.json deleted file mode 100644 index eba4c46e..00000000 --- a/drift_schemas/app_db/drift_schema_v8.json +++ /dev/null @@ -1,1143 +0,0 @@ -{ - "_meta": { - "description": "This file contains a serialized version of schema entities for drift.", - "version": "1.2.0" - }, - "options": { "store_date_time_values_as_text": false }, - "entities": [ - { - "id": 0, - "references": [], - "type": "table", - "data": { - "name": "authentication_table", - "was_declared_in_moor": false, - "columns": [ - { - "name": "id", - "getter_name": "id", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", - "dialectAwareDefaultConstraints": { - "sqlite": "PRIMARY KEY AUTOINCREMENT" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": ["auto-increment"] - }, - { - "name": "cookie", - "getter_name": "cookie", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "EncryptedTextConverter()", - "dart_type_name": "DecryptedText" - } - }, - { - "name": "access_token", - "getter_name": "accessToken", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "EncryptedTextConverter()", - "dart_type_name": "DecryptedText" - } - }, - { - "name": "expiration", - "getter_name": "expiration", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 1, - "references": [], - "type": "table", - "data": { - "name": "blacklist_table", - "was_declared_in_moor": false, - "columns": [ - { - "name": "id", - "getter_name": "id", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", - "dialectAwareDefaultConstraints": { - "sqlite": "PRIMARY KEY AUTOINCREMENT" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": ["auto-increment"] - }, - { - "name": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "element_type", - "getter_name": "elementType", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(BlacklistedType.values)", - "dart_type_name": "BlacklistedType" - } - }, - { - "name": "element_id", - "getter_name": "elementId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 2, - "references": [], - "type": "table", - "data": { - "name": "preferences_table", - "was_declared_in_moor": false, - "columns": [ - { - "name": "id", - "getter_name": "id", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", - "dialectAwareDefaultConstraints": { - "sqlite": "PRIMARY KEY AUTOINCREMENT" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": ["auto-increment"] - }, - { - "name": "audio_quality", - "getter_name": "audioQuality", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "Constant(SourceQualities.high.name)", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(SourceQualities.values)", - "dart_type_name": "SourceQualities" - } - }, - { - "name": "album_color_sync", - "getter_name": "albumColorSync", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"album_color_sync\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"album_color_sync\" IN (0, 1))" - }, - "default_dart": "const Constant(true)", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "amoled_dark_theme", - "getter_name": "amoledDarkTheme", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"amoled_dark_theme\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"amoled_dark_theme\" IN (0, 1))" - }, - "default_dart": "const Constant(false)", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "check_update", - "getter_name": "checkUpdate", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"check_update\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"check_update\" IN (0, 1))" - }, - "default_dart": "const Constant(true)", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "normalize_audio", - "getter_name": "normalizeAudio", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"normalize_audio\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"normalize_audio\" IN (0, 1))" - }, - "default_dart": "const Constant(false)", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "show_system_tray_icon", - "getter_name": "showSystemTrayIcon", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"show_system_tray_icon\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"show_system_tray_icon\" IN (0, 1))" - }, - "default_dart": "const Constant(false)", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "system_title_bar", - "getter_name": "systemTitleBar", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"system_title_bar\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"system_title_bar\" IN (0, 1))" - }, - "default_dart": "const Constant(false)", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "skip_non_music", - "getter_name": "skipNonMusic", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"skip_non_music\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"skip_non_music\" IN (0, 1))" - }, - "default_dart": "const Constant(false)", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "close_behavior", - "getter_name": "closeBehavior", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "Constant(CloseBehavior.close.name)", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(CloseBehavior.values)", - "dart_type_name": "CloseBehavior" - } - }, - { - "name": "accent_color_scheme", - "getter_name": "accentColorScheme", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const Constant(\"Slate:0xff64748b\")", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const SpotubeColorConverter()", - "dart_type_name": "SpotubeColor" - } - }, - { - "name": "layout_mode", - "getter_name": "layoutMode", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "Constant(LayoutMode.adaptive.name)", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(LayoutMode.values)", - "dart_type_name": "LayoutMode" - } - }, - { - "name": "locale", - "getter_name": "locale", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const Constant('{\"languageCode\":\"system\",\"countryCode\":\"system\"}')", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const LocaleConverter()", - "dart_type_name": "Locale" - } - }, - { - "name": "market", - "getter_name": "market", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "Constant(Market.US.name)", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(Market.values)", - "dart_type_name": "Market" - } - }, - { - "name": "search_mode", - "getter_name": "searchMode", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "Constant(SearchMode.youtube.name)", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(SearchMode.values)", - "dart_type_name": "SearchMode" - } - }, - { - "name": "download_location", - "getter_name": "downloadLocation", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const Constant(\"\")", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "local_library_location", - "getter_name": "localLibraryLocation", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const Constant(\"\")", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const StringListConverter()", - "dart_type_name": "List" - } - }, - { - "name": "piped_instance", - "getter_name": "pipedInstance", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const Constant(\"https://pipedapi.kavin.rocks\")", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "invidious_instance", - "getter_name": "invidiousInstance", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const Constant(\"https://inv.nadeko.net\")", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "theme_mode", - "getter_name": "themeMode", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "Constant(ThemeMode.system.name)", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(ThemeMode.values)", - "dart_type_name": "ThemeMode" - } - }, - { - "name": "audio_source", - "getter_name": "audioSource", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "Constant(AudioSource.youtube.name)", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(AudioSource.values)", - "dart_type_name": "AudioSource" - } - }, - { - "name": "youtube_client_engine", - "getter_name": "youtubeClientEngine", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "Constant(YoutubeClientEngine.youtubeExplode.name)", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(YoutubeClientEngine.values)", - "dart_type_name": "YoutubeClientEngine" - } - }, - { - "name": "stream_music_codec", - "getter_name": "streamMusicCodec", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "Constant(SourceCodecs.weba.name)", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(SourceCodecs.values)", - "dart_type_name": "SourceCodecs" - } - }, - { - "name": "download_music_codec", - "getter_name": "downloadMusicCodec", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "Constant(SourceCodecs.m4a.name)", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(SourceCodecs.values)", - "dart_type_name": "SourceCodecs" - } - }, - { - "name": "discord_presence", - "getter_name": "discordPresence", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"discord_presence\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"discord_presence\" IN (0, 1))" - }, - "default_dart": "const Constant(true)", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "endless_playback", - "getter_name": "endlessPlayback", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"endless_playback\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"endless_playback\" IN (0, 1))" - }, - "default_dart": "const Constant(true)", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "enable_connect", - "getter_name": "enableConnect", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"enable_connect\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"enable_connect\" IN (0, 1))" - }, - "default_dart": "const Constant(false)", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "connect_port", - "getter_name": "connectPort", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const Constant(-1)", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "cache_music", - "getter_name": "cacheMusic", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"cache_music\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"cache_music\" IN (0, 1))" - }, - "default_dart": "const Constant(true)", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 3, - "references": [], - "type": "table", - "data": { - "name": "scrobbler_table", - "was_declared_in_moor": false, - "columns": [ - { - "name": "id", - "getter_name": "id", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", - "dialectAwareDefaultConstraints": { - "sqlite": "PRIMARY KEY AUTOINCREMENT" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": ["auto-increment"] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": "currentDateAndTime", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "username", - "getter_name": "username", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "password_hash", - "getter_name": "passwordHash", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "EncryptedTextConverter()", - "dart_type_name": "DecryptedText" - } - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 4, - "references": [], - "type": "table", - "data": { - "name": "skip_segment_table", - "was_declared_in_moor": false, - "columns": [ - { - "name": "id", - "getter_name": "id", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", - "dialectAwareDefaultConstraints": { - "sqlite": "PRIMARY KEY AUTOINCREMENT" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": ["auto-increment"] - }, - { - "name": "start", - "getter_name": "start", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "end", - "getter_name": "end", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "track_id", - "getter_name": "trackId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": "currentDateAndTime", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 5, - "references": [], - "type": "table", - "data": { - "name": "source_match_table", - "was_declared_in_moor": false, - "columns": [ - { - "name": "id", - "getter_name": "id", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", - "dialectAwareDefaultConstraints": { - "sqlite": "PRIMARY KEY AUTOINCREMENT" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": ["auto-increment"] - }, - { - "name": "track_id", - "getter_name": "trackId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "source_id", - "getter_name": "sourceId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "source_type", - "getter_name": "sourceType", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "Constant(SourceType.youtube.name)", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(SourceType.values)", - "dart_type_name": "SourceType" - } - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": "currentDateAndTime", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 6, - "references": [], - "type": "table", - "data": { - "name": "audio_player_state_table", - "was_declared_in_moor": false, - "columns": [ - { - "name": "id", - "getter_name": "id", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", - "dialectAwareDefaultConstraints": { - "sqlite": "PRIMARY KEY AUTOINCREMENT" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": ["auto-increment"] - }, - { - "name": "playing", - "getter_name": "playing", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"playing\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"playing\" IN (0, 1))" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "loop_mode", - "getter_name": "loopMode", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(PlaylistMode.values)", - "dart_type_name": "PlaylistMode" - } - }, - { - "name": "shuffled", - "getter_name": "shuffled", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"shuffled\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"shuffled\" IN (0, 1))" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "collections", - "getter_name": "collections", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const StringListConverter()", - "dart_type_name": "List" - } - }, - { - "name": "tracks", - "getter_name": "tracks", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const Constant(\"[]\")", - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const SpotubeTrackObjectListConverter()", - "dart_type_name": "List" - } - }, - { - "name": "current_index", - "getter_name": "currentIndex", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "default_dart": "const Constant(0)", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 7, - "references": [], - "type": "table", - "data": { - "name": "history_table", - "was_declared_in_moor": false, - "columns": [ - { - "name": "id", - "getter_name": "id", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", - "dialectAwareDefaultConstraints": { - "sqlite": "PRIMARY KEY AUTOINCREMENT" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": ["auto-increment"] - }, - { - "name": "created_at", - "getter_name": "createdAt", - "moor_type": "dateTime", - "nullable": false, - "customConstraints": null, - "default_dart": "currentDateAndTime", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "type", - "getter_name": "type", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const EnumNameConverter(HistoryEntryType.values)", - "dart_type_name": "HistoryEntryType" - } - }, - { - "name": "item_id", - "getter_name": "itemId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "data", - "getter_name": "data", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const MapTypeConverter()", - "dart_type_name": "Map" - } - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 8, - "references": [], - "type": "table", - "data": { - "name": "lyrics_table", - "was_declared_in_moor": false, - "columns": [ - { - "name": "id", - "getter_name": "id", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", - "dialectAwareDefaultConstraints": { - "sqlite": "PRIMARY KEY AUTOINCREMENT" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": ["auto-increment"] - }, - { - "name": "track_id", - "getter_name": "trackId", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "data", - "getter_name": "data", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "SubtitleTypeConverter()", - "dart_type_name": "SubtitleSimple" - } - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 9, - "references": [], - "type": "table", - "data": { - "name": "metadata_plugins_table", - "was_declared_in_moor": false, - "columns": [ - { - "name": "id", - "getter_name": "id", - "moor_type": "int", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", - "dialectAwareDefaultConstraints": { - "sqlite": "PRIMARY KEY AUTOINCREMENT" - }, - "default_dart": null, - "default_client_dart": null, - "dsl_features": ["auto-increment"] - }, - { - "name": "name", - "getter_name": "name", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [{ "allowed-lengths": { "min": 1, "max": 50 } }] - }, - { - "name": "description", - "getter_name": "description", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "version", - "getter_name": "version", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "author", - "getter_name": "author", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "entry_point", - "getter_name": "entryPoint", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "apis", - "getter_name": "apis", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const StringListConverter()", - "dart_type_name": "List" - } - }, - { - "name": "abilities", - "getter_name": "abilities", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [], - "type_converter": { - "dart_expr": "const StringListConverter()", - "dart_type_name": "List" - } - }, - { - "name": "selected", - "getter_name": "selected", - "moor_type": "bool", - "nullable": false, - "customConstraints": null, - "defaultConstraints": "CHECK (\"selected\" IN (0, 1))", - "dialectAwareDefaultConstraints": { - "sqlite": "CHECK (\"selected\" IN (0, 1))" - }, - "default_dart": "const Constant(false)", - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "repository", - "getter_name": "repository", - "moor_type": "string", - "nullable": true, - "customConstraints": null, - "default_dart": null, - "default_client_dart": null, - "dsl_features": [] - }, - { - "name": "plugin_api_version", - "getter_name": "pluginApiVersion", - "moor_type": "string", - "nullable": false, - "customConstraints": null, - "default_dart": "const Constant('1.0.0')", - "default_client_dart": null, - "dsl_features": [] - } - ], - "is_virtual": false, - "without_rowid": false, - "constraints": [] - } - }, - { - "id": 10, - "references": [1], - "type": "index", - "data": { - "on": 1, - "name": "unique_blacklist", - "sql": null, - "unique": true, - "columns": ["element_type", "element_id"] - } - }, - { - "id": 11, - "references": [5], - "type": "index", - "data": { - "on": 5, - "name": "uniq_track_match", - "sql": null, - "unique": true, - "columns": ["track_id", "source_id", "source_type"] - } - } - ] -} diff --git a/drift_schemas/app_db/drift_schema_v9.json b/drift_schemas/app_db/drift_schema_v9.json deleted file mode 100644 index 73af2588..00000000 --- a/drift_schemas/app_db/drift_schema_v9.json +++ /dev/null @@ -1 +0,0 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"authentication_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"cookie","getter_name":"cookie","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"access_token","getter_name":"accessToken","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"expiration","getter_name":"expiration","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"blacklist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"element_type","getter_name":"elementType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BlacklistedType.values)","dart_type_name":"BlacklistedType"}},{"name":"element_id","getter_name":"elementId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"preferences_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_quality","getter_name":"audioQuality","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceQualities.high.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceQualities.values)","dart_type_name":"SourceQualities"}},{"name":"album_color_sync","getter_name":"albumColorSync","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"album_color_sync\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"album_color_sync\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"amoled_dark_theme","getter_name":"amoledDarkTheme","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"amoled_dark_theme\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"amoled_dark_theme\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"check_update","getter_name":"checkUpdate","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"check_update\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"check_update\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"normalize_audio","getter_name":"normalizeAudio","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"normalize_audio\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"normalize_audio\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"show_system_tray_icon","getter_name":"showSystemTrayIcon","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"show_system_tray_icon\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"show_system_tray_icon\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"system_title_bar","getter_name":"systemTitleBar","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"system_title_bar\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"system_title_bar\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"skip_non_music","getter_name":"skipNonMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"skip_non_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"skip_non_music\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"close_behavior","getter_name":"closeBehavior","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(CloseBehavior.close.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(CloseBehavior.values)","dart_type_name":"CloseBehavior"}},{"name":"accent_color_scheme","getter_name":"accentColorScheme","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"Slate:0xff64748b\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SpotubeColorConverter()","dart_type_name":"SpotubeColor"}},{"name":"layout_mode","getter_name":"layoutMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(LayoutMode.adaptive.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LayoutMode.values)","dart_type_name":"LayoutMode"}},{"name":"locale","getter_name":"locale","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant('{\"languageCode\":\"system\",\"countryCode\":\"system\"}')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const LocaleConverter()","dart_type_name":"Locale"}},{"name":"market","getter_name":"market","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(Market.US.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(Market.values)","dart_type_name":"Market"}},{"name":"search_mode","getter_name":"searchMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SearchMode.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SearchMode.values)","dart_type_name":"SearchMode"}},{"name":"download_location","getter_name":"downloadLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[]},{"name":"local_library_location","getter_name":"localLibraryLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"piped_instance","getter_name":"pipedInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://pipedapi.kavin.rocks\")","default_client_dart":null,"dsl_features":[]},{"name":"invidious_instance","getter_name":"invidiousInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://inv.nadeko.net\")","default_client_dart":null,"dsl_features":[]},{"name":"theme_mode","getter_name":"themeMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(ThemeMode.system.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeMode.values)","dart_type_name":"ThemeMode"}},{"name":"audio_source","getter_name":"audioSource","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(AudioSource.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(AudioSource.values)","dart_type_name":"AudioSource"}},{"name":"youtube_client_engine","getter_name":"youtubeClientEngine","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(YoutubeClientEngine.youtubeExplode.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(YoutubeClientEngine.values)","dart_type_name":"YoutubeClientEngine"}},{"name":"stream_music_codec","getter_name":"streamMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.weba.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"download_music_codec","getter_name":"downloadMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.m4a.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"discord_presence","getter_name":"discordPresence","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"discord_presence\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"discord_presence\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"endless_playback","getter_name":"endlessPlayback","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"endless_playback\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"endless_playback\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"enable_connect","getter_name":"enableConnect","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"enable_connect\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"enable_connect\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"connect_port","getter_name":"connectPort","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const Constant(-1)","default_client_dart":null,"dsl_features":[]},{"name":"cache_music","getter_name":"cacheMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"cache_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"cache_music\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":3,"references":[],"type":"table","data":{"name":"scrobbler_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"username","getter_name":"username","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"password_hash","getter_name":"passwordHash","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":4,"references":[],"type":"table","data":{"name":"skip_segment_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"start","getter_name":"start","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"end","getter_name":"end","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":5,"references":[],"type":"table","data":{"name":"source_match_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_id","getter_name":"sourceId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceType.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceType.values)","dart_type_name":"SourceType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":6,"references":[],"type":"table","data":{"name":"audio_player_state_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playing","getter_name":"playing","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"playing\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"playing\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"loop_mode","getter_name":"loopMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(PlaylistMode.values)","dart_type_name":"PlaylistMode"}},{"name":"shuffled","getter_name":"shuffled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"shuffled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"shuffled\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"collections","getter_name":"collections","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"tracks","getter_name":"tracks","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"[]\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SpotubeTrackObjectListConverter()","dart_type_name":"List"}},{"name":"current_index","getter_name":"currentIndex","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const Constant(0)","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":7,"references":[],"type":"table","data":{"name":"history_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(HistoryEntryType.values)","dart_type_name":"HistoryEntryType"}},{"name":"item_id","getter_name":"itemId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":8,"references":[],"type":"table","data":{"name":"lyrics_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"SubtitleTypeConverter()","dart_type_name":"SubtitleSimple"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":9,"references":[],"type":"table","data":{"name":"plugins_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[{"allowed-lengths":{"min":1,"max":50}}]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"version","getter_name":"version","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"author","getter_name":"author","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"entry_point","getter_name":"entryPoint","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"apis","getter_name":"apis","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"abilities","getter_name":"abilities","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"selected_for_metadata","getter_name":"selectedForMetadata","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"selected_for_metadata\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"selected_for_metadata\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"selected_for_audio_source","getter_name":"selectedForAudioSource","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"selected_for_audio_source\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"selected_for_audio_source\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"repository","getter_name":"repository","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"plugin_api_version","getter_name":"pluginApiVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant('2.0.0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":10,"references":[1],"type":"index","data":{"on":1,"name":"unique_blacklist","sql":null,"unique":true,"columns":["element_type","element_id"]}},{"id":11,"references":[5],"type":"index","data":{"on":5,"name":"uniq_track_match","sql":null,"unique":true,"columns":["track_id","source_id","source_type"]}}]} \ No newline at end of file diff --git a/flutter_launcher_icons-nightly.yaml b/flutter_launcher_icons-nightly.yaml deleted file mode 100644 index 9e4e805c..00000000 --- a/flutter_launcher_icons-nightly.yaml +++ /dev/null @@ -1,6 +0,0 @@ -flutter_launcher_icons: - android: true - ios: true - image_path: "assets/branding/spotube-nightly-logo.png" - adaptive_icon_foreground: "assets/branding/spotube-nightly-logo-foreground.png" - adaptive_icon_background: "#242832" diff --git a/flutter_launcher_icons.yaml b/flutter_launcher_icons.yaml deleted file mode 100644 index e5b26882..00000000 --- a/flutter_launcher_icons.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# flutter pub run flutter_launcher_icons -flutter_launcher_icons: - image_path: "assets/branding/spotube-logo.png" - - android: true - # image_path_android: "assets/branding/icon/icon.png" - min_sdk_android: 21 # android min sdk min:16, default 21 - adaptive_icon_background: "#242832" - adaptive_icon_foreground: "assets/branding/spotube-logo-foreground.png" - # adaptive_icon_monochrome: "assets/branding/icon/monochrome.png" - - ios: true - # image_path_ios: "assets/branding/icon/icon.png" - remove_alpha_channel_ios: true - # image_path_ios_dark_transparent: "assets/branding/icon/icon_dark.png" - # image_path_ios_tinted_grayscale: "assets/branding/icon/icon_tinted.png" - # desaturate_tinted_to_grayscale_ios: true - - web: - generate: false - - windows: - generate: true - image_path: "assets/branding/spotube-logo.png" - icon_size: 48 # min:48, max:256, default: 48 - - macos: - generate: true - image_path: "assets/branding/spotube-logo-macos.png" diff --git a/flutter_native_splash-nightly.yaml b/flutter_native_splash-nightly.yaml deleted file mode 100644 index 3b7daeec..00000000 --- a/flutter_native_splash-nightly.yaml +++ /dev/null @@ -1,9 +0,0 @@ -flutter_native_splash: - background_image: assets/images/bengali-patterns-bg.jpg - image: assets/branding/spotube-nightly-logo.png - branding: assets/branding/branding.png - android_12: - image: assets/branding/spotube-nightly-logo_android12.png - branding: assets/branding/branding.png - color: "#000000" - icon_background_color: "#000000" diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..a49b0977 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,37 @@ +# +# Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +#Kotlin +kotlin.code.style=official +kotlin.daemon.jvmargs=-Xmx3072M +kotlin.mpp.stability.nowarn=true +kotlin.incremental=true + +#Gradle +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g +org.gradle.configuration-cache=true +org.gradle.caching=true + +#Android +android.nonTransitiveRClass=true +android.useAndroidX=true +# Preserve compatibility with this project's current Kotlin Multiplatform Android setup on AGP 9. +android.builtInKotlin=false +android.newDsl=false +android.sourceset.disallowProvider=false + +org.gradle.vfs.watch=true \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..cef9c030 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,29 @@ +# +# Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac68b3e883c7c91c760/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac68b3e883c7c91c760/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/f257be9f04bfdf169051808541767806/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/1dcbacacca32618bd21ec5465779ade1/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac68b3e883c7c91c760/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect +toolchainVersion=21 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..838df455 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,199 @@ +# Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +[versions] +agp = "9.1.0" +android-compileSdk = "37" +android-minSdk = "28" +android-targetSdk = "36" +androidx-activity = "1.13.0" +androidx-appcompat = "1.7.1" +androidx-core = "1.19.0" +androidx-espresso = "3.7.0" +androidx-lifecycle = "2.10.0" +androidx-testExt = "1.3.0" +appdirs = "1.5.0" +cache4k = "0.14.0" +carApp = "1.7.0" +composeHotReload = "1.2.0" +composeMultiplatform = "1.12.2" +coil = "3.5.0" +composeShimmer = "1.5.0" +desugar_jdk_libs = "2.1.5" +haze = "2.0.0-alpha03" +jna = "5.18.1" +junit = "4.13.2" +kermit = "2.1.0" +kmpZip = "1.0.0" +kotlin = "2.4.21" +kotlinx-coroutines = "1.11.0" +kotlinx-io = "0.9.1" +kotlinx-serialization-json = "1.11.0" +millennium = "0.1.1" +mqttClient = "2.1.1" +mqttBuffer = "6.30.8" +multiplatformMarkdownRenderer = "0.45.0" +murmurhash = "0.4.2" +newpipeextractor = "v0.26.5" +newpipeExtractorKmp = "1.3.0" +nucleusNucleusApplication = "2.5.18" +semver = "2.1.0" +vlcj = "4.12.1" +vlcjNative = "4.12.0" +ziplineVersion = "1.28.0" +ktor = "3.5.1" +dnssdkt = "1.1.0" +runner = "1.7.0" +core = "1.7.0" +filekit = "0.14.2" +compose-webview = "0.1.0-SNAPSHOT" +composeNativeTray = "2.1.6" +koin = "4.2.2" +multiplatform-nav3-ui = "1.1.1" +compose-multiplatform-adaptive = "1.3.0-alpha05" +compose-multiplatform-lifecycle = "2.10.0" +feather-icons = "1.1.1" +kotlinx-datetime = "0.8.0" +media3 = "1.10.1" +androidx-datastore = "1.2.1" +ksp = "2.3.12" +spotube-gradle = "0.1.2" +cryptography = "0.6.0" +reorderable = "3.1.0" +vlcjBundler = "0.2.0" +vanniktechMavenPublish = "0.37.0" +uniffi = "0.3.7" +kotlinx-atomicfu = "0.33.0" +mokkery = "3.5.1" +jmustache = "1.16" + +[libraries] +# buildSrc: renders the package manager manifests in packaging/ +jmustache = { module = "com.samskivert:jmustache", version.ref = "jmustache" } +appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } +cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" } +androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" } +compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" } +desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } +dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" } +haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } +haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" } +haze-blur = { module = "dev.chrisbanes.haze:haze-blur", version.ref = "haze" } +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } +kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } +kermit-koin = { module = "co.touchlab:kermit-koin", version.ref = "kermit" } +kmp-zip = { module = "no.synth:kmp-zip", version.ref = "kmpZip" } +kmp-zip-kotlinx = { module = "no.synth:kmp-zip-kotlinx", version.ref = "kmpZip" } +kmp-zip-okio = { module = "no.synth:kmp-zip-okio", version.ref = "kmpZip" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +junit = { module = "junit:junit", version.ref = "junit" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" } +androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } +androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } +compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } +compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } +compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } +compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } +kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } +ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } +ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } +millennium = { module = "org.spotube.millennium:millennium", version.ref = "millennium" } +mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClient" } +mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" } +mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" } +mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" } +multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } +multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } +murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" } +newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } +newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" } +nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" } +nucleus-decorated-window-tao = { module = "dev.nucleusframework:nucleus.decorated-window-tao", version.ref = "nucleusNucleusApplication" } +nucleus-media-control = { module = "dev.nucleusframework:nucleus.media-control", version.ref = "nucleusNucleusApplication" } +nucleus-graalvm-runtime = { module = "dev.nucleusframework:nucleus.graalvm-runtime", version.ref = "nucleusNucleusApplication" } +nucleus-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" } +semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" } +vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" } +vlcj-natives = { module = "uk.co.caprica:vlcj-natives", version.ref = "vlcjNative" } +zipline-core = { module = "app.cash.zipline:zipline", version.ref = "ziplineVersion" } +zipline-loader = { module = "app.cash.zipline:zipline-loader", version.ref = "ziplineVersion" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } +androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } +androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } +filekit-core = { group = "io.github.vinceglb", name = "filekit-core", version.ref = "filekit" } +filekit-dialogs = { group = "io.github.vinceglb", name = "filekit-dialogs", version.ref = "filekit" } +filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "filekit" } +compose-webview = { module = "dev.nucleusframework:composewebview", version.ref = "compose-webview" } +compose-native-tray = { module = "dev.nucleusframework:composenativetray", version.ref = "composeNativeTray" } +koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } +koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } +koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } +koin-compose-navigation3 = { module = "io.insert-koin:koin-compose-navigation3", version.ref = "koin" } +jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" } +jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" } +feather-icons = { module = "br.com.devsrsouza.compose.icons:feather", version.ref = "feather-icons" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } +androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } +kotlinx-io-okio = { module = "org.jetbrains.kotlinx:kotlinx-io-okio", version.ref = "kotlinx-io" } +cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", version.ref = "cryptography" } +cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography" } +reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } +media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" } +media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion" } +mavenPublish = { id = "maven-publish" } +spotubeGradle = { id = "org.spotube.gradle-plugin", version.ref = "spotube-gradle" } +vlcjBundler = { id = "org.spotube.vlcj-bundler", version.ref = "vlcjBundler" } +kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" } +uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" } +cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" } +mokkery = { id = "dev.mokkery", version.ref = "mokkery" } +vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" } +neucleusFramework = { id = "dev.nucleusframework", version.ref = "nucleusNucleusApplication" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1b33c55b Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties similarity index 73% rename from android/gradle/wrapper/gradle-wrapper.properties rename to gradle/wrapper/gradle-wrapper.properties index bf6b7385..5be30bbe 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Fri Dec 13 21:53:13 BDT 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-all.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..23d15a93 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..db3a6ac2 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/integration_test/app_test.dart b/integration_test/app_test.dart deleted file mode 100644 index 619844b9..00000000 --- a/integration_test/app_test.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import 'package:spotube/main.dart' as app; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('end-to-end test', () { - testWidgets('check if app is successfully starting', (tester) async { - await app.main([]); - await tester.pumpAndSettle(); - - expect(find.byType(MaterialApp), findsOneWidget); - }); - }); -} diff --git a/ios/.gitignore b/ios/.gitignore deleted file mode 100644 index 7a7f9873..00000000 --- a/ios/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -**/dgph -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/ephemeral/ -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 7c569640..00000000 --- a/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - MinimumOSVersion - 12.0 - - diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig deleted file mode 100644 index ec97fc6f..00000000 --- a/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig deleted file mode 100644 index c4855bfe..00000000 --- a/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "Generated.xcconfig" diff --git a/ios/HomePlayerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/ios/HomePlayerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json deleted file mode 100644 index eb878970..00000000 --- a/ios/HomePlayerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/HomePlayerWidget/HomePlayerWidget.swift b/ios/HomePlayerWidget/HomePlayerWidget.swift deleted file mode 100644 index 8808aae1..00000000 --- a/ios/HomePlayerWidget/HomePlayerWidget.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// HomePlayerWidget.swift -// HomePlayerWidget -// -// Created by Kingkor Roy Tirtho on 15/12/24. -// - -import WidgetKit -import SwiftUI - -private let widgetGroupId = "group.spotube_home_player_widget" - -struct Provider: TimelineProvider { - func placeholder(in context: Context) -> SimpleEntry { - SimpleEntry(date: Date(), emoji: "😀") - } - - func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { - let entry = SimpleEntry(date: Date(), emoji: "😀") - completion(entry) - } - - func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { - var entries: [SimpleEntry] = [] - - // Generate a timeline consisting of five entries an hour apart, starting from the current date. - let currentDate = Date() - for hourOffset in 0 ..< 5 { - let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! - let entry = SimpleEntry(date: entryDate, emoji: "😀") - entries.append(entry) - } - - let timeline = Timeline(entries: entries, policy: .atEnd) - completion(timeline) - } - -// func relevances() async -> WidgetRelevances { -// // Generate a list containing the contexts this widget is relevant in. -// } -} - -struct SimpleEntry: TimelineEntry { - let date: Date - let emoji: String -} - -struct HomePlayerWidgetEntryView : View { - var entry: Provider.Entry - - var body: some View { - VStack { - Text("Time:") - Text(entry.date, style: .time) - - Text("Emoji:") - Text(entry.emoji) - } - } -} - -struct HomePlayerWidget: Widget { - let kind: String = "HomePlayerWidget" - - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: Provider()) { entry in - if #available(iOS 17.0, *) { - HomePlayerWidgetEntryView(entry: entry) - .containerBackground(.fill.tertiary, for: .widget) - } else { - HomePlayerWidgetEntryView(entry: entry) - .padding() - .background() - } - } - .configurationDisplayName("My Widget") - .description("This is an example widget.") - } -} - -#Preview(as: .systemSmall) { - HomePlayerWidget() -} timeline: { - SimpleEntry(date: .now, emoji: "😀") - SimpleEntry(date: .now, emoji: "🤩") -} diff --git a/ios/HomePlayerWidget/HomePlayerWidgetBundle.swift b/ios/HomePlayerWidget/HomePlayerWidgetBundle.swift deleted file mode 100644 index 68158b53..00000000 --- a/ios/HomePlayerWidget/HomePlayerWidgetBundle.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// HomePlayerWidgetBundle.swift -// HomePlayerWidget -// -// Created by Kingkor Roy Tirtho on 15/12/24. -// - -import WidgetKit -import SwiftUI - -@main -struct HomePlayerWidgetBundle: WidgetBundle { - var body: some Widget { - HomePlayerWidget() - } -} diff --git a/ios/HomePlayerWidget/Info.plist b/ios/HomePlayerWidget/Info.plist deleted file mode 100644 index 0f118fb7..00000000 --- a/ios/HomePlayerWidget/Info.plist +++ /dev/null @@ -1,11 +0,0 @@ - - - - - NSExtension - - NSExtensionPointIdentifier - com.apple.widgetkit-extension - - - diff --git a/ios/HomePlayerWidgetExtension.entitlements b/ios/HomePlayerWidgetExtension.entitlements deleted file mode 100644 index 58165678..00000000 --- a/ios/HomePlayerWidgetExtension.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.spotube_home_player_widget - - - diff --git a/ios/Podfile b/ios/Podfile deleted file mode 100644 index 7235f482..00000000 --- a/ios/Podfile +++ /dev/null @@ -1,69 +0,0 @@ -# Uncomment this line to define a global platform for your project -platform :ios, '13.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_ios_podfile_setup - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) -end - -target 'dev' do - use_frameworks! - use_modular_headers! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) -end - -target 'stable' do - use_frameworks! - use_modular_headers! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) -end - -target 'nightly' do - use_frameworks! - use_modular_headers! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - # Just Audio Config - target.build_configurations.each do |config| - config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ - '$(inherited)', - 'AUDIO_SESSION_MICROPHONE=0' - ] - end - end -end diff --git a/ios/Podfile.lock b/ios/Podfile.lock deleted file mode 100644 index 2ff415a0..00000000 --- a/ios/Podfile.lock +++ /dev/null @@ -1,293 +0,0 @@ -PODS: - - app_links (6.4.1): - - Flutter - - audio_service (0.0.1): - - Flutter - - FlutterMacOS - - audio_session (0.0.1): - - Flutter - - bonsoir_darwin (0.0.1): - - Flutter - - FlutterMacOS - - connectivity_plus (0.0.1): - - Flutter - - device_info_plus (0.0.1): - - Flutter - - DKImagePickerController/Core (4.3.4): - - DKImagePickerController/ImageDataManager - - DKImagePickerController/Resource - - DKImagePickerController/ImageDataManager (4.3.4) - - DKImagePickerController/PhotoGallery (4.3.4): - - DKImagePickerController/Core - - DKPhotoGallery - - DKImagePickerController/Resource (4.3.4) - - DKPhotoGallery (0.0.17): - - DKPhotoGallery/Core (= 0.0.17) - - DKPhotoGallery/Model (= 0.0.17) - - DKPhotoGallery/Preview (= 0.0.17) - - DKPhotoGallery/Resource (= 0.0.17) - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Core (0.0.17): - - DKPhotoGallery/Model - - DKPhotoGallery/Preview - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Model (0.0.17): - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Preview (0.0.17): - - DKPhotoGallery/Model - - DKPhotoGallery/Resource - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Resource (0.0.17): - - SDWebImage - - SwiftyGif - - file_picker (0.0.1): - - DKImagePickerController/PhotoGallery - - Flutter - - file_selector_ios (0.0.1): - - Flutter - - fk_user_agent (2.0.0): - - Flutter - - Flutter (1.0.0) - - flutter_broadcasts (0.0.1): - - Flutter - - flutter_discord_rpc (0.0.1): - - Flutter - - flutter_inappwebview_ios (0.0.1): - - Flutter - - flutter_inappwebview_ios/Core (= 0.0.1) - - OrderedSet (~> 6.0.3) - - flutter_inappwebview_ios/Core (0.0.1): - - Flutter - - OrderedSet (~> 6.0.3) - - flutter_native_splash (2.4.3): - - Flutter - - flutter_secure_storage (6.0.0): - - Flutter - - flutter_sharing_intent (0.0.1): - - Flutter - - flutter_timezone (0.0.1): - - Flutter - - home_widget (0.0.1): - - Flutter - - image_picker_ios (0.0.1): - - Flutter - - integration_test (0.0.1): - - Flutter - - irondash_engine_context (0.0.1): - - Flutter - - media_kit_libs_ios_audio (1.0.4): - - Flutter - - metadata_god (0.0.1): - - Flutter - - open_file_ios (0.0.1): - - Flutter - - OrderedSet (6.0.3) - - package_info_plus (0.4.5): - - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - - permission_handler_apple (9.3.0): - - Flutter - - SDWebImage (5.18.8): - - SDWebImage/Core (= 5.18.8) - - SDWebImage/Core (5.18.8) - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sqflite_darwin (0.0.4): - - Flutter - - FlutterMacOS - - sqlite3 (3.50.4): - - sqlite3/common (= 3.50.4) - - sqlite3/common (3.50.4) - - sqlite3/dbstatvtab (3.50.4): - - sqlite3/common - - sqlite3/fts5 (3.50.4): - - sqlite3/common - - sqlite3/math (3.50.4): - - sqlite3/common - - sqlite3/perf-threadsafe (3.50.4): - - sqlite3/common - - sqlite3/rtree (3.50.4): - - sqlite3/common - - sqlite3/session (3.50.4): - - sqlite3/common - - sqlite3_flutter_libs (0.0.1): - - Flutter - - FlutterMacOS - - sqlite3 (~> 3.50.4) - - sqlite3/dbstatvtab - - sqlite3/fts5 - - sqlite3/math - - sqlite3/perf-threadsafe - - sqlite3/rtree - - sqlite3/session - - super_native_extensions (0.0.1): - - Flutter - - SwiftyGif (5.4.4) - - system_theme (0.0.1): - - Flutter - - url_launcher_ios (0.0.1): - - Flutter - -DEPENDENCIES: - - app_links (from `.symlinks/plugins/app_links/ios`) - - audio_service (from `.symlinks/plugins/audio_service/darwin`) - - audio_session (from `.symlinks/plugins/audio_session/ios`) - - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`) - - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - - file_picker (from `.symlinks/plugins/file_picker/ios`) - - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - - fk_user_agent (from `.symlinks/plugins/fk_user_agent/ios`) - - Flutter (from `Flutter`) - - flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`) - - flutter_discord_rpc (from `.symlinks/plugins/flutter_discord_rpc/ios`) - - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) - - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - - flutter_sharing_intent (from `.symlinks/plugins/flutter_sharing_intent/ios`) - - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) - - home_widget (from `.symlinks/plugins/home_widget/ios`) - - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - - integration_test (from `.symlinks/plugins/integration_test/ios`) - - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) - - media_kit_libs_ios_audio (from `.symlinks/plugins/media_kit_libs_ios_audio/ios`) - - metadata_god (from `.symlinks/plugins/metadata_god/ios`) - - open_file_ios (from `.symlinks/plugins/open_file_ios/ios`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) - - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`) - - system_theme (from `.symlinks/plugins/system_theme/ios`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - -SPEC REPOS: - trunk: - - DKImagePickerController - - DKPhotoGallery - - OrderedSet - - SDWebImage - - sqlite3 - - SwiftyGif - -EXTERNAL SOURCES: - app_links: - :path: ".symlinks/plugins/app_links/ios" - audio_service: - :path: ".symlinks/plugins/audio_service/darwin" - audio_session: - :path: ".symlinks/plugins/audio_session/ios" - bonsoir_darwin: - :path: ".symlinks/plugins/bonsoir_darwin/darwin" - connectivity_plus: - :path: ".symlinks/plugins/connectivity_plus/ios" - device_info_plus: - :path: ".symlinks/plugins/device_info_plus/ios" - file_picker: - :path: ".symlinks/plugins/file_picker/ios" - file_selector_ios: - :path: ".symlinks/plugins/file_selector_ios/ios" - fk_user_agent: - :path: ".symlinks/plugins/fk_user_agent/ios" - Flutter: - :path: Flutter - flutter_broadcasts: - :path: ".symlinks/plugins/flutter_broadcasts/ios" - flutter_discord_rpc: - :path: ".symlinks/plugins/flutter_discord_rpc/ios" - flutter_inappwebview_ios: - :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" - flutter_native_splash: - :path: ".symlinks/plugins/flutter_native_splash/ios" - flutter_secure_storage: - :path: ".symlinks/plugins/flutter_secure_storage/ios" - flutter_sharing_intent: - :path: ".symlinks/plugins/flutter_sharing_intent/ios" - flutter_timezone: - :path: ".symlinks/plugins/flutter_timezone/ios" - home_widget: - :path: ".symlinks/plugins/home_widget/ios" - image_picker_ios: - :path: ".symlinks/plugins/image_picker_ios/ios" - integration_test: - :path: ".symlinks/plugins/integration_test/ios" - irondash_engine_context: - :path: ".symlinks/plugins/irondash_engine_context/ios" - media_kit_libs_ios_audio: - :path: ".symlinks/plugins/media_kit_libs_ios_audio/ios" - metadata_god: - :path: ".symlinks/plugins/metadata_god/ios" - open_file_ios: - :path: ".symlinks/plugins/open_file_ios/ios" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" - permission_handler_apple: - :path: ".symlinks/plugins/permission_handler_apple/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sqflite_darwin: - :path: ".symlinks/plugins/sqflite_darwin/darwin" - sqlite3_flutter_libs: - :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" - super_native_extensions: - :path: ".symlinks/plugins/super_native_extensions/ios" - system_theme: - :path: ".symlinks/plugins/system_theme/ios" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - -SPEC CHECKSUMS: - app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a - audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd - audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 - bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e - connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd - device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe - DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac - DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517 - file_selector_ios: f92e583d43608aebc2e4a18daac30b8902845502 - fk_user_agent: 137145b086229251761678fe034da53753f4ce59 - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_broadcasts: 7bb7cc1024900a7f85e98b6faab795290b7c2339 - flutter_discord_rpc: 0572e8227ea730c5afe5876a37c08c728ce95f3a - flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 - flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf - flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 - flutter_sharing_intent: afdc98985814d2c01d8c0956a177d6b6dfbdc373 - flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 - home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f - image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a - integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e - irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 - media_kit_libs_ios_audio: 905e6323b72e65c63ab9262b2e473f52c024a3a8 - metadata_god: 018b59c2f3617569928550dcbd17481591557c1d - open_file_ios: 5ff7526df64e4394b4fe207636b67a95e83078bb - OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 - package_info_plus: 580e9a5f1b6ca5594e7c9ed5f92d1dfb2a66b5e1 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b - sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1 - super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 - SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f - system_theme: a94f91f49eeb97cfa768c7d5a9b2f6aa51b00494 - url_launcher_ios: 694010445543906933d732453a59da0a173ae33d - -PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e - -COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 88a40d6f..00000000 --- a/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,3393 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXBuildFile section */ - 051977801F58E8DBB6712352 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F7E9EBDD27997A73A4D38EE1 /* Pods_Runner.framework */; }; - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 17438EB903776D8D0E926C9B /* Pods_nightly.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BAC36FC304DBD4E8A8C00694 /* Pods_nightly.framework */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 46249B26D47C5DB81A4F972E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F7E9EBDD27997A73A4D38EE1 /* Pods_Runner.framework */; }; - 4E86E0C42011EDB42C34AF9A /* Pods_stable.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5F91A319C771EEC978B238A /* Pods_stable.framework */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - B536BD902B405DB1009B3CE4 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - B536BD912B405DB1009B3CE4 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - B536BD952B405DB1009B3CE4 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - B536BD962B405DB1009B3CE4 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - B536BD972B405DB1009B3CE4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - B536BD982B405DB1009B3CE4 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - B536BDAE2B405FDE009B3CE4 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - B536BDAF2B405FDE009B3CE4 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - B536BDB22B405FDE009B3CE4 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - B536BDB32B405FDE009B3CE4 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - B536BDB42B405FDE009B3CE4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - B536BDB52B405FDE009B3CE4 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - B536BDD02B4060B3009B3CE4 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - B536BDD12B4060B3009B3CE4 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - B536BDD42B4060B3009B3CE4 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - B536BDD52B4060B3009B3CE4 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - B536BDD62B4060B3009B3CE4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - B536BDD72B4060B3009B3CE4 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - C36A05AD330BBFAED75A62D5 /* Pods_dev.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4238A4985255EC9F93067739 /* Pods_dev.framework */; }; - E612EC3B2D0F07A90022720C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E612EC3A2D0F07A90022720C /* WidgetKit.framework */; }; - E612EC3D2D0F07A90022720C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E612EC3C2D0F07A90022720C /* SwiftUI.framework */; }; - E612EC482D0F07AD0022720C /* HomePlayerWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E612EC392D0F07A90022720C /* HomePlayerWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - E612EC462D0F07AD0022720C /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = E612EC382D0F07A80022720C; - remoteInfo = HomePlayerWidgetExtension; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; - B536BD992B405DB1009B3CE4 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; - B536BDB62B405FDE009B3CE4 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; - B536BDD82B4060B3009B3CE4 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; - E612EC492D0F07AD0022720C /* Embed Foundation Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - E612EC482D0F07AD0022720C /* HomePlayerWidgetExtension.appex in Embed Foundation Extensions */, - ); - name = "Embed Foundation Extensions"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 04C104D3779B4D1635D939BF /* Pods-Runner.profile-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile-nightly.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile-nightly.xcconfig"; sourceTree = ""; }; - 0F8FB58820FF492BD3CF9315 /* Pods-nightly.debug-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.debug-stable.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.debug-stable.xcconfig"; sourceTree = ""; }; - 126B91CED32FAD3C40A67A23 /* Pods-dev.debug-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.debug-stable.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.debug-stable.xcconfig"; sourceTree = ""; }; - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 171073CFF94F5751BC2B78DD /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 1C9810F8B3FD927ED8C94791 /* Pods-dev.profile-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.profile-nightly.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.profile-nightly.xcconfig"; sourceTree = ""; }; - 21C0B1DEE0F0BFD3F3651F79 /* Pods-stable.debug-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.debug-nightly.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.debug-nightly.xcconfig"; sourceTree = ""; }; - 261A31AC0DBA2D93BD1910D9 /* Pods-nightly.profile-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.profile-nightly.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.profile-nightly.xcconfig"; sourceTree = ""; }; - 285DE2278D380EE2A6647CA9 /* Pods-nightly.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.debug.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.debug.xcconfig"; sourceTree = ""; }; - 29304D1832AA30DE0C33E05C /* Pods-dev.profile-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.profile-stable.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.profile-stable.xcconfig"; sourceTree = ""; }; - 2DA87118BE2AF25875B7C376 /* Pods-stable.release-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.release-stable.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.release-stable.xcconfig"; sourceTree = ""; }; - 2F9AD76AF35FFC693C051CE1 /* Pods-dev.release-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.release-nightly.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.release-nightly.xcconfig"; sourceTree = ""; }; - 39E15EE1745C9266FDB59558 /* Pods-stable.debug-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.debug-dev.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.debug-dev.xcconfig"; sourceTree = ""; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 3E262038FF3BDA3B8A7BDAC3 /* Pods-Runner.release-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release-dev.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release-dev.xcconfig"; sourceTree = ""; }; - 3F754C793C1BC0E8B8FFB5B7 /* Pods-stable.profile-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.profile-stable.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.profile-stable.xcconfig"; sourceTree = ""; }; - 4238A4985255EC9F93067739 /* Pods_dev.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_dev.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 46E04A5AA989356A32CD8E66 /* Pods-dev.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.profile.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.profile.xcconfig"; sourceTree = ""; }; - 48E7E801EAE1B520AA5F35DD /* Pods-dev.profile-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.profile-dev.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.profile-dev.xcconfig"; sourceTree = ""; }; - 4BDAF8FFADB62CA017755094 /* Pods-stable.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.profile.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.profile.xcconfig"; sourceTree = ""; }; - 5014E8BD9F7181E528538444 /* Pods-stable.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.release.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.release.xcconfig"; sourceTree = ""; }; - 53AD516AAEB9A1331C99CBAE /* Pods-stable.profile-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.profile-dev.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.profile-dev.xcconfig"; sourceTree = ""; }; - 5A8B64E98ADDA28FB63AA32C /* Pods-Runner.release-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release-nightly.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release-nightly.xcconfig"; sourceTree = ""; }; - 636F4A85470D9E3B4CC8AFB8 /* Pods-nightly.profile-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.profile-dev.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.profile-dev.xcconfig"; sourceTree = ""; }; - 66F649AFA6E49EA44F469DA3 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 68BE49B58C0EBB578948D773 /* Pods-nightly.release-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.release-dev.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.release-dev.xcconfig"; sourceTree = ""; }; - 6AE8151F4499707FA23C8223 /* Pods-dev.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.debug.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.debug.xcconfig"; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 77EFEBB27B276DD5F6B01B4B /* Pods-Runner.debug-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug-nightly.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug-nightly.xcconfig"; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 864AC9150518DFBA85A46A15 /* Pods-stable.release-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.release-dev.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.release-dev.xcconfig"; sourceTree = ""; }; - 869E7B97AE866F2BCA2E5A6A /* Pods-Runner.release-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release-stable.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release-stable.xcconfig"; sourceTree = ""; }; - 89CD409D60E1362C529707A4 /* Pods-nightly.release-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.release-nightly.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.release-nightly.xcconfig"; sourceTree = ""; }; - 8AD587044EF2C6A6FA3059DC /* Pods-stable.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.debug.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.debug.xcconfig"; sourceTree = ""; }; - 8B9DFB8E20C11066C3AB696A /* Pods-dev.debug-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.debug-nightly.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.debug-nightly.xcconfig"; sourceTree = ""; }; - 8CF39CF9464623571B63D15B /* Pods-nightly.release-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.release-stable.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.release-stable.xcconfig"; sourceTree = ""; }; - 9232DBE472C8CEA1101843D9 /* Pods-nightly.profile-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.profile-stable.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.profile-stable.xcconfig"; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 9878519B106548FD75CA15C0 /* Pods-nightly.debug-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.debug-dev.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.debug-dev.xcconfig"; sourceTree = ""; }; - A59B7A01EEC476AF3141B518 /* Pods-Runner.debug-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug-dev.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug-dev.xcconfig"; sourceTree = ""; }; - B38E6C7315D66215AFD8B218 /* Pods-stable.profile-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.profile-nightly.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.profile-nightly.xcconfig"; sourceTree = ""; }; - B536BDA02B405DB1009B3CE4 /* stable.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = stable.app; sourceTree = BUILT_PRODUCTS_DIR; }; - B536BDA12B405DB1009B3CE4 /* stable-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "stable-Info.plist"; path = "/Users/xiaobowen/Documents/GitHub/spotube/ios/stable-Info.plist"; sourceTree = ""; }; - B536BDBF2B405FDE009B3CE4 /* dev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = dev.app; sourceTree = BUILT_PRODUCTS_DIR; }; - B536BDC02B405FDE009B3CE4 /* dev-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "dev-Info.plist"; path = "/Users/xiaobowen/Documents/GitHub/spotube/ios/dev-Info.plist"; sourceTree = ""; }; - B536BDE42B4060B3009B3CE4 /* nightly.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nightly.app; sourceTree = BUILT_PRODUCTS_DIR; }; - B536BDE52B4060B3009B3CE4 /* nightly-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "nightly-Info.plist"; path = "/Users/xiaobowen/Documents/GitHub/spotube/ios/nightly-Info.plist"; sourceTree = ""; }; - B5F91A319C771EEC978B238A /* Pods_stable.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_stable.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - B95530D9046F7F9BA07D2ADD /* Pods-Runner.profile-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile-stable.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile-stable.xcconfig"; sourceTree = ""; }; - BAC36FC304DBD4E8A8C00694 /* Pods_nightly.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_nightly.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - BDE1B62C8A5219CAA5D19583 /* Pods-nightly.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.release.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.release.xcconfig"; sourceTree = ""; }; - C3F494F4E243EAE21CEC5765 /* Pods-Runner.profile-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile-dev.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile-dev.xcconfig"; sourceTree = ""; }; - C63F01302EF00EAECE6BEA7C /* Pods-dev.release-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.release-dev.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.release-dev.xcconfig"; sourceTree = ""; }; - CA0F4EAB0789E68A7C771A07 /* Pods-nightly.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.profile.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.profile.xcconfig"; sourceTree = ""; }; - CE8646F5A4BCC46B0416DC84 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - D32BAE0F55672DD7669755B8 /* Pods-Runner.debug-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug-stable.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug-stable.xcconfig"; sourceTree = ""; }; - D9A69004587D01A7C68666CF /* Pods-dev.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.release.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.release.xcconfig"; sourceTree = ""; }; - E0EAB4380EE7C7EA7A350B6F /* Pods-stable.release-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.release-nightly.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.release-nightly.xcconfig"; sourceTree = ""; }; - E612EC392D0F07A90022720C /* HomePlayerWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HomePlayerWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - E612EC3A2D0F07A90022720C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; - E612EC3C2D0F07A90022720C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; - E6F17DB92D0F34E500BC2FA2 /* HomePlayerWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HomePlayerWidgetExtension.entitlements; sourceTree = ""; }; - E6F17DBA2D0F352C00BC2FA2 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; - E6F17DBB2D0F356700BC2FA2 /* stable.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = stable.entitlements; sourceTree = ""; }; - E6F17DBC2D0F357500BC2FA2 /* dev.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = dev.entitlements; sourceTree = ""; }; - E6F17DBD2D0F357F00BC2FA2 /* nightly.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = nightly.entitlements; sourceTree = ""; }; - E81F11471FD7D807286E33D6 /* Pods-dev.debug-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.debug-dev.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.debug-dev.xcconfig"; sourceTree = ""; }; - EB7783C1029CEC13F4B05D36 /* Pods-nightly.debug-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.debug-nightly.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.debug-nightly.xcconfig"; sourceTree = ""; }; - EBBED0A8DE0D0E230CD03613 /* Pods-dev.release-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.release-stable.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.release-stable.xcconfig"; sourceTree = ""; }; - F6F397A82E788E50B186ADC7 /* Pods-stable.debug-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.debug-stable.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.debug-stable.xcconfig"; sourceTree = ""; }; - F7E9EBDD27997A73A4D38EE1 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ - -/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - E612EC562D0F07AD0022720C /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = E612EC382D0F07A80022720C /* HomePlayerWidgetExtension */; - }; -/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ - -/* Begin PBXFileSystemSynchronizedRootGroup section */ - E612EC3E2D0F07A90022720C /* HomePlayerWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E612EC562D0F07AD0022720C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = HomePlayerWidget; sourceTree = ""; }; -/* End PBXFileSystemSynchronizedRootGroup section */ - -/* Begin PBXFrameworksBuildPhase section */ - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 46249B26D47C5DB81A4F972E /* Pods_Runner.framework in Frameworks */, - 051977801F58E8DBB6712352 /* Pods_Runner.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B536BD922B405DB1009B3CE4 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 4E86E0C42011EDB42C34AF9A /* Pods_stable.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B536BDB02B405FDE009B3CE4 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - C36A05AD330BBFAED75A62D5 /* Pods_dev.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B536BDD22B4060B3009B3CE4 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 17438EB903776D8D0E926C9B /* Pods_nightly.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - E612EC362D0F07A80022720C /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - E612EC3D2D0F07A90022720C /* SwiftUI.framework in Frameworks */, - E612EC3B2D0F07A90022720C /* WidgetKit.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 0E0B839C4E103F896209E822 /* Frameworks */ = { - isa = PBXGroup; - children = ( - F7E9EBDD27997A73A4D38EE1 /* Pods_Runner.framework */, - 4238A4985255EC9F93067739 /* Pods_dev.framework */, - BAC36FC304DBD4E8A8C00694 /* Pods_nightly.framework */, - B5F91A319C771EEC978B238A /* Pods_stable.framework */, - E612EC3A2D0F07A90022720C /* WidgetKit.framework */, - E612EC3C2D0F07A90022720C /* SwiftUI.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; - 67CBFE209DF24C94A9837AD5 /* Pods */ = { - isa = PBXGroup; - children = ( - 66F649AFA6E49EA44F469DA3 /* Pods-Runner.debug.xcconfig */, - CE8646F5A4BCC46B0416DC84 /* Pods-Runner.release.xcconfig */, - 171073CFF94F5751BC2B78DD /* Pods-Runner.profile.xcconfig */, - D32BAE0F55672DD7669755B8 /* Pods-Runner.debug-stable.xcconfig */, - 869E7B97AE866F2BCA2E5A6A /* Pods-Runner.release-stable.xcconfig */, - B95530D9046F7F9BA07D2ADD /* Pods-Runner.profile-stable.xcconfig */, - A59B7A01EEC476AF3141B518 /* Pods-Runner.debug-dev.xcconfig */, - 3E262038FF3BDA3B8A7BDAC3 /* Pods-Runner.release-dev.xcconfig */, - C3F494F4E243EAE21CEC5765 /* Pods-Runner.profile-dev.xcconfig */, - 77EFEBB27B276DD5F6B01B4B /* Pods-Runner.debug-nightly.xcconfig */, - 5A8B64E98ADDA28FB63AA32C /* Pods-Runner.release-nightly.xcconfig */, - 04C104D3779B4D1635D939BF /* Pods-Runner.profile-nightly.xcconfig */, - 6AE8151F4499707FA23C8223 /* Pods-dev.debug.xcconfig */, - 8B9DFB8E20C11066C3AB696A /* Pods-dev.debug-nightly.xcconfig */, - E81F11471FD7D807286E33D6 /* Pods-dev.debug-dev.xcconfig */, - 126B91CED32FAD3C40A67A23 /* Pods-dev.debug-stable.xcconfig */, - D9A69004587D01A7C68666CF /* Pods-dev.release.xcconfig */, - 2F9AD76AF35FFC693C051CE1 /* Pods-dev.release-nightly.xcconfig */, - C63F01302EF00EAECE6BEA7C /* Pods-dev.release-dev.xcconfig */, - EBBED0A8DE0D0E230CD03613 /* Pods-dev.release-stable.xcconfig */, - 46E04A5AA989356A32CD8E66 /* Pods-dev.profile.xcconfig */, - 1C9810F8B3FD927ED8C94791 /* Pods-dev.profile-nightly.xcconfig */, - 48E7E801EAE1B520AA5F35DD /* Pods-dev.profile-dev.xcconfig */, - 29304D1832AA30DE0C33E05C /* Pods-dev.profile-stable.xcconfig */, - 285DE2278D380EE2A6647CA9 /* Pods-nightly.debug.xcconfig */, - EB7783C1029CEC13F4B05D36 /* Pods-nightly.debug-nightly.xcconfig */, - 9878519B106548FD75CA15C0 /* Pods-nightly.debug-dev.xcconfig */, - 0F8FB58820FF492BD3CF9315 /* Pods-nightly.debug-stable.xcconfig */, - BDE1B62C8A5219CAA5D19583 /* Pods-nightly.release.xcconfig */, - 89CD409D60E1362C529707A4 /* Pods-nightly.release-nightly.xcconfig */, - 68BE49B58C0EBB578948D773 /* Pods-nightly.release-dev.xcconfig */, - 8CF39CF9464623571B63D15B /* Pods-nightly.release-stable.xcconfig */, - CA0F4EAB0789E68A7C771A07 /* Pods-nightly.profile.xcconfig */, - 261A31AC0DBA2D93BD1910D9 /* Pods-nightly.profile-nightly.xcconfig */, - 636F4A85470D9E3B4CC8AFB8 /* Pods-nightly.profile-dev.xcconfig */, - 9232DBE472C8CEA1101843D9 /* Pods-nightly.profile-stable.xcconfig */, - 8AD587044EF2C6A6FA3059DC /* Pods-stable.debug.xcconfig */, - 21C0B1DEE0F0BFD3F3651F79 /* Pods-stable.debug-nightly.xcconfig */, - 39E15EE1745C9266FDB59558 /* Pods-stable.debug-dev.xcconfig */, - F6F397A82E788E50B186ADC7 /* Pods-stable.debug-stable.xcconfig */, - 5014E8BD9F7181E528538444 /* Pods-stable.release.xcconfig */, - E0EAB4380EE7C7EA7A350B6F /* Pods-stable.release-nightly.xcconfig */, - 864AC9150518DFBA85A46A15 /* Pods-stable.release-dev.xcconfig */, - 2DA87118BE2AF25875B7C376 /* Pods-stable.release-stable.xcconfig */, - 4BDAF8FFADB62CA017755094 /* Pods-stable.profile.xcconfig */, - B38E6C7315D66215AFD8B218 /* Pods-stable.profile-nightly.xcconfig */, - 53AD516AAEB9A1331C99CBAE /* Pods-stable.profile-dev.xcconfig */, - 3F754C793C1BC0E8B8FFB5B7 /* Pods-stable.profile-stable.xcconfig */, - ); - path = Pods; - sourceTree = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - E6F17DBD2D0F357F00BC2FA2 /* nightly.entitlements */, - E6F17DBC2D0F357500BC2FA2 /* dev.entitlements */, - E6F17DBB2D0F356700BC2FA2 /* stable.entitlements */, - E6F17DB92D0F34E500BC2FA2 /* HomePlayerWidgetExtension.entitlements */, - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - E612EC3E2D0F07A90022720C /* HomePlayerWidget */, - 97C146EF1CF9000F007C117D /* Products */, - 67CBFE209DF24C94A9837AD5 /* Pods */, - 0E0B839C4E103F896209E822 /* Frameworks */, - B536BDA12B405DB1009B3CE4 /* stable-Info.plist */, - B536BDC02B405FDE009B3CE4 /* dev-Info.plist */, - B536BDE52B4060B3009B3CE4 /* nightly-Info.plist */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - B536BDA02B405DB1009B3CE4 /* stable.app */, - B536BDBF2B405FDE009B3CE4 /* dev.app */, - B536BDE42B4060B3009B3CE4 /* nightly.app */, - E612EC392D0F07A90022720C /* HomePlayerWidgetExtension.appex */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - E6F17DBA2D0F352C00BC2FA2 /* Runner.entitlements */, - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 2AF6C7D149EE8481703D5255 /* [CP] Check Pods Manifest.lock */, - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 6E9FEF583EA597C8B76255B2 /* [CP] Embed Pods Frameworks */, - 46F6EB27C31C41D86428A28B /* [CP] Copy Pods Resources */, - E612EC492D0F07AD0022720C /* Embed Foundation Extensions */, - E63F9CBC2D10709D00CD9E72 /* ShellScript */, - ); - buildRules = ( - ); - dependencies = ( - E612EC472D0F07AD0022720C /* PBXTargetDependency */, - ); - name = Runner; - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; - B536BD8C2B405DB1009B3CE4 /* stable */ = { - isa = PBXNativeTarget; - buildConfigurationList = B536BD9C2B405DB1009B3CE4 /* Build configuration list for PBXNativeTarget "stable" */; - buildPhases = ( - F0C8BA10A27CA77E18F842E7 /* [CP] Check Pods Manifest.lock */, - B536BD8E2B405DB1009B3CE4 /* Run Script */, - B536BD8F2B405DB1009B3CE4 /* Sources */, - B536BD922B405DB1009B3CE4 /* Frameworks */, - B536BD942B405DB1009B3CE4 /* Resources */, - B536BD992B405DB1009B3CE4 /* Embed Frameworks */, - B536BD9A2B405DB1009B3CE4 /* Thin Binary */, - A6D446F111DE4C4A202BE7F7 /* [CP] Embed Pods Frameworks */, - 2DEF3CF18D30E819C0FF4BCE /* [CP] Copy Pods Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = stable; - productName = Runner; - productReference = B536BDA02B405DB1009B3CE4 /* stable.app */; - productType = "com.apple.product-type.application"; - }; - B536BDAB2B405FDE009B3CE4 /* dev */ = { - isa = PBXNativeTarget; - buildConfigurationList = B536BDB82B405FDE009B3CE4 /* Build configuration list for PBXNativeTarget "dev" */; - buildPhases = ( - 6228176255365EAC646F2745 /* [CP] Check Pods Manifest.lock */, - B536BDAC2B405FDE009B3CE4 /* Run Script */, - B536BDAD2B405FDE009B3CE4 /* Sources */, - B536BDB02B405FDE009B3CE4 /* Frameworks */, - B536BDB12B405FDE009B3CE4 /* Resources */, - B536BDB62B405FDE009B3CE4 /* Embed Frameworks */, - B536BDB72B405FDE009B3CE4 /* Thin Binary */, - 244D41CE80E4BC0FFD63F8C6 /* [CP] Embed Pods Frameworks */, - 4DD66E9E53D92195290872BE /* [CP] Copy Pods Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = dev; - productName = Runner; - productReference = B536BDBF2B405FDE009B3CE4 /* dev.app */; - productType = "com.apple.product-type.application"; - }; - B536BDCD2B4060B3009B3CE4 /* nightly */ = { - isa = PBXNativeTarget; - buildConfigurationList = B536BDDA2B4060B3009B3CE4 /* Build configuration list for PBXNativeTarget "nightly" */; - buildPhases = ( - 5CD4405E93760FBD048E36E2 /* [CP] Check Pods Manifest.lock */, - B536BDCE2B4060B3009B3CE4 /* Run Script */, - B536BDCF2B4060B3009B3CE4 /* Sources */, - B536BDD22B4060B3009B3CE4 /* Frameworks */, - B536BDD32B4060B3009B3CE4 /* Resources */, - B536BDD82B4060B3009B3CE4 /* Embed Frameworks */, - B536BDD92B4060B3009B3CE4 /* Thin Binary */, - D566C841A84D807A607F6DE5 /* [CP] Embed Pods Frameworks */, - 5C9D945D6569D9C3AC420285 /* [CP] Copy Pods Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = nightly; - productName = Runner; - productReference = B536BDE42B4060B3009B3CE4 /* nightly.app */; - productType = "com.apple.product-type.application"; - }; - E612EC382D0F07A80022720C /* HomePlayerWidgetExtension */ = { - isa = PBXNativeTarget; - buildConfigurationList = E612EC572D0F07AD0022720C /* Build configuration list for PBXNativeTarget "HomePlayerWidgetExtension" */; - buildPhases = ( - E612EC352D0F07A80022720C /* Sources */, - E612EC362D0F07A80022720C /* Frameworks */, - E612EC372D0F07A80022720C /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - E612EC3E2D0F07A90022720C /* HomePlayerWidget */, - ); - name = HomePlayerWidgetExtension; - packageProductDependencies = ( - ); - productName = HomePlayerWidgetExtension; - productReference = E612EC392D0F07A90022720C /* HomePlayerWidgetExtension.appex */; - productType = "com.apple.product-type.app-extension"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - LastSwiftUpdateCheck = 1620; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - E612EC382D0F07A80022720C = { - CreatedOnToolsVersion = 16.2; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - B536BD8C2B405DB1009B3CE4 /* stable */, - B536BDAB2B405FDE009B3CE4 /* dev */, - B536BDCD2B4060B3009B3CE4 /* nightly */, - E612EC382D0F07A80022720C /* HomePlayerWidgetExtension */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B536BD942B405DB1009B3CE4 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B536BD952B405DB1009B3CE4 /* LaunchScreen.storyboard in Resources */, - B536BD962B405DB1009B3CE4 /* AppFrameworkInfo.plist in Resources */, - B536BD972B405DB1009B3CE4 /* Assets.xcassets in Resources */, - B536BD982B405DB1009B3CE4 /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B536BDB12B405FDE009B3CE4 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B536BDB22B405FDE009B3CE4 /* LaunchScreen.storyboard in Resources */, - B536BDB32B405FDE009B3CE4 /* AppFrameworkInfo.plist in Resources */, - B536BDB42B405FDE009B3CE4 /* Assets.xcassets in Resources */, - B536BDB52B405FDE009B3CE4 /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B536BDD32B4060B3009B3CE4 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B536BDD42B4060B3009B3CE4 /* LaunchScreen.storyboard in Resources */, - B536BDD52B4060B3009B3CE4 /* AppFrameworkInfo.plist in Resources */, - B536BDD62B4060B3009B3CE4 /* Assets.xcassets in Resources */, - B536BDD72B4060B3009B3CE4 /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - E612EC372D0F07A80022720C /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 244D41CE80E4BC0FFD63F8C6 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 2AF6C7D149EE8481703D5255 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 2DEF3CF18D30E819C0FF4BCE /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 46F6EB27C31C41D86428A28B /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; - 4DD66E9E53D92195290872BE /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; - 5C9D945D6569D9C3AC420285 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; - 5CD4405E93760FBD048E36E2 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-nightly-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 6228176255365EAC646F2745 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-dev-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 6E9FEF583EA597C8B76255B2 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; - }; - A6D446F111DE4C4A202BE7F7 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - B536BD8E2B405DB1009B3CE4 /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; - B536BD9A2B405DB1009B3CE4 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - B536BDAC2B405FDE009B3CE4 /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; - B536BDB72B405FDE009B3CE4 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - B536BDCE2B4060B3009B3CE4 /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; - B536BDD92B4060B3009B3CE4 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - D566C841A84D807A607F6DE5 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - E63F9CBC2D10709D00CD9E72 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# Type a script or drag a sgeneratedPath=\"$SRCROOT/Flutter/Generated.xcconfig\"\n\n# Read and trim versionNumber and buildNumber\nversionNumber=$(grep FLUTTER_BUILD_NAME \"$generatedPath\" | cut -d '=' -f2 | xargs)\nbuildNumber=$(grep FLUTTER_BUILD_NUMBER \"$generatedPath\" | cut -d '=' -f2 | xargs)\n\ninfoPlistPath=\"$SRCROOT/HomePlayerWidget/Info.plist\"\n\n# Check and add CFBundleVersion if it does not exist\n/usr/libexec/PlistBuddy -c \"Print :CFBundleVersion\" \"$infoPlistPath\" 2>/dev/null\nif [ $? != 0 ]; then\n /usr/libexec/PlistBuddy -c \"Add :CFBundleVersion string $buildNumber\" \"$infoPlistPath\"\nelse\n /usr/libexec/PlistBuddy -c \"Set :CFBundleVersion $buildNumber\" \"$infoPlistPath\"\nfi\n\n# Check and add CFBundleShortVersionString if it does not exist\n/usr/libexec/PlistBuddy -c \"Print :CFBundleShortVersionString\" \"$infoPlistPath\" 2>/dev/null\nif [ $? != 0 ]; then\n /usr/libexec/PlistBuddy -c \"Add :CFBundleShortVersionString string $versionNumber\" \"$infoPlistPath\"\nelse\n /usr/libexec/PlistBuddy -c \"Set :CFBundleShortVersionString $versionNumber\" \"$infoPlistPath\"\nfi\n\ncript file from your workspace to insert its path.\n"; - }; - F0C8BA10A27CA77E18F842E7 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-stable-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B536BD8F2B405DB1009B3CE4 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B536BD902B405DB1009B3CE4 /* AppDelegate.swift in Sources */, - B536BD912B405DB1009B3CE4 /* GeneratedPluginRegistrant.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B536BDAD2B405FDE009B3CE4 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B536BDAE2B405FDE009B3CE4 /* AppDelegate.swift in Sources */, - B536BDAF2B405FDE009B3CE4 /* GeneratedPluginRegistrant.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B536BDCF2B4060B3009B3CE4 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B536BDD02B4060B3009B3CE4 /* AppDelegate.swift in Sources */, - B536BDD12B4060B3009B3CE4 /* GeneratedPluginRegistrant.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - E612EC352D0F07A80022720C /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - E612EC472D0F07AD0022720C /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = E612EC382D0F07A80022720C /* HomePlayerWidgetExtension */; - targetProxy = E612EC462D0F07AD0022720C /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; - B536BD9D2B405DB1009B3CE4 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = stable.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "stable-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - B536BD9E2B405DB1009B3CE4 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = stable.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "stable-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; - B536BD9F2B405DB1009B3CE4 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = stable.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "stable-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - B536BDA22B405E06009B3CE4 /* Debug-stable */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = "Debug-stable"; - }; - B536BDA32B405E06009B3CE4 /* Debug-stable */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Debug-stable"; - }; - B536BDA42B405E06009B3CE4 /* Debug-stable */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = stable.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "stable-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Debug-stable"; - }; - B536BDA52B405E19009B3CE4 /* Release-stable */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = "Release-stable"; - }; - B536BDA62B405E19009B3CE4 /* Release-stable */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Release-stable"; - }; - B536BDA72B405E19009B3CE4 /* Release-stable */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = stable.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "stable-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Release-stable"; - }; - B536BDA82B405E1F009B3CE4 /* Profile-stable */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = "Profile-stable"; - }; - B536BDA92B405E1F009B3CE4 /* Profile-stable */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Profile-stable"; - }; - B536BDAA2B405E1F009B3CE4 /* Profile-stable */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = stable.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "stable-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Profile-stable"; - }; - B536BDB92B405FDE009B3CE4 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = dev.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "dev-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - B536BDBA2B405FDE009B3CE4 /* Debug-stable */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = dev.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "dev-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Debug-stable"; - }; - B536BDBB2B405FDE009B3CE4 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = dev.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "dev-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; - B536BDBC2B405FDE009B3CE4 /* Release-stable */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = dev.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "dev-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Release-stable"; - }; - B536BDBD2B405FDE009B3CE4 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = dev.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "dev-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - B536BDBE2B405FDE009B3CE4 /* Profile-stable */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = dev.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "dev-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Profile-stable"; - }; - B536BDC12B406014009B3CE4 /* Debug-dev */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = "Debug-dev"; - }; - B536BDC22B406014009B3CE4 /* Debug-dev */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Debug-dev"; - }; - B536BDC32B406014009B3CE4 /* Debug-dev */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = stable.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "stable-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Debug-dev"; - }; - B536BDC42B406014009B3CE4 /* Debug-dev */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = dev.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "dev-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Debug-dev"; - }; - B536BDC52B40601C009B3CE4 /* Release-dev */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = "Release-dev"; - }; - B536BDC62B40601C009B3CE4 /* Release-dev */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Release-dev"; - }; - B536BDC72B40601C009B3CE4 /* Release-dev */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = stable.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "stable-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Release-dev"; - }; - B536BDC82B40601C009B3CE4 /* Release-dev */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = dev.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "dev-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Release-dev"; - }; - B536BDC92B406021009B3CE4 /* Profile-dev */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = "Profile-dev"; - }; - B536BDCA2B406021009B3CE4 /* Profile-dev */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Profile-dev"; - }; - B536BDCB2B406021009B3CE4 /* Profile-dev */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = stable.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "stable-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Profile-dev"; - }; - B536BDCC2B406021009B3CE4 /* Profile-dev */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = dev.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "dev-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Profile-dev"; - }; - B536BDDB2B4060B3009B3CE4 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = nightly.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "nightly-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - B536BDDC2B4060B3009B3CE4 /* Debug-dev */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = nightly.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "nightly-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Debug-dev"; - }; - B536BDDD2B4060B3009B3CE4 /* Debug-stable */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = nightly.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "nightly-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Debug-stable"; - }; - B536BDDE2B4060B3009B3CE4 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = nightly.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "nightly-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; - B536BDDF2B4060B3009B3CE4 /* Release-dev */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = nightly.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "nightly-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Release-dev"; - }; - B536BDE02B4060B3009B3CE4 /* Release-stable */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = nightly.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "nightly-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Release-stable"; - }; - B536BDE12B4060B3009B3CE4 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = nightly.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "nightly-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - B536BDE22B4060B3009B3CE4 /* Profile-dev */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = nightly.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "nightly-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Profile-dev"; - }; - B536BDE32B4060B3009B3CE4 /* Profile-stable */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = nightly.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "nightly-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Profile-stable"; - }; - B536BDE62B4060FE009B3CE4 /* Debug-nightly */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = "Debug-nightly"; - }; - B536BDE72B4060FE009B3CE4 /* Debug-nightly */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Debug-nightly"; - }; - B536BDE82B4060FE009B3CE4 /* Debug-nightly */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = stable.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "stable-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Debug-nightly"; - }; - B536BDE92B4060FE009B3CE4 /* Debug-nightly */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = dev.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "dev-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Debug-nightly"; - }; - B536BDEA2B4060FE009B3CE4 /* Debug-nightly */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = nightly.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "nightly-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Debug-nightly"; - }; - B536BDEB2B406105009B3CE4 /* Release-nightly */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = "Release-nightly"; - }; - B536BDEC2B406105009B3CE4 /* Release-nightly */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Release-nightly"; - }; - B536BDED2B406105009B3CE4 /* Release-nightly */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = stable.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "stable-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Release-nightly"; - }; - B536BDEE2B406105009B3CE4 /* Release-nightly */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = dev.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "dev-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Release-nightly"; - }; - B536BDEF2B406105009B3CE4 /* Release-nightly */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = nightly.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "nightly-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Release-nightly"; - }; - B536BDF02B40610B009B3CE4 /* Profile-nightly */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = "Profile-nightly"; - }; - B536BDF12B40610B009B3CE4 /* Profile-nightly */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Profile-nightly"; - }; - B536BDF22B40610B009B3CE4 /* Profile-nightly */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = stable.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "stable-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Profile-nightly"; - }; - B536BDF32B40610B009B3CE4 /* Profile-nightly */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = dev.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "dev-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Profile-nightly"; - }; - B536BDF42B40610B009B3CE4 /* Profile-nightly */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = nightly.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "nightly-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Profile-nightly"; - }; - E612EC4A2D0F07AD0022720C /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = HomePlayerWidget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - E612EC4B2D0F07AD0022720C /* Debug-nightly */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = HomePlayerWidget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = "Debug-nightly"; - }; - E612EC4C2D0F07AD0022720C /* Debug-dev */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = HomePlayerWidget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = "Debug-dev"; - }; - E612EC4D2D0F07AD0022720C /* Debug-stable */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = HomePlayerWidget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = "Debug-stable"; - }; - E612EC4E2D0F07AD0022720C /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = HomePlayerWidget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - E612EC4F2D0F07AD0022720C /* Release-nightly */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = HomePlayerWidget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = "Release-nightly"; - }; - E612EC502D0F07AD0022720C /* Release-dev */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = HomePlayerWidget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = "Release-dev"; - }; - E612EC512D0F07AD0022720C /* Release-stable */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = HomePlayerWidget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = "Release-stable"; - }; - E612EC522D0F07AD0022720C /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = HomePlayerWidget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Profile; - }; - E612EC532D0F07AD0022720C /* Profile-nightly */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = HomePlayerWidget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = "Profile-nightly"; - }; - E612EC542D0F07AD0022720C /* Profile-dev */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = HomePlayerWidget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = "Profile-dev"; - }; - E612EC552D0F07AD0022720C /* Profile-stable */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = HomePlayerWidget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = "Profile-stable"; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - B536BDE62B4060FE009B3CE4 /* Debug-nightly */, - B536BDC12B406014009B3CE4 /* Debug-dev */, - B536BDA22B405E06009B3CE4 /* Debug-stable */, - 97C147041CF9000F007C117D /* Release */, - B536BDEB2B406105009B3CE4 /* Release-nightly */, - B536BDC52B40601C009B3CE4 /* Release-dev */, - B536BDA52B405E19009B3CE4 /* Release-stable */, - 249021D3217E4FDB00AE95B9 /* Profile */, - B536BDF02B40610B009B3CE4 /* Profile-nightly */, - B536BDC92B406021009B3CE4 /* Profile-dev */, - B536BDA82B405E1F009B3CE4 /* Profile-stable */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - B536BDE72B4060FE009B3CE4 /* Debug-nightly */, - B536BDC22B406014009B3CE4 /* Debug-dev */, - B536BDA32B405E06009B3CE4 /* Debug-stable */, - 97C147071CF9000F007C117D /* Release */, - B536BDEC2B406105009B3CE4 /* Release-nightly */, - B536BDC62B40601C009B3CE4 /* Release-dev */, - B536BDA62B405E19009B3CE4 /* Release-stable */, - 249021D4217E4FDB00AE95B9 /* Profile */, - B536BDF12B40610B009B3CE4 /* Profile-nightly */, - B536BDCA2B406021009B3CE4 /* Profile-dev */, - B536BDA92B405E1F009B3CE4 /* Profile-stable */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - B536BD9C2B405DB1009B3CE4 /* Build configuration list for PBXNativeTarget "stable" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B536BD9D2B405DB1009B3CE4 /* Debug */, - B536BDE82B4060FE009B3CE4 /* Debug-nightly */, - B536BDC32B406014009B3CE4 /* Debug-dev */, - B536BDA42B405E06009B3CE4 /* Debug-stable */, - B536BD9E2B405DB1009B3CE4 /* Release */, - B536BDED2B406105009B3CE4 /* Release-nightly */, - B536BDC72B40601C009B3CE4 /* Release-dev */, - B536BDA72B405E19009B3CE4 /* Release-stable */, - B536BD9F2B405DB1009B3CE4 /* Profile */, - B536BDF22B40610B009B3CE4 /* Profile-nightly */, - B536BDCB2B406021009B3CE4 /* Profile-dev */, - B536BDAA2B405E1F009B3CE4 /* Profile-stable */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - B536BDB82B405FDE009B3CE4 /* Build configuration list for PBXNativeTarget "dev" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B536BDB92B405FDE009B3CE4 /* Debug */, - B536BDE92B4060FE009B3CE4 /* Debug-nightly */, - B536BDC42B406014009B3CE4 /* Debug-dev */, - B536BDBA2B405FDE009B3CE4 /* Debug-stable */, - B536BDBB2B405FDE009B3CE4 /* Release */, - B536BDEE2B406105009B3CE4 /* Release-nightly */, - B536BDC82B40601C009B3CE4 /* Release-dev */, - B536BDBC2B405FDE009B3CE4 /* Release-stable */, - B536BDBD2B405FDE009B3CE4 /* Profile */, - B536BDF32B40610B009B3CE4 /* Profile-nightly */, - B536BDCC2B406021009B3CE4 /* Profile-dev */, - B536BDBE2B405FDE009B3CE4 /* Profile-stable */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - B536BDDA2B4060B3009B3CE4 /* Build configuration list for PBXNativeTarget "nightly" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B536BDDB2B4060B3009B3CE4 /* Debug */, - B536BDEA2B4060FE009B3CE4 /* Debug-nightly */, - B536BDDC2B4060B3009B3CE4 /* Debug-dev */, - B536BDDD2B4060B3009B3CE4 /* Debug-stable */, - B536BDDE2B4060B3009B3CE4 /* Release */, - B536BDEF2B406105009B3CE4 /* Release-nightly */, - B536BDDF2B4060B3009B3CE4 /* Release-dev */, - B536BDE02B4060B3009B3CE4 /* Release-stable */, - B536BDE12B4060B3009B3CE4 /* Profile */, - B536BDF42B40610B009B3CE4 /* Profile-nightly */, - B536BDE22B4060B3009B3CE4 /* Profile-dev */, - B536BDE32B4060B3009B3CE4 /* Profile-stable */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - E612EC572D0F07AD0022720C /* Build configuration list for PBXNativeTarget "HomePlayerWidgetExtension" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - E612EC4A2D0F07AD0022720C /* Debug */, - E612EC4B2D0F07AD0022720C /* Debug-nightly */, - E612EC4C2D0F07AD0022720C /* Debug-dev */, - E612EC4D2D0F07AD0022720C /* Debug-stable */, - E612EC4E2D0F07AD0022720C /* Release */, - E612EC4F2D0F07AD0022720C /* Release-nightly */, - E612EC502D0F07AD0022720C /* Release-dev */, - E612EC512D0F07AD0022720C /* Release-stable */, - E612EC522D0F07AD0022720C /* Profile */, - E612EC532D0F07AD0022720C /* Profile-nightly */, - E612EC542D0F07AD0022720C /* Profile-dev */, - E612EC552D0F07AD0022720C /* Profile-stable */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index c53e2b31..00000000 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme deleted file mode 100644 index 1ccca8e1..00000000 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/nightly.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/nightly.xcscheme deleted file mode 100644 index 7ec18a73..00000000 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/nightly.xcscheme +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/stable.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/stable.xcscheme deleted file mode 100644 index ddc19e2e..00000000 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/stable.xcscheme +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 21a3cc14..00000000 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift deleted file mode 100644 index f512ac86..00000000 --- a/ios/Runner/AppDelegate.swift +++ /dev/null @@ -1,18 +0,0 @@ -import UIKit -import Flutter - -@UIApplicationMain -@objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - // Add this to get Documents directory path - if let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.path { - UserDefaults.standard.set(documentsPath, forKey: "download_path") - } - - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } -} diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-1024x1024@1x.png deleted file mode 100644 index 2185c35d..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-1024x1024@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@1x.png deleted file mode 100644 index 172bc383..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@2x.png deleted file mode 100644 index 876617d9..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@3x.png deleted file mode 100644 index fec86d87..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@1x.png deleted file mode 100644 index fbb0f45a..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@2x.png deleted file mode 100644 index 854e1e45..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@3x.png deleted file mode 100644 index 420d2a17..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@1x.png deleted file mode 100644 index 876617d9..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@2x.png deleted file mode 100644 index 6b7a608d..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@3x.png deleted file mode 100644 index d871fa3f..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@1x.png deleted file mode 100644 index e77b38df..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@2x.png deleted file mode 100644 index 358cb28f..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@1x.png deleted file mode 100644 index 87f4290b..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@2x.png deleted file mode 100644 index 53c10a09..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@2x.png deleted file mode 100644 index d871fa3f..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@3x.png deleted file mode 100644 index 4bb39789..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@1x.png deleted file mode 100644 index 8164cc67..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@2x.png deleted file mode 100644 index e58ef25f..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@1x.png deleted file mode 100644 index f5e1ae5a..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@2x.png deleted file mode 100644 index 99bd36c9..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-83.5x83.5@2x.png deleted file mode 100644 index 8d199658..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-83.5x83.5@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/Contents.json deleted file mode 100644 index 1ce0f517..00000000 --- a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/Contents.json +++ /dev/null @@ -1 +0,0 @@ -{"images":[{"size":"20x20","idiom":"iphone","filename":"AppIcon-nightly-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"AppIcon-nightly-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-nightly-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-nightly-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-nightly-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-nightly-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-nightly-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"AppIcon-nightly-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"AppIcon-nightly-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-nightly-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-nightly-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-nightly-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-nightly-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-nightly-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-nightly-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-nightly-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-nightly-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"AppIcon-nightly-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"AppIcon-nightly-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"AppIcon-nightly-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"AppIcon-nightly-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-nightly-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-nightly-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"AppIcon-nightly-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"AppIcon-nightly-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-1024x1024@1x.png deleted file mode 100644 index aaf8f69b..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-1024x1024@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png deleted file mode 100644 index 2ac9068e..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png deleted file mode 100644 index d0a01485..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png deleted file mode 100644 index 693f7baa..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png deleted file mode 100644 index 033019fc..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png deleted file mode 100644 index 809668c3..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png deleted file mode 100644 index eaa1de13..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png deleted file mode 100644 index d0a01485..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png deleted file mode 100644 index ffb602b3..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png deleted file mode 100644 index 77d37d5d..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-50x50@1x.png deleted file mode 100644 index a26cd088..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-50x50@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-50x50@2x.png deleted file mode 100644 index 8d860f15..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-50x50@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-57x57@1x.png deleted file mode 100644 index 6a480baf..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-57x57@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-57x57@2x.png deleted file mode 100644 index d8b55615..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-57x57@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png deleted file mode 100644 index 77d37d5d..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png deleted file mode 100644 index 2b587235..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-72x72@1x.png deleted file mode 100644 index efac11ba..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-72x72@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-72x72@2x.png deleted file mode 100644 index a73fe33c..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-72x72@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png deleted file mode 100644 index e8ac9032..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png deleted file mode 100644 index e1859a0d..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png deleted file mode 100644 index f863a923..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d0d98aa1..00000000 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1 +0,0 @@ -{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index 59407b44..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 5eacaa5f..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index bbb9f839..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 28d0d8a8..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index b1df0e72..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index 24b76e25..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 5c0b6d57..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index bbb9f839..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index bdc5656b..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index c03c89fe..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png deleted file mode 100644 index 40b88968..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png deleted file mode 100644 index 2050f427..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png deleted file mode 100644 index d1ccab30..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png deleted file mode 100644 index 47c629f3..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index c03c89fe..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index 22d28c12..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png deleted file mode 100644 index c9e5cfad..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png deleted file mode 100644 index 3450fb00..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 3dd3eda3..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 2e69c843..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index f769eb6e..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage.png b/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage.png deleted file mode 100644 index 3ff2a2da..00000000 Binary files a/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage@2x.png b/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage@2x.png deleted file mode 100644 index 8e2bb197..00000000 Binary files a/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage@3x.png b/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage@3x.png deleted file mode 100644 index d301093a..00000000 Binary files a/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/BrandingImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/BrandingImage.imageset/Contents.json deleted file mode 100644 index 12712275..00000000 --- a/ios/Runner/Assets.xcassets/BrandingImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "BrandingImage.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "BrandingImage@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "BrandingImage@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage.png b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage.png deleted file mode 100644 index 3ff2a2da..00000000 Binary files a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@2x.png b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@2x.png deleted file mode 100644 index 8e2bb197..00000000 Binary files a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@3x.png b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@3x.png deleted file mode 100644 index d301093a..00000000 Binary files a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/Contents.json b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/Contents.json deleted file mode 100644 index 12712275..00000000 --- a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "BrandingImage.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "BrandingImage@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "BrandingImage@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json deleted file mode 100644 index 9f447e1b..00000000 --- a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "background.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png deleted file mode 100644 index 4bebb9de..00000000 Binary files a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/Contents.json deleted file mode 100644 index 9f447e1b..00000000 --- a/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "background.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/background.png deleted file mode 100644 index 4bebb9de..00000000 Binary files a/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/background.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 00cabce8..00000000 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "LaunchImage.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "LaunchImage@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "LaunchImage@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 6e04efdc..00000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 51a669aa..00000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index cc79cb85..00000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725b..00000000 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/Contents.json deleted file mode 100644 index 00cabce8..00000000 --- a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "LaunchImage.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "LaunchImage@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "LaunchImage@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage.png deleted file mode 100644 index d8f7cc0e..00000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@2x.png deleted file mode 100644 index 17a2c373..00000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@3x.png deleted file mode 100644 index db53f016..00000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index ec8a1de3..00000000 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner/Base.lproj/LaunchScreenNightly.storyboard b/ios/Runner/Base.lproj/LaunchScreenNightly.storyboard deleted file mode 100644 index 645a417f..00000000 --- a/ios/Runner/Base.lproj/LaunchScreenNightly.storyboard +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c28516..00000000 --- a/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist deleted file mode 100644 index 8d6c09a2..00000000 --- a/ios/Runner/Info.plist +++ /dev/null @@ -1,82 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Spotube - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - spotube - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsArbitraryLoadsForMedia - - - NSCameraUsageDescription - This app require access to the device camera - NSMicrophoneUsageDescription - This app does not require access to the device microphone - NSPhotoLibraryUsageDescription - This app require access to the photo library - UIApplicationSupportsIndirectInputEvents - - UIBackgroundModes - - audio - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIStatusBarHidden - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - NSLocalNetworkUsageDescription - To allow other devices on the network control playback of Spotube securely. - NSBonjourServices - - _spotube._tcp - - UIFileSharingEnabled - - LSSupportsOpeningDocumentsInPlace - - UISupportsDocumentBrowser - - - diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a56..00000000 --- a/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements deleted file mode 100644 index 58165678..00000000 --- a/ios/Runner/Runner.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.spotube_home_player_widget - - - diff --git a/ios/build/.last_build_id b/ios/build/.last_build_id deleted file mode 100644 index ee73fd53..00000000 --- a/ios/build/.last_build_id +++ /dev/null @@ -1 +0,0 @@ -6f5ed64a4065df2d43bfb5b18863018c \ No newline at end of file diff --git a/ios/dev-Info.plist b/ios/dev-Info.plist deleted file mode 100644 index 3581787b..00000000 --- a/ios/dev-Info.plist +++ /dev/null @@ -1,70 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Spotube Dev - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - spotube - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsArbitraryLoadsForMedia - - - NSCameraUsageDescription - This app require access to the device camera - NSMicrophoneUsageDescription - This app does not require access to the device microphone - NSPhotoLibraryUsageDescription - This app require access to the photo library - UIApplicationSupportsIndirectInputEvents - - UIBackgroundModes - - audio - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIStatusBarHidden - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - - diff --git a/ios/dev.entitlements b/ios/dev.entitlements deleted file mode 100644 index 58165678..00000000 --- a/ios/dev.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.spotube_home_player_widget - - - diff --git a/ios/nightly-Info.plist b/ios/nightly-Info.plist deleted file mode 100644 index e1db8f01..00000000 --- a/ios/nightly-Info.plist +++ /dev/null @@ -1,70 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Spotube - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - spotube - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsArbitraryLoadsForMedia - - - NSCameraUsageDescription - This app require access to the device camera - NSMicrophoneUsageDescription - This app does not require access to the device microphone - NSPhotoLibraryUsageDescription - This app require access to the photo library - UIApplicationSupportsIndirectInputEvents - - UIBackgroundModes - - audio - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIStatusBarHidden - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - - diff --git a/ios/nightly.entitlements b/ios/nightly.entitlements deleted file mode 100644 index 58165678..00000000 --- a/ios/nightly.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.spotube_home_player_widget - - - diff --git a/ios/stable-Info.plist b/ios/stable-Info.plist deleted file mode 100644 index e1db8f01..00000000 --- a/ios/stable-Info.plist +++ /dev/null @@ -1,70 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Spotube - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - spotube - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsArbitraryLoadsForMedia - - - NSCameraUsageDescription - This app require access to the device camera - NSMicrophoneUsageDescription - This app does not require access to the device microphone - NSPhotoLibraryUsageDescription - This app require access to the photo library - UIApplicationSupportsIndirectInputEvents - - UIBackgroundModes - - audio - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIStatusBarHidden - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - - diff --git a/ios/stable.entitlements b/ios/stable.entitlements deleted file mode 100644 index 58165678..00000000 --- a/ios/stable.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.spotube_home_player_widget - - - diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig new file mode 100644 index 00000000..5f1e25ca --- /dev/null +++ b/iosApp/Configuration/Config.xcconfig @@ -0,0 +1,7 @@ +TEAM_ID= + +PRODUCT_NAME=Spotube +PRODUCT_BUNDLE_IDENTIFIER=org.spotube.spotube.Spotube$(TEAM_ID) + +CURRENT_PROJECT_VERSION=1 +MARKETING_VERSION=1.0 \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000..0eb68270 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,373 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 11DF02E4B44E5BCF61736473 /* Spotube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spotube.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + FC0CC9A88AED959E9CE9C913 /* Exceptions for "iosApp" folder in "iosApp" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = CBFC97515810713C553A7541 /* iosApp */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 5BB730BF90365122B74925FF /* iosApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + FC0CC9A88AED959E9CE9C913 /* Exceptions for "iosApp" folder in "iosApp" target */, + ); + path = iosApp; + sourceTree = ""; + }; + E265D0F6D10F52F5C1374EEB /* Configuration */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Configuration; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + EFCF243BC7D8246E008EF23D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + EFA169D322B90F32841D602F = { + isa = PBXGroup; + children = ( + E265D0F6D10F52F5C1374EEB /* Configuration */, + 5BB730BF90365122B74925FF /* iosApp */, + 835615636B5FE86AA850B7DB /* Products */, + ); + sourceTree = ""; + }; + 835615636B5FE86AA850B7DB /* Products */ = { + isa = PBXGroup; + children = ( + 11DF02E4B44E5BCF61736473 /* Spotube.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + CBFC97515810713C553A7541 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + 6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */, + 314349EF3C4149F4A4A72378 /* Sources */, + EFCF243BC7D8246E008EF23D /* Frameworks */, + ED9557BFD7438D37A5E9513D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 5BB730BF90365122B74925FF /* iosApp */, + ); + name = iosApp; + packageProductDependencies = ( + ); + productName = iosApp; + productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B056E79C138AE849968965F1 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1620; + LastUpgradeCheck = 1620; + TargetAttributes = { + CBFC97515810713C553A7541 = { + CreatedOnToolsVersion = 16.2; + }; + }; + }; + buildConfigurationList = 79A44D8DEA428263AC7DC2E1 /* Build configuration list for PBXProject "iosApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = EFA169D322B90F32841D602F; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 835615636B5FE86AA850B7DB /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + CBFC97515810713C553A7541 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + ED9557BFD7438D37A5E9513D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Compile Kotlin Framework"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 314349EF3C4149F4A4A72378 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + AF5828948B37FEEF5EF7F345 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */; + baseConfigurationReferenceRelativePath = Config.xcconfig; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 1C42200356BD14712FAE1CFA /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */; + baseConfigurationReferenceRelativePath = Config.xcconfig; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + AC996E9458BD3DAE9610EA49 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = "${TEAM_ID}"; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4FB276594B17F1A0D71D2462 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = "${TEAM_ID}"; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 79A44D8DEA428263AC7DC2E1 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AF5828948B37FEEF5EF7F345 /* Debug */, + 1C42200356BD14712FAE1CFA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AC996E9458BD3DAE9610EA49 /* Debug */, + 4FB276594B17F1A0D71D2462 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = B056E79C138AE849968965F1 /* Project object */; +} \ No newline at end of file diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/ios/HomePlayerWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from ios/HomePlayerWidget/Assets.xcassets/AccentColor.colorset/Contents.json rename to iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/ios/HomePlayerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 74% rename from ios/HomePlayerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json rename to iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json index 23058801..5a38d341 100644 --- a/ios/HomePlayerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "app-icon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -14,7 +15,8 @@ ], "idiom" : "universal", "platform" : "ios", - "size" : "1024x1024" + "size" : "1024x1024", + "filename" : "app-icon-1024-dark.png" }, { "appearances" : [ @@ -25,7 +27,8 @@ ], "idiom" : "universal", "platform" : "ios", - "size" : "1024x1024" + "size" : "1024x1024", + "filename" : "app-icon-1024-tinted.png" } ], "info" : { diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-dark.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-dark.png new file mode 100644 index 00000000..dbf53a46 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-dark.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-tinted.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-tinted.png new file mode 100644 index 00000000..eb3721eb Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-tinted.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png new file mode 100644 index 00000000..dbf53a46 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png differ diff --git a/ios/HomePlayerWidget/Assets.xcassets/Contents.json b/iosApp/iosApp/Assets.xcassets/Contents.json similarity index 100% rename from ios/HomePlayerWidget/Assets.xcassets/Contents.json rename to iosApp/iosApp/Assets.xcassets/Contents.json diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift new file mode 100644 index 00000000..445341b2 --- /dev/null +++ b/iosApp/iosApp/ContentView.swift @@ -0,0 +1,36 @@ +// Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import UIKit +import SwiftUI +import ComposeApp + +struct ComposeView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + MainViewControllerKt.MainViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +struct ContentView: View { + var body: some View { + ComposeView() + .ignoresSafeArea() + } +} + + + diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist new file mode 100644 index 00000000..5663e50d --- /dev/null +++ b/iosApp/iosApp/Info.plist @@ -0,0 +1,26 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleURLTypes + + + CFBundleURLName + org.spotube.spotube + CFBundleURLSchemes + + spotube + + + + NSLocalNetworkUsageDescription + Required to discover local network devices + NSBonjourServices + + _http._tcp + _spotube-ctrl._tcp + + + diff --git a/ios/Runner/Assets.xcassets/Contents.json b/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json similarity index 100% rename from ios/Runner/Assets.xcassets/Contents.json rename to iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift new file mode 100644 index 00000000..349b4ba1 --- /dev/null +++ b/iosApp/iosApp/iOSApp.swift @@ -0,0 +1,29 @@ +// Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import SwiftUI +import ComposeApp + +@main +struct iOSApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .onOpenURL { url in + ExternalUriHandler.shared.onNewUri(uri: url.absoluteString) + } + } + } +} \ No newline at end of file diff --git a/js_plugin_example/.gitignore b/js_plugin_example/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/js_plugin_example/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/js_plugin_example/build.gradle.kts b/js_plugin_example/build.gradle.kts new file mode 100644 index 00000000..6b88fb8a --- /dev/null +++ b/js_plugin_example/build.gradle.kts @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnPlugin +import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnRootExtension + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.zipline.gradle.plugin) + alias(libs.plugins.spotubeGradle) +} + +kotlin { + applyDefaultHierarchyTemplate() + + js { + browser() + binaries.executable() + } + + sourceSets { + commonMain.dependencies { + implementation(project(":plugin_interfaces")) + + api(libs.zipline.core) + api(libs.kotlinx.coroutines.core) + api(libs.semver) + } + + jsMain.dependencies {} + } +} + +zipline { + mainFunction.set("org.spotube.js_plugin_example.main") +} + +plugins.withType { + the().yarnLockAutoReplace = true +} + +//fun registerPackageTask(flavor: String, compileTaskName: String) { +// val capitalizedFlavor = flavor.replaceFirstChar { it.uppercase() } +// +// tasks.register("package${capitalizedFlavor}Plugin") { +// group = "distribution" +// description = "Packages the $flavor Zipline executable and plugin.json into a smplug." +// +// // 1. Depend on the Zipline compilation task +// val compileTask = tasks.named(compileTaskName) +// dependsOn(compileTask) +// +// // 2. Set the output location and name +// archiveFileName.set("plugin-$flavor.smplug") +// destinationDirectory.set(layout.buildDirectory.dir("distributions")) +// +// // 3. Include the Zipline outputs +// // We use a provider/closure to ensure the directory exists when the task runs +// from(layout.buildDirectory.dir("zipline/$capitalizedFlavor")) { +// // This preserves subdirectories if Zipline generated any +// include("**/*") +// } +// +// // 4. Include plugin.json +// // Use layout.projectDirectory.file() to reach the root JSON +// from(layout.projectDirectory.file("plugin.json")) +// +// // Set inputs for incremental build support +// inputs.file(layout.projectDirectory.file("plugin.json")) +// } +//} +// +//registerPackageTask("development", "compileDevelopmentExecutableKotlinJsZipline") +//registerPackageTask("production", "compileProductionExecutableKotlinJsZipline") \ No newline at end of file diff --git a/js_plugin_example/plugin.json b/js_plugin_example/plugin.json new file mode 100644 index 00000000..58cf95e5 --- /dev/null +++ b/js_plugin_example/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "js_plugin_example", + "version": "1.0.0", + "description": "A simple JavaScript plugin example.", + "apiVersion": "0.0.1", + "author": "Kingkor Roy Tirtho", + "capabilities": [ + "PERSISTENT_STORAGE", + "NETWORK_REQUESTS", + "WEBVIEW" + ], + "abilities": [ + "METADATA", + "AUDIO", + "LYRICS", + "SCROBBLE" + ] +} \ No newline at end of file diff --git a/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/js.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/js.kt new file mode 100644 index 00000000..4893cd32 --- /dev/null +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/js.kt @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.js_plugin_example + +import app.cash.zipline.Zipline +import org.spotube.js_plugin_example.plugin_apis.audio.RealAudioAPI +import org.spotube.js_plugin_example.plugin_apis.core.RealCoreAPI +import org.spotube.js_plugin_example.plugin_apis.lyrics.RealLyricsAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataAlbumAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataArtistAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataBrowseAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataPlaylistAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataSearchAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataTrackAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataUserAPI +import org.spotube.js_plugin_example.plugin_apis.scrobble.RealScrobbleAPI +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.PersistedStorageAPI +import org.spotube.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.WebViewAPI +import org.spotube.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME +import kotlin.js.Date + +private val zipline by lazy { Zipline.get() } + +@OptIn(ExperimentalJsExport::class) +@JsExport +fun main() { + zipline.take(HttpClientAPI_SERVICE_NAME) + zipline.take(WebViewAPI_SERVICE_NAME) + zipline.take(PersistedStorageAPI_SERVICE_NAME) + console.log("Epoch Time: ${Date.now()}") + + zipline.bind(CoreAPI_SERVICE_NAME, RealCoreAPI()) + zipline.bind(AudioAPI_SERVICE_NAME, RealAudioAPI()) + zipline.bind(LyricsAPI_SERVICE_NAME, RealLyricsAPI()) + zipline.bind(ScrobbleAPI_SERVICE_NAME, RealScrobbleAPI()) + + zipline.bind(MetadataAlbumAPI_SERVICE_NAME, RealMetadataAlbumAPI()) + zipline.bind(MetadataArtistAPI_SERVICE_NAME, RealMetadataArtistAPI()) + zipline.bind(MetadataBrowseAPI_SERVICE_NAME, RealMetadataBrowseAPI()) + zipline.bind(MetadataPlaylistAPI_SERVICE_NAME, RealMetadataPlaylistAPI()) + zipline.bind(MetadataSearchAPI_SERVICE_NAME, RealMetadataSearchAPI()) + zipline.bind(MetadataTrackAPI_SERVICE_NAME, RealMetadataTrackAPI()) + zipline.bind(MetadataUserAPI_SERVICE_NAME, RealMetadataUserAPI()) +} \ No newline at end of file diff --git a/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt new file mode 100644 index 00000000..6f861550 --- /dev/null +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.js_plugin_example.plugin_apis.audio + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack + +val urls = listOf( + "https://cdn.pixabay.com/audio/2024/02/28/audio_d1a9995fc3.mp3", + "https://cdn.pixabay.com/audio/2024/05/29/audio_f3a1d24f19.mp3", + "https://cdn.pixabay.com/audio/2025/03/18/audio_9c95eb2557.mp3", + "https://cdn.pixabay.com/audio/2023/06/02/audio_320a2e0f57.mp3", + "https://cdn.pixabay.com/audio/2024/06/30/audio_0eb1f1f4ec.mp3", + "https://cdn.pixabay.com/audio/2023/08/12/audio_5cdd274d4b.mp3", + "https://cdn.pixabay.com/audio/2025/03/17/audio_a71887c1b8.mp3", + "https://cdn.pixabay.com/audio/2026/03/25/audio_19c3c36ce2.mp3", + "https://cdn.pixabay.com/audio/2023/06/25/audio_ded864e440.mp3" +) + +class RealAudioAPI : AudioAPI { + override val supportedQualities: List = listOf( + AudioFormat( + codec = "mp3", container = "mp3", qualities = listOf( + AudioQuality.Lossy(bitrate = 128), + AudioQuality.Lossy(bitrate = 320), + ) + ), + AudioFormat( + codec = "mp3", container = "mp3", qualities = listOf( + AudioQuality.Lossy(bitrate = 128), + AudioQuality.Lossy(bitrate = 320), + ) + ), + AudioFormat( + codec = "aac", container = "aac", qualities = listOf( + AudioQuality.Lossy(bitrate = 160), + ) + ), + ) + + override suspend fun getStreamsByTrack(track: MetadataTrack): List { + val title = track.title.ifBlank { "Unknown Track" } + val artist = track.artists.firstOrNull()?.name ?: "Unknown Artist" + val album = track.album?.title?.ifBlank { null } + + return listOf( + AudioSource.Basic( + id = "${track.id}-source-1", + title = title, + artist = artist, + album = album, + thumbnails = track.album?.thumbnails.orEmpty(), + externalUri = track.externalUri, + confidence = 0.98f, + ), + AudioSource.Basic( + id = "${track.id}-source-2", + title = "$title (Alternative)", + artist = artist, + album = album, + thumbnails = track.album?.thumbnails.orEmpty(), + externalUri = track.externalUri, + confidence = 0.92f, + ) + ) + } + + override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List { + val defaultDuration = 180_000L + val primary = AudioSource.Streamed( + id = source.id, + title = source.title, + artist = source.artist, + album = source.album, + thumbnails = source.thumbnails, + externalUri = source.externalUri, + confidence = source.confidence, + streams = listOf( + AudioStream.Lossy( + url = urls.random(), + codec = "mp3", + bitrate = 256000, + container = "mp3", + ), + ).shuffled() + ) + + return listOf(primary) + } +} \ No newline at end of file diff --git a/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/core/RealCoreAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/core/RealCoreAPI.kt new file mode 100644 index 00000000..9a25de0a --- /dev/null +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/core/RealCoreAPI.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.js_plugin_example.plugin_apis.core + +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.core.PluginUpdateInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class RealCoreAPI: CoreAPI { + override suspend fun checkPluginUpdates(currentVersion: net.swiftzer.semver.SemVer): PluginUpdateInfo? { + return null + } + + override fun supportMarkdownText(currentVersion: net.swiftzer.semver.SemVer): String { + return "Support us please!" + } + + override val requiresAuthentication = true + + private val stateFlow = MutableStateFlow(false) + override val loggedInFlow: StateFlow = stateFlow.asStateFlow() + + override suspend fun login() { + stateFlow.value = true + } + + override suspend fun logout() { + stateFlow.value = false + } + +} \ No newline at end of file diff --git a/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt new file mode 100644 index 00000000..8d776b8f --- /dev/null +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.js_plugin_example.plugin_apis.lyrics + +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricType +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.SyncLyricsLine + +class RealLyricsAPI : LyricsAPI { + override val supportedLyricTypes: List = listOf(LyricType.STATIC, LyricType.SYNCED) + + override suspend fun getStaticLyrics(trackId: String): String { + return """ + Dummy lyrics for track $trackId + + Verse 1: + This is a placeholder line + Singing through the test design + + Chorus: + La la la, plugin sample song + Everything compiles all along + """.trimIndent() + } + + override suspend fun getSyncedLyrics(trackId: String): List { + return listOf( + SyncLyricsLine(time = 0L, text = "[$trackId] Intro"), + SyncLyricsLine(time = 10_000L, text = "This is a synced dummy lyric line"), + SyncLyricsLine(time = 20_000L, text = "Another line appears right on time"), + SyncLyricsLine(time = 30_000L, text = "Chorus: La la la, plugin sample song"), + SyncLyricsLine(time = 40_000L, text = "Outro: End of demo lyrics") + ) + } +} \ No newline at end of file diff --git a/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt new file mode 100644 index 00000000..4d858495 --- /dev/null +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt @@ -0,0 +1,598 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.js_plugin_example.plugin_apis.metadata + +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser + +private data class ArtistEntity( + val id: String, + val name: String, + val genres: List, + val biography: String, + val followersCount: Int, +) + +private data class AlbumEntity( + val id: String, + val title: String, + val description: String, + val albumType: MetadataAlbumType, + val artistIds: List, + val releaseDate: String, + val genres: List, + val trackIds: MutableList, +) + +private data class TrackEntity( + val id: String, + val title: String, + val durationMs: Long, + val trackNumber: Int, + val discNumber: Int, + val artistIds: List, + val albumId: String, + val explicit: Boolean, + val popularity: Int, + val isrcCode: String, +) + +private data class UserEntity( + val id: String, + val username: String, + var displayName: String, +) + +private data class PlaylistEntity( + val id: String, + var title: String, + var description: String?, + var ownerId: String, + var thumbnail: String, + var isPublic: Boolean, + var isCollaborating: Boolean, + val trackIds: MutableList, +) + +object FakeMetadataStore { + const val SECTION_NEW_RELEASES = "new-releases" + const val SECTION_TOP_ARTISTS = "top-artists" + const val SECTION_FEATURED_PLAYLISTS = "featured-playlists" + + private const val CURRENT_USER_ID = "user-1" + + private val artists = linkedMapOf() + private val albums = linkedMapOf() + private val tracks = linkedMapOf() + private val users = linkedMapOf() + private val playlists = linkedMapOf() + + private val savedTrackIds = linkedSetOf() + private val savedAlbumIds = linkedSetOf() + private val savedArtistIds = linkedSetOf() + private val savedPlaylistIds = linkedSetOf() + + private var playlistCounter = 1000 + + init { + seed() + } + + fun getUser(id: String): MetadataUser? = users[id]?.toModel() + + fun getCurrentUser(): MetadataUser? = users[CURRENT_USER_ID]?.toModel() + + fun getTrack(id: String): MetadataTrack = tracks[id]?.toModel() + ?: error("Track not found: $id") + + fun getAlbum(id: String): MetadataAlbum.Detailed = albums[id]?.toDetailedModel() + ?: error("Album not found: $id") + + fun getArtist(id: String): MetadataArtist.Detailed = artists[id]?.toDetailedModel() + ?: error("Artist not found: $id") + + fun getArtistTopTracks(id: String): List { + return tracks.values + .filter { id in it.artistIds } + .sortedByDescending { it.popularity } + .take(10) + .map { it.toModel() } + } + + fun getArtistAlbums(id: String): List { + return albums.values + .filter { id in it.artistIds } + .sortedByDescending { it.releaseDate } + .map { it.toDetailedModel() } + } + + fun getAlbumTracks(id: String): List { + val album = albums[id] ?: error("Album not found: $id") + return album.trackIds.mapNotNull { trackId -> tracks[trackId]?.toModel() } + } + + fun getPlaylist(id: String): MetadataPlaylist = playlists[id]?.toModel() + ?: error("Playlist not found: $id") + + fun getPlaylistTracks(id: String): List { + val playlist = playlists[id] ?: error("Playlist not found: $id") + return playlist.trackIds.mapNotNull { trackId -> tracks[trackId]?.toModel() } + } + + fun getFeaturedItems(): List { + val featuredTrack = tracks.values.maxByOrNull { it.popularity }?.toModel() + val featuredAlbum = albums.values.maxByOrNull { it.trackIds.size }?.toBasicModel() + val featuredArtist = artists.values.maxByOrNull { it.followersCount }?.toBasicModel() + val featuredPlaylist = playlists.values.firstOrNull()?.toModel() + + return listOfNotNull( + featuredTrack?.let { MetadataBrowseItem.Track(it) }, + featuredAlbum?.let { MetadataBrowseItem.Album(it) }, + featuredArtist?.let { MetadataBrowseItem.Artist(it) }, + featuredPlaylist?.let { MetadataBrowseItem.Playlist(it) }, + ) + } + + fun getBrowseSections(): List { + val newReleases = albums.values + .sortedByDescending { it.releaseDate } + .take(8) + .map { MetadataBrowseItem.Album(it.toBasicModel()) } + + val topArtists = artists.values + .sortedByDescending { it.followersCount } + .take(8) + .map { MetadataBrowseItem.Artist(it.toBasicModel()) } + + val featuredPlaylists = playlists.values + .take(8) + .map { MetadataBrowseItem.Playlist(it.toModel()) } + + return listOf( + MetadataBrowseSection( + title = "New Releases", + description = "Recently released albums", + items = newReleases, + moreLink = "https://example.com/new-releases", + ), + MetadataBrowseSection( + title = "Top Artists", + description = "Trending artists in this simulation", + items = topArtists, + moreLink = "https://example.com/top-artists", + ), + MetadataBrowseSection( + title = "Featured Playlists", + description = "Curated playlists from linked track data", + items = featuredPlaylists, + moreLink = "https://example.com/featured-playlists", + ), + ) + } + + fun getBrowseSublist(sectionId: String): List { + return when (sectionId) { + SECTION_NEW_RELEASES -> albums.values + .sortedByDescending { it.releaseDate } + .map { MetadataBrowseItem.Album(it.toBasicModel()) } + + SECTION_TOP_ARTISTS -> artists.values + .sortedByDescending { it.followersCount } + .map { MetadataBrowseItem.Artist(it.toBasicModel()) } + + SECTION_FEATURED_PLAYLISTS -> playlists.values + .map { MetadataBrowseItem.Playlist(it.toModel()) } + + else -> tracks.values + .sortedByDescending { it.popularity } + .map { MetadataBrowseItem.Track(it.toModel()) } + } + } + + fun search(query: String): List { + val q = query.trim().lowercase() + val trackHit = tracks.values.firstOrNull { it.title.lowercase().contains(q) } + val artistHit = artists.values.firstOrNull { it.name.lowercase().contains(q) } + val albumHit = albums.values.firstOrNull { it.title.lowercase().contains(q) } + val playlistHit = playlists.values.firstOrNull { it.title.lowercase().contains(q) } + val userHit = users.values.firstOrNull { + it.username.lowercase().contains(q) || it.displayName.lowercase().contains(q) + } + + return listOfNotNull( + trackHit?.let { MetadataSearchResult.Track(it.toModel()) }, + artistHit?.let { MetadataSearchResult.Artist(it.toBasicModel()) }, + albumHit?.let { MetadataSearchResult.Album(it.toBasicModel()) }, + playlistHit?.let { MetadataSearchResult.Playlist(it.toModel()) }, + userHit?.let { MetadataSearchResult.User(it.toModel()) }, + ) + } + + fun searchTracks(query: String): List { + val q = query.trim().lowercase() + return tracks.values + .filter { it.title.lowercase().contains(q) } + .map { MetadataSearchResult.Track(it.toModel()) } + } + + fun searchArtists(query: String): List { + val q = query.trim().lowercase() + return artists.values + .filter { it.name.lowercase().contains(q) } + .map { MetadataSearchResult.Artist(it.toBasicModel()) } + } + + fun searchAlbums(query: String): List { + val q = query.trim().lowercase() + return albums.values + .filter { it.title.lowercase().contains(q) } + .map { MetadataSearchResult.Album(it.toBasicModel()) } + } + + fun searchPlaylists(query: String): List { + val q = query.trim().lowercase() + return playlists.values + .filter { it.title.lowercase().contains(q) } + .map { MetadataSearchResult.Playlist(it.toModel()) } + } + + fun searchUsers(query: String): List { + val q = query.trim().lowercase() + return users.values + .filter { + it.username.lowercase().contains(q) || it.displayName.lowercase().contains(q) + } + .map { MetadataSearchResult.User(it.toModel()) } + } + + fun savedTracks(): List { + return savedTrackIds + .mapNotNull { trackId -> tracks[trackId]?.toModel() } + } + + fun savedAlbums(): List { + return savedAlbumIds + .mapNotNull { albumId -> albums[albumId]?.toDetailedModel() } + } + + fun savedArtists(): List { + return savedArtistIds + .mapNotNull { artistId -> artists[artistId]?.toDetailedModel() } + } + + fun savedPlaylists(): List { + return savedPlaylistIds + .mapNotNull { playlistId -> playlists[playlistId]?.toModel() } + } + + fun isSavedTracks(ids: List): List = ids.map { it in savedTrackIds } + fun isSavedAlbums(ids: List): List = ids.map { it in savedAlbumIds } + fun isSavedArtists(ids: List): List = ids.map { it in savedArtistIds } + fun isSavedPlaylists(ids: List): List = ids.map { it in savedPlaylistIds } + + fun saveTracks(ids: List) { + ids.filterTo(savedTrackIds) { it in tracks } + } + + fun removeSavedTracks(ids: List) { + savedTrackIds.removeAll(ids.toSet()) + } + + fun saveAlbums(ids: List) { + ids.filterTo(savedAlbumIds) { it in albums } + } + + fun removeSavedAlbums(ids: List) { + savedAlbumIds.removeAll(ids.toSet()) + } + + fun saveArtists(ids: List) { + ids.filterTo(savedArtistIds) { it in artists } + } + + fun removeSavedArtists(ids: List) { + savedArtistIds.removeAll(ids.toSet()) + } + + fun savePlaylists(ids: List) { + ids.filterTo(savedPlaylistIds) { it in playlists } + } + + fun removeSavedPlaylists(ids: List) { + savedPlaylistIds.removeAll(ids.toSet()) + } + + fun createPlaylist( + name: String, + description: String?, + isPublic: Boolean, + isCollaborating: Boolean, + imageBase64: String, + trackIds: List, + ): MetadataPlaylist { + val id = "playlist-${playlistCounter++}" + val resolvedTracks = trackIds.filter { it in tracks } + playlists[id] = PlaylistEntity( + id = id, + title = name, + description = description, + ownerId = CURRENT_USER_ID, + thumbnail = imageBase64.takeIf { it.isNotBlank() } + ?: "https://picsum.photos/seed/$id/300/300", + isPublic = isPublic, + isCollaborating = isCollaborating, + trackIds = resolvedTracks.toMutableList(), + ) + return playlists.getValue(id).toModel() + } + + fun updatePlaylist( + id: String, + name: String?, + description: String?, + isPublic: Boolean?, + isCollaborating: Boolean?, + imageBase64: String?, + trackIds: List?, + ): MetadataPlaylist { + val playlist = playlists[id] ?: error("Playlist not found: $id") + if (name != null) playlist.title = name + if (description != null) playlist.description = description + if (isPublic != null) playlist.isPublic = isPublic + if (isCollaborating != null) playlist.isCollaborating = isCollaborating + if (imageBase64 != null) playlist.thumbnail = imageBase64 + if (trackIds != null) { + playlist.trackIds.clear() + playlist.trackIds.addAll(trackIds.filter { it in tracks }) + } + return playlist.toModel() + } + + fun deletePlaylist(id: String) { + playlists.remove(id) + savedPlaylistIds.remove(id) + } + + fun recommendationsBasedOnTracks(seedTrackIds: List, limit: Int): List { + if (tracks.isEmpty()) return emptyList() + val seedTracks = seedTrackIds.mapNotNull { tracks[it] } + val seedArtistIds = seedTracks.flatMap { it.artistIds }.toSet() + val seedAlbumIds = seedTracks.map { it.albumId }.toSet() + + val ranked = tracks.values + .asSequence() + .filter { it.id !in seedTrackIds.toSet() } + .sortedWith( + compareByDescending { track -> + track.artistIds.count { it in seedArtistIds } * 10 + + (if (track.albumId in seedAlbumIds) 3 else 0) + + track.popularity + } + ) + .take(limit) + .map { it.toModel() } + .toList() + + return if (ranked.isEmpty()) { + tracks.values.take(limit).map { it.toModel() } + } else ranked + } + + fun paginate( + items: List, + pagination: PaginationStrategy? = PaginationStrategy.Offset(0, 20) + ): PaginationResult { + if (pagination is PaginationStrategy.Offset) { + val safeOffset = pagination.offset.coerceAtLeast(0) + val safePageSize = pagination.limit.coerceAtLeast(1) + val paged = items.drop(safeOffset).take(safePageSize) + val nextOffset = + if (safeOffset + paged.size < items.size) safeOffset + paged.size else null + return PaginationResult( + items = paged, + totalCount = items.size, + nextPagination = PaginationStrategy.Offset(nextOffset ?: 0, safePageSize) + ) + } + return PaginationResult( + items = items, + totalCount = items.size, + nextPagination = null + ) + } + + private fun seed() { + users["user-1"] = UserEntity("user-1", "demo_user", "Demo User") + users["user-2"] = UserEntity("user-2", "mixmaster", "Mix Master") + + repeat(6) { index -> + val id = "artist-${index + 1}" + artists[id] = ArtistEntity( + id = id, + name = "Sim Artist ${index + 1}", + genres = listOf("Pop", "Electronic", "Rock").shuffled().take(2), + biography = "Simulated biography for ${index + 1}.", + followersCount = 50_000 * (index + 1), + ) + } + + var trackCounter = 1 + var albumCounter = 1 + artists.values.forEachIndexed { idx, artist -> + repeat(2) { releaseIndex -> + val albumId = "album-$albumCounter" + val albumTrackIds = mutableListOf() + val album = AlbumEntity( + id = albumId, + title = "${artist.name} Album ${releaseIndex + 1}", + description = "Simulated album ${releaseIndex + 1} by ${artist.name}", + albumType = if (releaseIndex == 0) MetadataAlbumType.Album else MetadataAlbumType.Single, + artistIds = listOf(artist.id), + releaseDate = "202${idx % 4 + 1}-0${releaseIndex + 1}-15", + genres = artist.genres, + trackIds = albumTrackIds, + ) + albums[albumId] = album + + repeat(5) { trackIndex -> + val trackId = "track-$trackCounter" + tracks[trackId] = TrackEntity( + id = trackId, + title = "${artist.name} Track ${trackIndex + 1}", + durationMs = 180_000L + (trackIndex * 12_000L), + trackNumber = trackIndex + 1, + discNumber = 1, + artistIds = listOf(artist.id), + albumId = albumId, + explicit = trackIndex % 4 == 0, + popularity = (95 - trackCounter).coerceAtLeast(35), + isrcCode = "USSIM${trackCounter.toString().padStart(6, '0')}", + ) + albumTrackIds += trackId + trackCounter++ + } + albumCounter++ + } + } + + playlists["playlist-1"] = PlaylistEntity( + id = "playlist-1", + title = "Morning Flow", + description = "A smooth mix to start the day.", + ownerId = "user-2", + thumbnail = "https://picsum.photos/seed/playlist-1/300/300", + isPublic = true, + isCollaborating = false, + trackIds = tracks.keys.take(10).toMutableList(), + ) + + playlists["playlist-2"] = PlaylistEntity( + id = "playlist-2", + title = "Workout Pulse", + description = "Higher energy tracks for workouts.", + ownerId = "user-1", + thumbnail = "https://picsum.photos/seed/playlist-2/300/300", + isPublic = true, + isCollaborating = true, + trackIds = tracks.keys.drop(8).take(12).toMutableList(), + ) + + savedTrackIds += tracks.keys.take(5) + savedAlbumIds += albums.keys.take(2) + savedArtistIds += artists.keys.take(2) + savedPlaylistIds += playlists.keys.take(1) + } + + private fun ArtistEntity.toBasicModel(): MetadataArtist.Basic { + return MetadataArtist.Basic( + id = id, + name = name, + thumbnails = listOf(Thumbnail("https://picsum.photos/seed/$id/200/200", 200, 200)), + externalUri = "https://example.com/artist/$id", + ) + } + + private fun ArtistEntity.toDetailedModel(): MetadataArtist.Detailed { + return MetadataArtist.Detailed( + id = id, + name = name, + thumbnails = listOf(Thumbnail("https://picsum.photos/seed/$id/200/200", 200, 200)), + externalUri = "https://example.com/artist/$id", + genres = genres, + biography = biography, + followersCount = followersCount, + ) + } + + private fun AlbumEntity.toBasicModel(): MetadataAlbum.Basic { + return MetadataAlbum.Basic( + id = id, + title = title, + description = description, + thumbnails = listOf(Thumbnail("https://picsum.photos/seed/$id/300/300", 300, 300)), + albumType = albumType, + artists = artistIds.mapNotNull { artists[it]?.toBasicModel() }, + externalUri = "https://example.com/album/$id", + ) + } + + private fun AlbumEntity.toDetailedModel(): MetadataAlbum.Detailed { + return MetadataAlbum.Detailed( + id = id, + title = title, + description = description, + thumbnails = listOf(Thumbnail("https://picsum.photos/seed/$id/300/300", 300, 300)), + albumType = albumType, + artists = artistIds.mapNotNull { artists[it]?.toBasicModel() }, + externalUri = "https://example.com/album/$id", + releaseDate = releaseDate, + genres = genres, + trackCount = trackIds.size, + ) + } + + private fun TrackEntity.toModel(): MetadataTrack { + return MetadataTrack( + id = id, + title = title, + durationMs = durationMs, + trackNumber = trackNumber, + discNumber = discNumber, + artists = artistIds.mapNotNull { artists[it]?.toBasicModel() }, + album = albums.getValue(albumId).toDetailedModel(), + explicit = explicit, + popularity = popularity, + isrcCode = isrcCode, + externalUri = "https://example.com/track/$id", + thumbnails = null + ) + } + + private fun UserEntity.toModel(): MetadataUser { + return MetadataUser( + id = id, + username = username, + displayName = displayName, + thumbnails = listOf(Thumbnail("https://picsum.photos/seed/$id/100/100", 100, 100)), + externalUri = "https://example.com/user/$id", + ) + } + + private fun PlaylistEntity.toModel(): MetadataPlaylist { + return MetadataPlaylist( + id = id, + title = title, + description = description, + thumbnails = listOf(Thumbnail(thumbnail, 300, 300)), + trackCount = trackIds.count { it in tracks }, + externalUri = "https://example.com/playlist/$id", + owner = users[ownerId]?.toModel(), + ) + } +} + diff --git a/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt new file mode 100644 index 00000000..7a6c9f25 --- /dev/null +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.js_plugin_example.plugin_apis.metadata + +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack + +class RealMetadataAlbumAPI : MetadataAlbumAPI { + + override suspend fun getAlbum(id: String): MetadataAlbum.Detailed { + return FakeMetadataStore.getAlbum(id) + } + + override suspend fun getTrackAlbum(track: MetadataTrack): MetadataAlbum.Detailed { + TODO("Not yet implemented") + } + + override suspend fun getAlbumTracks( + id: String, + pagination: PaginationStrategy? + ): PaginationResult { + return FakeMetadataStore.paginate(FakeMetadataStore.getAlbumTracks(id), pagination) + } + + override suspend fun savedAlbums( + pagination: PaginationStrategy? + ): PaginationResult { + return FakeMetadataStore.paginate(FakeMetadataStore.savedAlbums(), pagination) + } + + override suspend fun isSavedAlbums(ids: List): List { + return FakeMetadataStore.isSavedAlbums(ids) + } + + override suspend fun saveAlbums(ids: List) { + FakeMetadataStore.saveAlbums(ids) + } + + override suspend fun removeSavedAlbums(ids: List) { + FakeMetadataStore.removeSavedAlbums(ids) + } +} diff --git a/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt new file mode 100644 index 00000000..9e40e0e7 --- /dev/null +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.js_plugin_example.plugin_apis.metadata + +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack + +class RealMetadataArtistAPI : MetadataArtistAPI { + + override suspend fun getArtist(id: String): MetadataArtist.Detailed { + return FakeMetadataStore.getArtist(id) + } + + override suspend fun artistOverview(id: String): MetadataArtistOverview { + TODO("Not yet implemented") + } + + override suspend fun getArtistTop10Tracks(id: String): List { + return FakeMetadataStore.getArtistTopTracks(id) + } + + override suspend fun relatedArtists( + id: String, + pagination: PaginationStrategy? + ): PaginationResult { + TODO("Not yet implemented") + } + + override suspend fun featuredPlaylists( + id: String, + pagination: PaginationStrategy? + ): PaginationResult { + TODO("Not yet implemented") + } + + override suspend fun getArtistAlbums( + id: String, + pagination: PaginationStrategy? + ): PaginationResult { + return FakeMetadataStore.paginate(FakeMetadataStore.getArtistAlbums(id), pagination) + } + + override suspend fun savedArtists( + pagination: PaginationStrategy? + ): PaginationResult { + return FakeMetadataStore.paginate(FakeMetadataStore.savedArtists(), pagination) + } + + override suspend fun isSavedArtists(ids: List): List { + return FakeMetadataStore.isSavedArtists(ids) + } + + override suspend fun saveArtists(ids: List) { + FakeMetadataStore.saveArtists(ids) + } + + override suspend fun removeSavedArtists(ids: List) { + FakeMetadataStore.removeSavedArtists(ids) + } +} diff --git a/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt new file mode 100644 index 00000000..b2463688 --- /dev/null +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.js_plugin_example.plugin_apis.metadata + +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult + +class RealMetadataBrowseAPI : MetadataBrowseAPI { + + override suspend fun featured(): List { + return FakeMetadataStore.getFeaturedItems() + } + + override suspend fun genres(): List { + return listOf( + MetadataBrowseGenre(id = "1", name = "Pop"), + MetadataBrowseGenre(id = "2", name = "Rock"), + MetadataBrowseGenre(id = "3", name = "Hip-Hop"), + MetadataBrowseGenre(id = "4", name = "Jazz"), + MetadataBrowseGenre(id = "5", name = "Classical") + ) + } + + override suspend fun list(genreId: String, pagination: PaginationStrategy?): PaginationResult { + return FakeMetadataStore.paginate(FakeMetadataStore.getBrowseSections(), pagination) + } + + override suspend fun sublist( + genreId: String, + sectionId: String, + pagination: PaginationStrategy? + ): PaginationResult { + return FakeMetadataStore.paginate(FakeMetadataStore.getBrowseSublist(sectionId), pagination) + } +} diff --git a/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt new file mode 100644 index 00000000..c45b307f --- /dev/null +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.js_plugin_example.plugin_apis.metadata + +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack + +class RealMetadataPlaylistAPI : MetadataPlaylistAPI { + + override suspend fun getPlaylist(id: String): MetadataPlaylist { + return FakeMetadataStore.getPlaylist(id) + } + + override suspend fun getPlaylistTracks( + id: String, + pagination: PaginationStrategy? + ): PaginationResult { + return FakeMetadataStore.paginate(FakeMetadataStore.getPlaylistTracks(id), pagination) + } + + override suspend fun savedPlaylists( + pagination: PaginationStrategy? + ): PaginationResult { + return FakeMetadataStore.paginate(FakeMetadataStore.savedPlaylists(), pagination) + } + + override suspend fun isSavedPlaylists(ids: List): List { + return FakeMetadataStore.isSavedPlaylists(ids) + } + + override suspend fun savePlaylists(ids: List) { + FakeMetadataStore.savePlaylists(ids) + } + + override suspend fun removeSavedPlaylists(ids: List) { + FakeMetadataStore.removeSavedPlaylists(ids) + } + + override suspend fun createPlaylist( + name: String, + description: String?, + isPublic: Boolean, + isCollaborating: Boolean, + imageBase64: String, + trackIds: List + ): MetadataPlaylist { + return FakeMetadataStore.createPlaylist( + name = name, + description = description, + isPublic = isPublic, + isCollaborating = isCollaborating, + imageBase64 = imageBase64, + trackIds = trackIds, + ) + } + + override suspend fun updatePlaylist( + id: String, + name: String?, + description: String?, + isPublic: Boolean?, + isCollaborating: Boolean?, + imageBase64: String?, + trackIds: List? + ): MetadataPlaylist { + return FakeMetadataStore.updatePlaylist( + id = id, + name = name, + description = description, + isPublic = isPublic, + isCollaborating = isCollaborating, + imageBase64 = imageBase64, + trackIds = trackIds, + ) + } + + override suspend fun deletePlaylist(id: String) { + FakeMetadataStore.deletePlaylist(id) + } + + override suspend fun addTracksToPlaylist( + playlistId: String, + trackIds: List + ) { + TODO("Not yet implemented") + } + + override suspend fun removeTracksFromPlaylist( + playlistId: String, + trackIds: List + ) { + TODO("Not yet implemented") + } +} diff --git a/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt new file mode 100644 index 00000000..ca8985ee --- /dev/null +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.js_plugin_example.plugin_apis.metadata + +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType + +class RealMetadataSearchAPI : MetadataSearchAPI { + + override val supportedSearchTypes: List = listOf( + MetadataSupportedSearchType.ALL, + MetadataSupportedSearchType.TRACK, + MetadataSupportedSearchType.ARTIST, + MetadataSupportedSearchType.ALBUM, + MetadataSupportedSearchType.PLAYLIST, + MetadataSupportedSearchType.USER, + ) + + override suspend fun search(query: String): List { + return FakeMetadataStore.search(query) + } + + override suspend fun searchTracks( + query: String, + pagination: PaginationStrategy? + ): PaginationResult { + return FakeMetadataStore.paginate(FakeMetadataStore.searchTracks(query), pagination) + } + + override suspend fun searchArtists( + query: String, + pagination: PaginationStrategy? + ): PaginationResult { + return FakeMetadataStore.paginate(FakeMetadataStore.searchArtists(query), pagination) + } + + override suspend fun searchAlbums( + query: String, + pagination: PaginationStrategy? + ): PaginationResult { + return FakeMetadataStore.paginate(FakeMetadataStore.searchAlbums(query), pagination) + } + + override suspend fun searchPlaylists( + query: String, + pagination: PaginationStrategy? + ): PaginationResult { + return FakeMetadataStore.paginate(FakeMetadataStore.searchPlaylists(query), pagination) + } + + override suspend fun searchUsers( + query: String, + pagination: PaginationStrategy? + ): PaginationResult { + return FakeMetadataStore.paginate(FakeMetadataStore.searchUsers(query), pagination) + } +} diff --git a/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt new file mode 100644 index 00000000..3237601c --- /dev/null +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.js_plugin_example.plugin_apis.metadata + +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI + +class RealMetadataTrackAPI : MetadataTrackAPI { + + override suspend fun getTrack(id: String): MetadataTrack { + return FakeMetadataStore.getTrack(id) + } + + override suspend fun savedTracks( + pagination: PaginationStrategy? + ): PaginationResult { + return FakeMetadataStore.paginate(FakeMetadataStore.savedTracks(), pagination) + } + + override suspend fun isSavedTracks(ids: List): List { + return FakeMetadataStore.isSavedTracks(ids) + } + + override suspend fun saveTracks(ids: List) { + FakeMetadataStore.saveTracks(ids) + } + + override suspend fun removeSavedTracks(ids: List) { + FakeMetadataStore.removeSavedTracks(ids) + } + + override suspend fun recommendationsBasedOnTracks( + seedTrackIds: List, + limit: Int + ): List { + return FakeMetadataStore.recommendationsBasedOnTracks(seedTrackIds, limit) + } +} diff --git a/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt new file mode 100644 index 00000000..dda9a334 --- /dev/null +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.js_plugin_example.plugin_apis.metadata + +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI + +class RealMetadataUserAPI : MetadataUserAPI { + override suspend fun getUser(id: String): MetadataUser? { + return FakeMetadataStore.getUser(id) + } +} \ No newline at end of file diff --git a/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt new file mode 100644 index 00000000..85b8d901 --- /dev/null +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.js_plugin_example.plugin_apis.scrobble + +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack + +class RealScrobbleAPI: ScrobbleAPI { + override suspend fun scrobble(track: ScrobbleTrack) { + // No Op + } +} \ No newline at end of file diff --git a/l10n.yaml b/l10n.yaml deleted file mode 100644 index d5911fe1..00000000 --- a/l10n.yaml +++ /dev/null @@ -1,4 +0,0 @@ -arb-dir: lib/l10n -template-arb-file: app_en.arb -output-dir: lib/l10n/generated -untranslated-messages-file: untranslated_messages.json diff --git a/lefthook.yaml b/lefthook.yaml new file mode 100644 index 00000000..f053b35d --- /dev/null +++ b/lefthook.yaml @@ -0,0 +1,23 @@ +# Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +pre-commit: + parallel: true + commands: + # 1. Protect the AGPL Core (Exclude the Apache libraries) + agpl-headers: + glob: "*.{kt,kts,xml,yaml,yml}" + run: addlicense -f .github/agpl_header.txt {staged_files} + stage_fixed: true \ No newline at end of file diff --git a/lib/collections/assets.gen.dart b/lib/collections/assets.gen.dart deleted file mode 100644 index 7ab0ad03..00000000 --- a/lib/collections/assets.gen.dart +++ /dev/null @@ -1,228 +0,0 @@ -// dart format width=80 - -/// GENERATED CODE - DO NOT MODIFY BY HAND -/// ***************************************************** -/// FlutterGen -/// ***************************************************** - -// coverage:ignore-file -// ignore_for_file: type=lint -// ignore_for_file: deprecated_member_use,directives_ordering,implicit_dynamic_list_literal,unnecessary_import - -import 'package:flutter/widgets.dart'; - -class $AssetsBrandingGen { - const $AssetsBrandingGen(); - - /// File path: assets/branding/spotube-logo-light.png - AssetGenImage get spotubeLogoLight => - const AssetGenImage('assets/branding/spotube-logo-light.png'); - - /// File path: assets/branding/spotube-logo.ico - String get spotubeLogoIco => 'assets/branding/spotube-logo.ico'; - - /// File path: assets/branding/spotube-logo.png - AssetGenImage get spotubeLogoPng => - const AssetGenImage('assets/branding/spotube-logo.png'); - - /// List of all assets - List get values => - [spotubeLogoLight, spotubeLogoIco, spotubeLogoPng]; -} - -class $AssetsImagesGen { - const $AssetsImagesGen(); - - /// File path: assets/images/album-placeholder.png - AssetGenImage get albumPlaceholder => - const AssetGenImage('assets/images/album-placeholder.png'); - - /// File path: assets/images/bengali-patterns-bg.jpg - AssetGenImage get bengaliPatternsBg => - const AssetGenImage('assets/images/bengali-patterns-bg.jpg'); - - /// File path: assets/images/liked-tracks.jpg - AssetGenImage get likedTracks => - const AssetGenImage('assets/images/liked-tracks.jpg'); - - /// Directory path: assets/images/logos - $AssetsImagesLogosGen get logos => const $AssetsImagesLogosGen(); - - /// File path: assets/images/placeholder.png - AssetGenImage get placeholder => - const AssetGenImage('assets/images/placeholder.png'); - - /// File path: assets/images/user-placeholder.png - AssetGenImage get userPlaceholder => - const AssetGenImage('assets/images/user-placeholder.png'); - - /// List of all assets - List get values => [ - albumPlaceholder, - bengaliPatternsBg, - likedTracks, - placeholder, - userPlaceholder - ]; -} - -class $AssetsPluginsGen { - const $AssetsPluginsGen(); - - /// Directory path: assets/plugins/spotube-plugin-musicbrainz-listenbrainz - $AssetsPluginsSpotubePluginMusicbrainzListenbrainzGen - get spotubePluginMusicbrainzListenbrainz => - const $AssetsPluginsSpotubePluginMusicbrainzListenbrainzGen(); - - /// Directory path: assets/plugins/spotube-plugin-youtube-audio - $AssetsPluginsSpotubePluginYoutubeAudioGen get spotubePluginYoutubeAudio => - const $AssetsPluginsSpotubePluginYoutubeAudioGen(); -} - -class $AssetsImagesLogosGen { - const $AssetsImagesLogosGen(); - - /// File path: assets/images/logos/dab-music.png - AssetGenImage get dabMusic => - const AssetGenImage('assets/images/logos/dab-music.png'); - - /// File path: assets/images/logos/invidious.jpg - AssetGenImage get invidious => - const AssetGenImage('assets/images/logos/invidious.jpg'); - - /// File path: assets/images/logos/jiosaavn.png - AssetGenImage get jiosaavn => - const AssetGenImage('assets/images/logos/jiosaavn.png'); - - /// List of all assets - List get values => [dabMusic, invidious, jiosaavn]; -} - -class $AssetsPluginsSpotubePluginMusicbrainzListenbrainzGen { - const $AssetsPluginsSpotubePluginMusicbrainzListenbrainzGen(); - - /// File path: assets/plugins/spotube-plugin-musicbrainz-listenbrainz/plugin.smplug - String get plugin => - 'assets/plugins/spotube-plugin-musicbrainz-listenbrainz/plugin.smplug'; - - /// List of all assets - List get values => [plugin]; -} - -class $AssetsPluginsSpotubePluginYoutubeAudioGen { - const $AssetsPluginsSpotubePluginYoutubeAudioGen(); - - /// File path: assets/plugins/spotube-plugin-youtube-audio/plugin.smplug - String get plugin => - 'assets/plugins/spotube-plugin-youtube-audio/plugin.smplug'; - - /// List of all assets - List get values => [plugin]; -} - -class Assets { - const Assets._(); - - static const String license = 'LICENSE'; - static const $AssetsBrandingGen branding = $AssetsBrandingGen(); - static const $AssetsImagesGen images = $AssetsImagesGen(); - static const $AssetsPluginsGen plugins = $AssetsPluginsGen(); - - /// List of all assets - static List get values => [license]; -} - -class AssetGenImage { - const AssetGenImage( - this._assetName, { - this.size, - this.flavors = const {}, - this.animation, - }); - - final String _assetName; - - final Size? size; - final Set flavors; - final AssetGenImageAnimation? animation; - - Image image({ - Key? key, - AssetBundle? bundle, - ImageFrameBuilder? frameBuilder, - ImageErrorWidgetBuilder? errorBuilder, - String? semanticLabel, - bool excludeFromSemantics = false, - double? scale, - double? width, - double? height, - Color? color, - Animation? opacity, - BlendMode? colorBlendMode, - BoxFit? fit, - AlignmentGeometry alignment = Alignment.center, - ImageRepeat repeat = ImageRepeat.noRepeat, - Rect? centerSlice, - bool matchTextDirection = false, - bool gaplessPlayback = true, - bool isAntiAlias = false, - String? package, - FilterQuality filterQuality = FilterQuality.medium, - int? cacheWidth, - int? cacheHeight, - }) { - return Image.asset( - _assetName, - key: key, - bundle: bundle, - frameBuilder: frameBuilder, - errorBuilder: errorBuilder, - semanticLabel: semanticLabel, - excludeFromSemantics: excludeFromSemantics, - scale: scale, - width: width, - height: height, - color: color, - opacity: opacity, - colorBlendMode: colorBlendMode, - fit: fit, - alignment: alignment, - repeat: repeat, - centerSlice: centerSlice, - matchTextDirection: matchTextDirection, - gaplessPlayback: gaplessPlayback, - isAntiAlias: isAntiAlias, - package: package, - filterQuality: filterQuality, - cacheWidth: cacheWidth, - cacheHeight: cacheHeight, - ); - } - - ImageProvider provider({ - AssetBundle? bundle, - String? package, - }) { - return AssetImage( - _assetName, - bundle: bundle, - package: package, - ); - } - - String get path => _assetName; - - String get keyName => _assetName; -} - -class AssetGenImageAnimation { - const AssetGenImageAnimation({ - required this.isAnimation, - required this.duration, - required this.frames, - }); - - final bool isAnimation; - final Duration duration; - final int frames; -} diff --git a/lib/collections/env.dart b/lib/collections/env.dart deleted file mode 100644 index 52ef2bbf..00000000 --- a/lib/collections/env.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:envied/envied.dart'; -import 'package:spotube/utils/platform.dart'; - -part 'env.g.dart'; - -enum ReleaseChannel { - nightly, - stable, -} - -@Envied(obfuscate: true, requireEnvFile: true, path: ".env") -abstract class Env { - @EnviedField(varName: 'LASTFM_API_KEY') - static final String lastFmApiKey = _Env.lastFmApiKey; - - @EnviedField(varName: 'LASTFM_API_SECRET') - static final String lastFmApiSecret = _Env.lastFmApiSecret; - - @EnviedField(varName: 'HIDE_DONATIONS', defaultValue: "0") - static final int _hideDonations = _Env._hideDonations; - - static bool get hideDonations => _hideDonations == 1; - - @EnviedField(varName: 'ENABLE_UPDATE_CHECK', defaultValue: "1") - static final String _enableUpdateChecker = _Env._enableUpdateChecker; - - @EnviedField(varName: "RELEASE_CHANNEL", defaultValue: "nightly") - static final String _releaseChannel = _Env._releaseChannel; - - static ReleaseChannel get releaseChannel => _releaseChannel == "stable" - ? ReleaseChannel.stable - : ReleaseChannel.nightly; - - static bool get enableUpdateChecker => - kIsFlatpak || _enableUpdateChecker == "1"; - - static String discordAppId = "1176718791388975124"; -} diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart deleted file mode 100644 index 7d201ae2..00000000 --- a/lib/collections/fake.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/history/summary.dart'; - -abstract class FakeData { - static final SpotubeImageObject image = SpotubeImageObject( - height: 100, - width: 100, - url: "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg", - ); - - static final SpotubeFullArtistObject artist = SpotubeFullArtistObject( - id: "1", - name: "What an artist", - externalUri: "https://example.com", - followers: 10000, - genres: ["genre"], - images: [ - SpotubeImageObject( - height: 100, - width: 100, - url: "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg", - ), - ], - ); - - static final SpotubeFullAlbumObject album = SpotubeFullAlbumObject( - id: "1", - name: "A good album", - externalUri: "https://example.com", - artists: [artistSimple], - releaseDate: "2021-01-01", - albumType: SpotubeAlbumType.album, - images: [image], - totalTracks: 10, - genres: ["genre"], - recordLabel: "Record Label", - ); - - static final SpotubeSimpleArtistObject artistSimple = - SpotubeSimpleArtistObject( - id: "1", - name: "What an artist", - externalUri: "https://example.com", - images: null, - ); - - static final SpotubeSimpleAlbumObject albumSimple = SpotubeSimpleAlbumObject( - albumType: SpotubeAlbumType.album, - artists: [], - externalUri: "https://example.com", - id: "1", - name: "A good album", - releaseDate: "2021-01-01", - images: [ - SpotubeImageObject( - height: 1, - width: 1, - url: "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg", - ) - ], - ); - - static final SpotubeFullTrackObject track = SpotubeTrackObject.full( - id: "1", - name: "A good track", - externalUri: "https://example.com", - album: albumSimple, - durationMs: 3 * 60 * 1000, // 3 minutes - isrc: "USUM72112345", - explicit: false, - ) as SpotubeFullTrackObject; - - static final SpotubeUserObject user = SpotubeUserObject( - id: "1", - name: "User Name", - externalUri: "https://example.com", - images: [image], - ); - - static final SpotubeFullPlaylistObject playlist = SpotubeFullPlaylistObject( - id: "1", - name: "A good playlist", - description: "A very good playlist description", - externalUri: "https://example.com", - collaborative: false, - public: true, - owner: user, - images: [image], - collaborators: [user]); - - static final SpotubeSimplePlaylistObject playlistSimple = - SpotubeSimplePlaylistObject( - id: "1", - name: "A good playlist", - description: "A very good playlist description", - externalUri: "https://example.com", - owner: user, - images: [image], - ); - - static final SpotubeBrowseSectionObject browseSection = - SpotubeBrowseSectionObject( - id: "section-id", - title: "Browse Section", - browseMore: true, - externalUri: "https://example.com/browse/section", - items: [playlistSimple, playlistSimple, playlistSimple]); - - static const historySummary = PlaybackHistorySummary( - albums: 1, - artists: 1, - duration: Duration(seconds: 1), - playlists: 1, - tracks: 1, - fees: 1, - ); - - static final historyRecentlyPlayedPlaylist = HistoryTableData( - id: 0, - type: HistoryEntryType.track, - createdAt: DateTime.now(), - itemId: "1", - data: playlist.toJson(), - ); - - static final historyRecentlyPlayedAlbum = HistoryTableData( - id: 0, - type: HistoryEntryType.track, - createdAt: DateTime.now(), - itemId: "1", - data: album.toJson(), - ); - - static final historyRecentlyPlayedItems = List.generate( - 10, - (index) => index % 2 == 0 - ? historyRecentlyPlayedPlaylist - : historyRecentlyPlayedAlbum, - ); -} diff --git a/lib/collections/fonts.gen.dart b/lib/collections/fonts.gen.dart deleted file mode 100644 index d2c68231..00000000 --- a/lib/collections/fonts.gen.dart +++ /dev/null @@ -1,25 +0,0 @@ -// dart format width=80 -/// GENERATED CODE - DO NOT MODIFY BY HAND -/// ***************************************************** -/// FlutterGen -/// ***************************************************** - -// coverage:ignore-file -// ignore_for_file: type=lint -// ignore_for_file: deprecated_member_use,directives_ordering,implicit_dynamic_list_literal,unnecessary_import - -class FontFamily { - FontFamily._(); - - /// Font family: BootstrapIcons - static const String bootstrapIcons = 'BootstrapIcons'; - - /// Font family: Cookie - static const String cookie = 'Cookie'; - - /// Font family: RadixIcons - static const String radixIcons = 'RadixIcons'; - - /// Font family: Ubuntu Mono - static const String ubuntuMono = 'Ubuntu Mono'; -} diff --git a/lib/collections/formatters.dart b/lib/collections/formatters.dart deleted file mode 100644 index 0aed9e9f..00000000 --- a/lib/collections/formatters.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:intl/intl.dart'; - -final compactNumberFormatter = NumberFormat.compact(); -final usdFormatter = NumberFormat.compactCurrency( - locale: 'en-US', - symbol: r"$", - decimalDigits: 2, -); diff --git a/lib/collections/gradients.dart b/lib/collections/gradients.dart deleted file mode 100644 index a7936ee2..00000000 --- a/lib/collections/gradients.dart +++ /dev/null @@ -1,232 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -const gradients = [ - LinearGradient(colors: [ - Color.fromRGBO(123, 102, 255, 1), - Color.fromRGBO(95, 189, 255, 1), - Color.fromRGBO(150, 239, 255, 1), - Color.fromRGBO(197, 255, 248, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(245, 204, 160, 1), - Color.fromRGBO(228, 143, 69, 1), - Color.fromRGBO(153, 77, 28, 1), - Color.fromRGBO(107, 36, 12, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(243, 243, 243, 1), - Color.fromRGBO(197, 232, 152, 1), - Color.fromRGBO(41, 173, 178, 1), - Color.fromRGBO(7, 102, 173, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(240, 89, 65, 1), - Color.fromRGBO(190, 49, 68, 1), - Color.fromRGBO(135, 35, 65, 1), - Color.fromRGBO(34, 9, 44, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(119, 107, 93, 1), - Color.fromRGBO(176, 166, 149, 1), - Color.fromRGBO(235, 227, 213, 1), - Color.fromRGBO(243, 238, 234, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(208, 162, 247, 1), - Color.fromRGBO(220, 191, 255, 1), - Color.fromRGBO(229, 212, 255, 1), - Color.fromRGBO(241, 234, 255, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(221, 242, 253, 1), - Color.fromRGBO(155, 190, 200, 1), - Color.fromRGBO(66, 125, 157, 1), - Color.fromRGBO(22, 72, 99, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(119, 67, 219, 1), - Color.fromRGBO(195, 172, 208, 1), - Color.fromRGBO(247, 239, 229, 1), - Color.fromRGBO(255, 251, 245, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(194, 217, 255, 1), - Color.fromRGBO(142, 143, 250, 1), - Color.fromRGBO(119, 82, 254, 1), - Color.fromRGBO(25, 4, 130, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(104, 126, 255, 1), - Color.fromRGBO(128, 179, 255, 1), - Color.fromRGBO(152, 228, 255, 1), - Color.fromRGBO(182, 255, 250, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(176, 87, 141, 1), - Color.fromRGBO(217, 136, 185, 1), - Color.fromRGBO(250, 203, 234, 1), - Color.fromRGBO(255, 228, 214, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(190, 255, 247, 1), - Color.fromRGBO(166, 246, 255, 1), - Color.fromRGBO(158, 221, 255, 1), - Color.fromRGBO(100, 153, 233, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(245, 252, 205, 1), - Color.fromRGBO(120, 214, 198, 1), - Color.fromRGBO(65, 145, 151, 1), - Color.fromRGBO(18, 72, 107, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(229, 207, 247, 1), - Color.fromRGBO(157, 118, 193, 1), - Color.fromRGBO(113, 58, 190, 1), - Color.fromRGBO(91, 8, 136, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(249, 222, 201, 1), - Color.fromRGBO(247, 140, 162, 1), - Color.fromRGBO(216, 0, 50, 1), - Color.fromRGBO(61, 12, 17, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(242, 247, 161, 1), - Color.fromRGBO(53, 162, 159, 1), - Color.fromRGBO(8, 131, 149, 1), - Color.fromRGBO(7, 25, 82, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(243, 159, 90, 1), - Color.fromRGBO(174, 68, 90, 1), - Color.fromRGBO(102, 37, 73, 1), - Color.fromRGBO(69, 25, 82, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(255, 200, 200, 1), - Color.fromRGBO(255, 155, 130, 1), - Color.fromRGBO(255, 63, 164, 1), - Color.fromRGBO(87, 55, 93, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(238, 238, 238, 1), - Color.fromRGBO(100, 204, 197, 1), - Color.fromRGBO(23, 107, 135, 1), - Color.fromRGBO(5, 59, 80, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(198, 61, 47, 1), - Color.fromRGBO(226, 94, 62, 1), - Color.fromRGBO(255, 155, 80, 1), - Color.fromRGBO(255, 187, 92, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(236, 83, 176, 1), - Color.fromRGBO(157, 68, 192, 1), - Color.fromRGBO(77, 45, 183, 1), - Color.fromRGBO(14, 33, 160, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(242, 236, 190, 1), - Color.fromRGBO(226, 199, 153, 1), - Color.fromRGBO(192, 130, 97, 1), - Color.fromRGBO(154, 59, 59, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(255, 253, 140, 1), - Color.fromRGBO(151, 255, 244, 1), - Color.fromRGBO(112, 145, 245, 1), - Color.fromRGBO(121, 63, 223, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(67, 83, 52, 1), - Color.fromRGBO(158, 179, 132, 1), - Color.fromRGBO(206, 222, 189, 1), - Color.fromRGBO(250, 241, 228, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(250, 240, 230, 1), - Color.fromRGBO(185, 180, 199, 1), - Color.fromRGBO(92, 84, 112, 1), - Color.fromRGBO(53, 47, 68, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(255, 186, 134, 1), - Color.fromRGBO(246, 99, 92, 1), - Color.fromRGBO(194, 51, 115, 1), - Color.fromRGBO(121, 21, 91, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(213, 255, 208, 1), - Color.fromRGBO(64, 248, 255, 1), - Color.fromRGBO(39, 158, 255, 1), - Color.fromRGBO(12, 53, 106, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(131, 96, 150, 1), - Color.fromRGBO(237, 123, 123, 1), - Color.fromRGBO(240, 184, 110, 1), - Color.fromRGBO(235, 231, 108, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(63, 29, 56, 1), - Color.fromRGBO(77, 60, 119, 1), - Color.fromRGBO(162, 103, 138, 1), - Color.fromRGBO(225, 152, 152, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(254, 123, 229, 1), - Color.fromRGBO(151, 78, 195, 1), - Color.fromRGBO(80, 64, 153, 1), - Color.fromRGBO(49, 56, 102, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(248, 222, 34, 1), - Color.fromRGBO(249, 76, 16, 1), - Color.fromRGBO(199, 0, 57, 1), - Color.fromRGBO(144, 12, 63, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(101, 69, 31, 1), - Color.fromRGBO(118, 88, 39, 1), - Color.fromRGBO(200, 174, 125, 1), - Color.fromRGBO(234, 198, 150, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(255, 246, 224, 1), - Color.fromRGBO(216, 217, 218, 1), - Color.fromRGBO(97, 103, 122, 1), - Color.fromRGBO(39, 40, 41, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(145, 109, 179, 1), - Color.fromRGBO(228, 133, 134, 1), - Color.fromRGBO(252, 186, 173, 1), - Color.fromRGBO(253, 229, 236, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(124, 115, 192, 1), - Color.fromRGBO(148, 173, 215, 1), - Color.fromRGBO(172, 250, 223, 1), - Color.fromRGBO(232, 255, 206, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(174, 216, 204, 1), - Color.fromRGBO(205, 102, 136, 1), - Color.fromRGBO(122, 49, 111, 1), - Color.fromRGBO(70, 25, 89, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(237, 228, 255, 1), - Color.fromRGBO(215, 187, 245, 1), - Color.fromRGBO(160, 118, 249, 1), - Color.fromRGBO(101, 40, 247, 1) - ]), - LinearGradient(colors: [ - Color.fromRGBO(255, 236, 175, 1), - Color.fromRGBO(255, 176, 127, 1), - Color.fromRGBO(255, 82, 162, 1), - Color.fromRGBO(243, 21, 89, 1) - ]), -]; diff --git a/lib/collections/http-override.dart b/lib/collections/http-override.dart deleted file mode 100644 index 3bf4f30e..00000000 --- a/lib/collections/http-override.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'dart:io'; - -const allowList = [ - "spotify.com", -]; - -class BadCertificateAllowlistOverrides extends HttpOverrides { - @override - HttpClient createHttpClient(SecurityContext? context) { - return super.createHttpClient(context) - ..badCertificateCallback = (X509Certificate cert, String host, int port) { - return allowList.any((allowedHost) { - return host.endsWith(allowedHost); - }); - }; - } -} diff --git a/lib/collections/initializers.dart b/lib/collections/initializers.dart deleted file mode 100644 index 976661fc..00000000 --- a/lib/collections/initializers.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:io'; - -import 'package:spotube/utils/platform.dart'; -import 'package:win32_registry/win32_registry.dart'; - -Future registerWindowsScheme(String scheme) async { - if (!kIsWindows) return; - String appPath = Platform.resolvedExecutable; - - String protocolRegKey = 'Software\\Classes\\$scheme'; - RegistryValue protocolRegValue = const RegistryValue( - 'URL Protocol', - RegistryValueType.string, - '', - ); - String protocolCmdRegKey = 'shell\\open\\command'; - RegistryValue protocolCmdRegValue = RegistryValue( - '', - RegistryValueType.string, - '"$appPath" "%1"', - ); - - final regKey = Registry.currentUser.createKey(protocolRegKey); - regKey.createValue(protocolRegValue); - regKey.createKey(protocolCmdRegKey).createValue(protocolCmdRegValue); -} diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart deleted file mode 100644 index 42c580ca..00000000 --- a/lib/collections/intents.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/collections/routes.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/modules/player/player_controls.dart'; -import 'package:spotube/provider/audio_player/querying_track_info.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/utils/platform.dart'; - -class PlayPauseIntent extends Intent { - final WidgetRef ref; - const PlayPauseIntent(this.ref); -} - -class PlayPauseAction extends Action { - @override - invoke(intent) async { - if (PlayerControls.focusNode.canRequestFocus) { - PlayerControls.focusNode.requestFocus(); - } - - if (!audioPlayer.isPlaying) { - await audioPlayer.resume(); - } else { - await audioPlayer.pause(); - } - return null; - } -} - -class NavigationIntent extends Intent { - final AppRouter router; - final String path; - const NavigationIntent(this.router, this.path); -} - -class NavigationAction extends Action { - @override - invoke(intent) { - intent.router.navigateNamed(intent.path); - return null; - } -} - -enum HomeTabs { - browse, - search, - - lyrics, - userPlaylists, - userArtists, - userAlbums, - userLocalLibrary, - userDownloads, -} - -class HomeTabIntent extends Intent { - final AppRouter router; - final HomeTabs tab; - const HomeTabIntent(this.router, {required this.tab}); -} - -class HomeTabAction extends Action { - @override - invoke(intent) { - final router = intent.router; - switch (intent.tab) { - case HomeTabs.browse: - router.navigate(const HomeRoute()); - break; - case HomeTabs.search: - router.navigate(const SearchRoute()); - break; - case HomeTabs.lyrics: - router.navigate(const LyricsRoute()); - break; - case HomeTabs.userPlaylists: - router.navigate(const UserPlaylistsRoute()); - break; - case HomeTabs.userArtists: - router.navigate(const UserArtistsRoute()); - break; - case HomeTabs.userAlbums: - router.navigate(const UserAlbumsRoute()); - break; - case HomeTabs.userLocalLibrary: - router.navigate(const UserLocalLibraryRoute()); - break; - case HomeTabs.userDownloads: - router.navigate(const UserDownloadsRoute()); - break; - } - return null; - } -} - -class SeekIntent extends Intent { - final WidgetRef ref; - final bool forward; - const SeekIntent(this.ref, this.forward); -} - -class SeekAction extends Action { - @override - invoke(intent) async { - final isFetchingActiveTrack = intent.ref.read(queryingTrackInfoProvider); - if (isFetchingActiveTrack) { - DirectionalFocusAction().invoke( - DirectionalFocusIntent( - intent.forward ? TraversalDirection.right : TraversalDirection.left, - ), - ); - return null; - } - final position = audioPlayer.position.inSeconds; - await audioPlayer.seek( - Duration( - seconds: intent.forward ? position + 5 : position - 5, - ), - ); - return null; - } -} - -class CloseAppIntent extends Intent {} - -class CloseAppAction extends Action { - @override - invoke(intent) { - if (kIsDesktop) { - exit(0); - } else { - SystemNavigator.pop(); - } - return null; - } -} diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart deleted file mode 100644 index b5d3f7c8..00000000 --- a/lib/collections/language_codes.dart +++ /dev/null @@ -1,765 +0,0 @@ -class ISOLanguageName { - final String name; - final String nativeName; - - const ISOLanguageName({ - required this.name, - required this.nativeName, - }); - - @override - String toString() { - return "$name ($nativeName)"; - } -} - -// Uncomment the languages as we add support for them -// Currently supported: bn,en,fr,hi,zh -abstract class LanguageLocals { - static final Map isoLangs = { - // "ab": const ISOLanguageName( - // name: "Abkhaz", - // nativeName: "аҧсуа", - // ), - // "aa": const ISOLanguageName( - // name: "Afar", - // nativeName: "Afaraf", - // ), - // "af": const ISOLanguageName( - // name: "Afrikaans", - // nativeName: "Afrikaans", - // ), - // "ak": const ISOLanguageName( - // name: "Akan", - // nativeName: "Akan", - // ), - // "sq": const ISOLanguageName( - // name: "Albanian", - // nativeName: "Shqip", - // ), - // "am": const ISOLanguageName( - // name: "Amharic", - // nativeName: "አማርኛ", - // ), - "ar": const ISOLanguageName( - name: "Arabic", - nativeName: "العربية", - ), - // "an": const ISOLanguageName( - // name: "Aragonese", - // nativeName: "Aragonés", - // ), - // "hy": const ISOLanguageName( - // name: "Armenian", - // nativeName: "Հայերեն", - // ), - // "as": const ISOLanguageName( - // name: "Assamese", - // nativeName: "অসমীয়া", - // ), - // "av": const ISOLanguageName( - // name: "Avaric", - // nativeName: "авар мацӀ, магӀарул мацӀ", - // ), - // "ae": const ISOLanguageName( - // name: "Avestan", - // nativeName: "avesta", - // ), - // "ay": const ISOLanguageName( - // name: "Aymara", - // nativeName: "aymar aru", - // ), - // "az": const ISOLanguageName( - // name: "Azerbaijani", - // nativeName: "azərbaycan dili", - // ), - // "bm": const ISOLanguageName( - // name: "Bambara", - // nativeName: "bamanankan", - // ), - // "ba": const ISOLanguageName( - // name: "Bashkir", - // nativeName: "башҡорт теле", - // ), - "eu": const ISOLanguageName( - name: "Basque", - nativeName: "Euskara", - ), - // "be": const ISOLanguageName( - // name: "Belarusian", - // nativeName: "Беларуская", - // ), - "bn": const ISOLanguageName( - name: "Bengali", - nativeName: "বাংলা", - ), - // "bh": const ISOLanguageName( - // name: "Bihari", - // nativeName: "भोजपुरी", - // ), - // "bi": const ISOLanguageName( - // name: "Bislama", - // nativeName: "Bislama", - // ), - // "bs": const ISOLanguageName( - // name: "Bosnian", - // nativeName: "bosanski jezik", - // ), - // "br": const ISOLanguageName( - // name: "Breton", - // nativeName: "brezhoneg", - // ), - // "bg": const ISOLanguageName( - // name: "Bulgarian", - // nativeName: "български език", - // ), - // "my": const ISOLanguageName( - // name: "Burmese", - // nativeName: "ဗမာစာ", - // ), - "ca": const ISOLanguageName( - name: "Catalan", - nativeName: "Català", - ), - // "ch": const ISOLanguageName( - // name: "Chamorro", - // nativeName: "Chamoru", - // ), - // "ce": const ISOLanguageName( - // name: "Chechen", - // nativeName: "нохчийн мотт", - // ), - // "ny": const ISOLanguageName( - // name: "Chichewa", - // nativeName: "chiCheŵa", - // ), - "zh_CN": const ISOLanguageName( - name: "Simplified Chinese", - nativeName: "简体中文", - ), - "zh_TW": const ISOLanguageName( - name: "Traditional Chinese", - nativeName: "繁體中文(台灣)", - ), - // "cv": const ISOLanguageName( - // name: "Chuvash", - // nativeName: "чӑваш чӗлхи", - // ), - // "kw": const ISOLanguageName( - // name: "Cornish", - // nativeName: "Kernewek", - // ), - // "co": const ISOLanguageName( - // name: "Corsican", - // nativeName: "lingua corsa", - // ), - // "cr": const ISOLanguageName( - // name: "Cree", - // nativeName: "ᓀᐦᐃᔭᐍᐏᐣ", - // ), - // "hr": const ISOLanguageName( - // name: "Croatian", - // nativeName: "hrvatski", - // ), - "cs": const ISOLanguageName( - name: "Czech", - nativeName: "česky, čeština", - ), - // "da": const ISOLanguageName( - // name: "Danish", - // nativeName: "dansk", - // ), - // "dv": const ISOLanguageName( - // name: "Maldivian;", - // nativeName: "ދިވެހި", - // ), - "nl": const ISOLanguageName( - name: "Dutch", - nativeName: "Nederlands", - ), - "en": const ISOLanguageName( - name: "English", - nativeName: "English", - ), - // "eo": const ISOLanguageName( - // name: "Esperanto", - // nativeName: "Esperanto", - // ), - // "et": const ISOLanguageName( - // name: "Estonian", - // nativeName: "eesti", - // ), - // "ee": const ISOLanguageName( - // name: "Ewe", - // nativeName: "Eʋegbe", - // ), - // "fo": const ISOLanguageName( - // name: "Faroese", - // nativeName: "føroyskt", - // ), - // "fj": const ISOLanguageName( - // name: "Fijian", - // nativeName: "vosa Vakaviti", - // ), - "fi": const ISOLanguageName( - name: "Finnish", - nativeName: "suomi", - ), - "fr": const ISOLanguageName( - name: "French", - nativeName: "français", - ), - // "ff": const ISOLanguageName( - // name: "Fula; Fulah; Pulaar; Pular", - // nativeName: "Fulfulde, Pulaar, Pular", - // ), - // "gl": const ISOLanguageName( - // name: "Galician", - // nativeName: "Galego", - // ), - "ka": const ISOLanguageName( - name: "Georgian", - nativeName: "ქართული", - ), - "de": const ISOLanguageName( - name: "German", - nativeName: "Deutsch", - ), - // "el": const ISOLanguageName( - // name: "Greek, Modern", - // nativeName: "Ελληνικά", - // ), - // "gn": const ISOLanguageName( - // name: "Guaraní", - // nativeName: "Avañeẽ", - // ), - // "gu": const ISOLanguageName( - // name: "Gujarati", - // nativeName: "ગુજરાતી", - // ), - // "ht": const ISOLanguageName( - // name: "Haitian; Haitian Creole", - // nativeName: "Kreyòl ayisyen", - // ), - // "ha": const ISOLanguageName( - // name: "Hausa", - // nativeName: "Hausa, هَوُسَ", - // ), - // "he": const ISOLanguageName( - // name: "Hebrew (modern)", - // nativeName: "עברית", - // ), - // "hz": const ISOLanguageName( - // name: "Herero", - // nativeName: "Otjiherero", - // ), - "hi": const ISOLanguageName( - name: "Hindi", - nativeName: "हिन्दी, हिंदी", - ), - // "ho": const ISOLanguageName( - // name: "Hiri Motu", - // nativeName: "Hiri Motu", - // ), - // "hu": const ISOLanguageName( - // name: "Hungarian", - // nativeName: "Magyar", - // ), - // "ia": const ISOLanguageName( - // name: "Interlingua", - // nativeName: "Interlingua", - // ), - "id": const ISOLanguageName( - name: "Indonesian", - nativeName: "Bahasa Indonesia", - ), - // "ie": const ISOLanguageName( - // name: "Interlingue", - // nativeName: "Occidental", - // ), - // "ga": const ISOLanguageName( - // name: "Irish", - // nativeName: "Gaeilge", - // ), - // "ig": const ISOLanguageName( - // name: "Igbo", - // nativeName: "Asụsụ Igbo", - // ), - // "ik": const ISOLanguageName( - // name: "Inupiaq", - // nativeName: "Iñupiaq, Iñupiatun", - // ), - // "io": const ISOLanguageName( - // name: "Ido", - // nativeName: "Ido", - // ), - // "is": const ISOLanguageName( - // name: "Icelandic", - // nativeName: "Íslenska", - // ), - "it": const ISOLanguageName( - name: "Italian", - nativeName: "Italiano", - ), - // "iu": const ISOLanguageName( - // name: "Inuktitut", - // nativeName: "ᐃᓄᒃᑎᑐᑦ", - // ), - "ja": const ISOLanguageName( - name: "Japanese", - nativeName: "日本語", - ), - // "jv": const ISOLanguageName( - // name: "Javanese", - // nativeName: "basa Jawa", - // ), - // "kl": const ISOLanguageName( - // name: "Kalaallisut, Greenlandic", - // nativeName: "kalaallisut, kalaallit oqaasii", - // ), - // "kn": const ISOLanguageName( - // name: "Kannada", - // nativeName: "ಕನ್ನಡ", - // ), - // "kr": const ISOLanguageName( - // name: "Kanuri", - // nativeName: "Kanuri", - // ), - // "ks": const ISOLanguageName( - // name: "Kashmiri", - // nativeName: "कश्मीरी, كشميري‎", - // ), - // "kk": const ISOLanguageName( - // name: "Kazakh", - // nativeName: "Қазақ тілі", - // ), - // "km": const ISOLanguageName( - // name: "Khmer", - // nativeName: "ភាសាខ្មែរ", - // ), - // "ki": const ISOLanguageName( - // name: "Kikuyu, Gikuyu", - // nativeName: "Gĩkũyũ", - // ), - // "rw": const ISOLanguageName( - // name: "Kinyarwanda", - // nativeName: "Ikinyarwanda", - // ), - // "ky": const ISOLanguageName( - // name: "Kirghiz, Kyrgyz", - // nativeName: "кыргыз тили", - // ), - // "kv": const ISOLanguageName( - // name: "Komi", - // nativeName: "коми кыв", - // ), - // "kg": const ISOLanguageName( - // name: "Kongo", - // nativeName: "KiKongo", - // ), - "ko": const ISOLanguageName( - name: "Korean", - nativeName: "한국어 (韓國語), 조선말 (朝鮮語)", - ), - // "ku": const ISOLanguageName( - // name: "Kurdish", - // nativeName: "Kurdî, كوردی‎", - // ), - // "kj": const ISOLanguageName( - // name: "Kwanyama, Kuanyama", - // nativeName: "Kuanyama", - // ), - // "la": const ISOLanguageName( - // name: "Latin", - // nativeName: "latine, lingua latina", - // ), - // "lb": const ISOLanguageName( - // name: "Luxembourgish, Letzeburgesch", - // nativeName: "Lëtzebuergesch", - // ), - // "lg": const ISOLanguageName( - // name: "Luganda", - // nativeName: "Luganda", - // ), - // "li": const ISOLanguageName( - // name: "Limburgish, Limburgan, Limburger", - // nativeName: "Limburgs", - // ), - // "ln": const ISOLanguageName( - // name: "Lingala", - // nativeName: "Lingála", - // ), - // "lo": const ISOLanguageName( - // name: "Lao", - // nativeName: "ພາສາລາວ", - // ), - // "lt": const ISOLanguageName( - // name: "Lithuanian", - // nativeName: "lietuvių kalba", - // ), - // "lu": const ISOLanguageName( - // name: "Luba-Katanga", - // nativeName: "", - // ), - // "lv": const ISOLanguageName( - // name: "Latvian", - // nativeName: "latviešu valoda", - // ), - // "gv": const ISOLanguageName( - // name: "Manx", - // nativeName: "Gaelg, Gailck", - // ), - // "mk": const ISOLanguageName( - // name: "Macedonian", - // nativeName: "македонски јазик", - // ), - // "mg": const ISOLanguageName( - // name: "Malagasy", - // nativeName: "Malagasy fiteny", - // ), - // "ms": const ISOLanguageName( - // name: "Malay", - // nativeName: "bahasa Melayu, بهاس ملايو‎", - // ), - // "ml": const ISOLanguageName( - // name: "Malayalam", - // nativeName: "മലയാളം", - // ), - // "mt": const ISOLanguageName( - // name: "Maltese", - // nativeName: "Malti", - // ), - // "mi": const ISOLanguageName( - // name: "Māori", - // nativeName: "te reo Māori", - // ), - // "mr": const ISOLanguageName( - // name: "Marathi (Marāṭhī)", - // nativeName: "मराठी", - // ), - // "mh": const ISOLanguageName( - // name: "Marshallese", - // nativeName: "Kajin M̧ajeļ", - // ), - // "mn": const ISOLanguageName( - // name: "Mongolian", - // nativeName: "монгол", - // ), - // "na": const ISOLanguageName( - // name: "Nauru", - // nativeName: "Ekakairũ Naoero", - // ), - // "nv": const ISOLanguageName( - // name: "Navajo, Navaho", - // nativeName: "Diné bizaad, Dinékʼehǰí", - // ), - // "nb": const ISOLanguageName( - // name: "Norwegian Bokmål", - // nativeName: "Norsk bokmål", - // ), - // "nd": const ISOLanguageName( - // name: "North Ndebele", - // nativeName: "isiNdebele", - // ), - "ne": const ISOLanguageName( - name: "Nepali", - nativeName: "नेपाली", - ), - // "ng": const ISOLanguageName( - // name: "Ndonga", - // nativeName: "Owambo", - // ), - // "nn": const ISOLanguageName( - // name: "Norwegian Nynorsk", - // nativeName: "Norsk nynorsk", - // ), - // "no": const ISOLanguageName( - // name: "Norwegian", - // nativeName: "Norsk", - // ), - // "ii": const ISOLanguageName( - // name: "Nuosu", - // nativeName: "ꆈꌠ꒿ Nuosuhxop", - // ), - // "nr": const ISOLanguageName( - // name: "South Ndebele", - // nativeName: "isiNdebele", - // ), - // "oc": const ISOLanguageName( - // name: "Occitan", - // nativeName: "Occitan", - // ), - // "oj": const ISOLanguageName( - // name: "Ojibwe, Ojibwa", - // nativeName: "ᐊᓂᔑᓈᐯᒧᐎᓐ", - // ), - // "cu": const ISOLanguageName( - // name: "Old Church Slavonic", - // nativeName: "ѩзыкъ словѣньскъ", - // ), - // "om": const ISOLanguageName( - // name: "Oromo", - // nativeName: "Afaan Oromoo", - // ), - // "or": const ISOLanguageName( - // name: "Oriya", - // nativeName: "ଓଡ଼ିଆ", - // ), - // "os": const ISOLanguageName( - // name: "Ossetian, Ossetic", - // nativeName: "ирон æвзаг", - // ), - // "pa": const ISOLanguageName( - // name: "Panjabi, Punjabi", - // nativeName: "ਪੰਜਾਬੀ, پنجابی‎", - // ), - // "pi": const ISOLanguageName( - // name: "Pāli", - // nativeName: "पाऴि", - // ), - "fa": const ISOLanguageName( - name: "Persian", - nativeName: "فارسی", - ), - "pl": const ISOLanguageName( - name: "Polish", - nativeName: "polski", - ), - // "ps": const ISOLanguageName( - // name: "Pashto, Pushto", - // nativeName: "پښتو", - // ), - "pt": const ISOLanguageName( - name: "Portuguese", - nativeName: "Português", - ), - // "qu": const ISOLanguageName( - // name: "Quechua", - // nativeName: "Runa Simi, Kichwa", - // ), - // "rm": const ISOLanguageName( - // name: "Romansh", - // nativeName: "rumantsch grischun", - // ), - // "rn": const ISOLanguageName( - // name: "Kirundi", - // nativeName: "kiRundi", - // ), - // "ro": const ISOLanguageName( - // name: "Romanian, Moldavian, Moldovan", - // nativeName: "română", - // ), - "ru": const ISOLanguageName( - name: "Russian", - nativeName: "русский язык", - ), - // "sa": const ISOLanguageName( - // name: "Sanskrit (Saṁskṛta)", - // nativeName: "संस्कृतम्", - // ), - // "sc": const ISOLanguageName( - // name: "Sardinian", - // nativeName: "sardu", - // ), - // "sd": const ISOLanguageName( - // name: "Sindhi", - // nativeName: "सिन्धी, سنڌي، سندھی‎", - // ), - // "se": const ISOLanguageName( - // name: "Northern Sami", - // nativeName: "Davvisámegiella", - // ), - // "sm": const ISOLanguageName( - // name: "Samoan", - // nativeName: "gagana faa Samoa", - // ), - // "sg": const ISOLanguageName( - // name: "Sango", - // nativeName: "yângâ tî sängö", - // ), - // "sr": const ISOLanguageName( - // name: "Serbian", - // nativeName: "српски језик", - // ), - // "gd": const ISOLanguageName( - // name: "Scottish Gaelic; Gaelic", - // nativeName: "Gàidhlig", - // ), - // "sn": const ISOLanguageName( - // name: "Shona", - // nativeName: "chiShona", - // ), - // "si": const ISOLanguageName( - // name: "Sinhala, Sinhalese", - // nativeName: "සිංහල", - // ), - // "sk": const ISOLanguageName( - // name: "Slovak", - // nativeName: "slovenčina", - // ), - // "sl": const ISOLanguageName( - // name: "Slovene", - // nativeName: "slovenščina", - // ), - // "so": const ISOLanguageName( - // name: "Somali", - // nativeName: "Soomaaliga, af Soomaali", - // ), - // "st": const ISOLanguageName( - // name: "Southern Sotho", - // nativeName: "Sesotho", - // ), - "es": const ISOLanguageName( - name: "Spanish", - nativeName: "español", - ), - // "su": const ISOLanguageName( - // name: "Sundanese", - // nativeName: "Basa Sunda", - // ), - // "sw": const ISOLanguageName( - // name: "Swahili", - // nativeName: "Kiswahili", - // ), - // "ss": const ISOLanguageName( - // name: "Swati", - // nativeName: "SiSwati", - // ), - // "sv": const ISOLanguageName( - // name: "Swedish", - // nativeName: "svenska", - // ), - "ta": const ISOLanguageName( - name: "Tamil", - nativeName: "தமிழ்", - ), - // "te": const ISOLanguageName( - // name: "Telugu", - // nativeName: "తెలుగు", - // ), - // "tg": const ISOLanguageName( - // name: "Tajik", - // nativeName: "тоҷикӣ, toğikī, تاجیکی‎", - // ), - "th": const ISOLanguageName( - name: "Thai", - nativeName: "ไทย", - ), - // "ti": const ISOLanguageName( - // name: "Tigrinya", - // nativeName: "ትግርኛ", - // ), - // "bo": const ISOLanguageName( - // name: "Tibetan Standard, Tibetan, Central", - // nativeName: "བོད་ཡིག", - // ), - // "tk": const ISOLanguageName( - // name: "Turkmen", - // nativeName: "Türkmen, Түркмен", - // ), - "tl": const ISOLanguageName( - name: "Tagalog", - nativeName: "Wikang Tagalog", - ), - // "tn": const ISOLanguageName( - // name: "Tswana", - // nativeName: "Setswana", - // ), - // "to": const ISOLanguageName( - // name: "Tonga (Tonga Islands)", - // nativeName: "faka Tonga", - // ), - "tr": const ISOLanguageName( - name: "Turkish", - nativeName: "Türkçe", - ), - // "ts": const ISOLanguageName( - // name: "Tsonga", - // nativeName: "Xitsonga", - // ), - // "tt": const ISOLanguageName( - // name: "Tatar", - // nativeName: "татарча, tatarça, تاتارچا‎", - // ), - // "tw": const ISOLanguageName( - // name: "Twi", - // nativeName: "Twi", - // ), - // "ty": const ISOLanguageName( - // name: "Tahitian", - // nativeName: "Reo Tahiti", - // ), - // "ug": const ISOLanguageName( - // name: "Uighur, Uyghur", - // nativeName: "Uyƣurqə, ئۇيغۇرچە‎", - // ), - "uk": const ISOLanguageName( - name: "Ukrainian", - nativeName: "українська", - ), - // "ur": const ISOLanguageName( - // name: "Urdu", - // nativeName: "اردو", - // ), - // "uz": const ISOLanguageName( - // name: "Uzbek", - // nativeName: "zbek, Ўзбек, أۇزبېك‎", - // ), - // "ve": const ISOLanguageName( - // name: "Venda", - // nativeName: "Tshivenḓa", - // ), - "vi": const ISOLanguageName( - name: "Vietnamese", - nativeName: "Tiếng Việt", - ), - // "vo": const ISOLanguageName( - // name: "Volapük", - // nativeName: "Volapük", - // ), - // "wa": const ISOLanguageName( - // name: "Walloon", - // nativeName: "Walon", - // ), - // "cy": const ISOLanguageName( - // name: "Welsh", - // nativeName: "Cymraeg", - // ), - // "wo": const ISOLanguageName( - // name: "Wolof", - // nativeName: "Wollof", - // ), - // "fy": const ISOLanguageName( - // name: "Western Frisian", - // nativeName: "Frysk", - // ), - // "xh": const ISOLanguageName( - // name: "Xhosa", - // nativeName: "isiXhosa", - // ), - // "yi": const ISOLanguageName( - // name: "Yiddish", - // nativeName: "ייִדיש", - // ), - // "yo": const ISOLanguageName( - // name: "Yoruba", - // nativeName: "Yorùbá", - // ), - // "za": const ISOLanguageName( - // name: "Zhuang, Chuang", - // nativeName: "Saɯ cueŋƅ, Saw cuengh", - // ) - }; - - static ISOLanguageName getDisplayLanguage(String key, String? countryCode) { - if (isoLangs.containsKey(key)) { - return isoLangs[key]!; - } else if (countryCode != null && - countryCode.isNotEmpty && - isoLangs.containsKey("${key}_$countryCode")) { - return isoLangs["${key}_$countryCode"]!; - } else { - throw Exception("Language key incorrect"); - } - } -} diff --git a/lib/collections/markets.dart b/lib/collections/markets.dart deleted file mode 100644 index 8398c662..00000000 --- a/lib/collections/markets.dart +++ /dev/null @@ -1,189 +0,0 @@ -// Country Codes contributed by momobobe - -import 'package:spotube/models/metadata/market.dart'; - -final marketsMap = [ - (Market.AL, "Albania (AL)"), - (Market.DZ, "Algeria (DZ)"), - (Market.AD, "Andorra (AD)"), - (Market.AO, "Angola (AO)"), - (Market.AG, "Antigua and Barbuda (AG)"), - (Market.AR, "Argentina (AR)"), - (Market.AM, "Armenia (AM)"), - (Market.AU, "Australia (AU)"), - (Market.AT, "Austria (AT)"), - (Market.AZ, "Azerbaijan (AZ)"), - (Market.BH, "Bahrain (BH)"), - (Market.BD, "Bangladesh (BD)"), - (Market.BB, "Barbados (BB)"), - (Market.BY, "Belarus (BY)"), - (Market.BE, "Belgium (BE)"), - (Market.BZ, "Belize (BZ)"), - (Market.BJ, "Benin (BJ)"), - (Market.BT, "Bhutan (BT)"), - (Market.BO, "Bolivia (BO)"), - (Market.BA, "Bosnia and Herzegovina (BA)"), - (Market.BW, "Botswana (BW)"), - (Market.BR, "Brazil (BR)"), - (Market.BN, "Brunei Darussalam (BN)"), - (Market.BG, "Bulgaria (BG)"), - (Market.BF, "Burkina Faso (BF)"), - (Market.BI, "Burundi (BI)"), - (Market.CV, "Cabo Verde / Cape Verde (CV)"), - (Market.KH, "Cambodia (KH)"), - (Market.CM, "Cameroon (CM)"), - (Market.CA, "Canada (CA)"), - (Market.TD, "Chad (TD)"), - (Market.CL, "Chile (CL)"), - (Market.CO, "Colombia (CO)"), - (Market.KM, "Comoros (KM)"), - (Market.CR, "Costa Rica (CR)"), - (Market.HR, "Croatia (HR)"), - (Market.CW, "Curaçao (CW)"), - (Market.CY, "Cyprus (CY)"), - (Market.CZ, "Czech Republic (CZ)"), - (Market.CI, "Ivory Coast (CI)"), - (Market.CD, "Congo (CD)"), - (Market.DK, "Denmark (DK)"), - (Market.DJ, "Djibouti (DJ)"), - (Market.DM, "Dominica (DM)"), - (Market.DO, "Dominican Republic (DO)"), - (Market.EC, "Ecuador (EC)"), - (Market.EG, "Egypt (EG)"), - (Market.SV, "El Salvador (SV)"), - (Market.GQ, "Equatorial Guinea (GQ)"), - (Market.EE, "Estonia (EE)"), - (Market.SZ, "Eswatini (SZ)"), - (Market.FJ, "Fiji (FJ)"), - (Market.FI, "Finland (FI)"), - (Market.FR, "France (FR)"), - (Market.GA, "Gabon (GA)"), - (Market.GE, "Georgia (GE)"), - (Market.DE, "Germany (DE)"), - (Market.GH, "Ghana (GH)"), - (Market.GR, "Greece (GR)"), - (Market.GD, "Grenada (GD)"), - (Market.GT, "Guatemala (GT)"), - (Market.GN, "Guinea (GN)"), - (Market.GW, "Guinea-Bissau (GW)"), - (Market.GY, "Guyana (GY)"), - (Market.HT, "Haiti (HT)"), - (Market.HN, "Honduras (HN)"), - (Market.HK, "Hong Kong (HK)"), - (Market.HU, "Hungary (HU)"), - (Market.IS, "Iceland (IS)"), - (Market.IN, "India (IN)"), - (Market.ID, "Indonesia (ID)"), - (Market.IQ, "Iraq (IQ)"), - (Market.IE, "Ireland (IE)"), - (Market.IL, "Israel (IL)"), - (Market.IT, "Italy (IT)"), - (Market.JM, "Jamaica (JM)"), - (Market.JP, "Japan (JP)"), - (Market.JO, "Jordan (JO)"), - (Market.KZ, "Kazakhstan (KZ)"), - (Market.KE, "Kenya (KE)"), - (Market.KI, "Kiribati (KI)"), - (Market.XK, "Kosovo (XK)"), - (Market.KW, "Kuwait (KW)"), - (Market.KG, "Kyrgyzstan (KG)"), - (Market.LA, "Laos (LA)"), - (Market.LV, "Latvia (LV)"), - (Market.LB, "Lebanon (LB)"), - (Market.LS, "Lesotho (LS)"), - (Market.LR, "Liberia (LR)"), - (Market.LY, "Libya (LY)"), - (Market.LI, "Liechtenstein (LI)"), - (Market.LT, "Lithuania (LT)"), - (Market.LU, "Luxembourg (LU)"), - (Market.MO, "Macao / Macau (MO)"), - (Market.MG, "Madagascar (MG)"), - (Market.MW, "Malawi (MW)"), - (Market.MY, "Malaysia (MY)"), - (Market.MV, "Maldives (MV)"), - (Market.ML, "Mali (ML)"), - (Market.MT, "Malta (MT)"), - (Market.MH, "Marshall Islands (MH)"), - (Market.MR, "Mauritania (MR)"), - (Market.MU, "Mauritius (MU)"), - (Market.MX, "Mexico (MX)"), - (Market.FM, "Micronesia (FM)"), - (Market.MD, "Moldova (MD)"), - (Market.MC, "Monaco (MC)"), - (Market.MN, "Mongolia (MN)"), - (Market.ME, "Montenegro (ME)"), - (Market.MA, "Morocco (MA)"), - (Market.MZ, "Mozambique (MZ)"), - (Market.NA, "Namibia (NA)"), - (Market.NR, "Nauru (NR)"), - (Market.NP, "Nepal (NP)"), - (Market.NL, "Netherlands (NL)"), - (Market.NZ, "New Zealand (NZ)"), - (Market.NI, "Nicaragua (NI)"), - (Market.NE, "Niger (NE)"), - (Market.NG, "Nigeria (NG)"), - (Market.MK, "North Macedonia (MK)"), - (Market.NO, "Norway (NO)"), - (Market.OM, "Oman (OM)"), - (Market.PK, "Pakistan (PK)"), - (Market.PW, "Palau (PW)"), - (Market.PS, "Palestine (PS)"), - (Market.PA, "Panama (PA)"), - (Market.PG, "Papua New Guinea (PG)"), - (Market.PY, "Paraguay (PY)"), - (Market.PE, "Peru (PE)"), - (Market.PH, "Philippines (PH)"), - (Market.PL, "Poland (PL)"), - (Market.PT, "Portugal (PT)"), - (Market.QA, "Qatar (QA)"), - (Market.CG, "Congo (CG)"), - (Market.RO, "Romania (RO)"), - (Market.RU, "Russia (RU)"), - (Market.RW, "Rwanda (RW)"), - (Market.WS, "Samoa (WS)"), - (Market.SM, "San Marino (SM)"), - (Market.SA, "Saudi Arabia (SA)"), - (Market.SN, "Senegal (SN)"), - (Market.RS, "Serbia (RS)"), - (Market.SC, "Seychelles (SC)"), - (Market.SL, "Sierra Leone (SL)"), - (Market.SG, "Singapore (SG)"), - (Market.SK, "Slovakia (SK)"), - (Market.SI, "Slovenia (SI)"), - (Market.SB, "Solomon Islands (SB)"), - (Market.ZA, "South Africa (ZA)"), - (Market.KR, "South Korea (KR)"), - (Market.ES, "Spain (ES)"), - (Market.LK, "Sri Lanka (LK)"), - (Market.KN, "St. Kitts and Nevis (KN)"), - (Market.LC, "St. Lucia (LC)"), - (Market.SR, "Suriname (SR)"), - (Market.SE, "Sweden (SE)"), - (Market.CH, "Switzerland (CH)"), - (Market.ST, "São Tomé and Príncipe (ST)"), - (Market.TW, "Taiwan (TW)"), - (Market.TJ, "Tajikistan (TJ)"), - (Market.TZ, "Tanzania (TZ)"), - (Market.TH, "Thailand (TH)"), - (Market.BS, "The Bahamas (BS)"), - (Market.GM, "The Gambia (GM)"), - (Market.TL, "East Timor (TL)"), - (Market.TG, "Togo (TG)"), - (Market.TO, "Tonga (TO)"), - (Market.TT, "Trinidad and Tobago (TT)"), - (Market.TN, "Tunisia (TN)"), - (Market.TR, "Turkey (TR)"), - (Market.TV, "Tuvalu (TV)"), - (Market.UG, "Uganda (UG)"), - (Market.UA, "Ukraine (UA)"), - (Market.AE, "United Arab Emirates (AE)"), - (Market.GB, "United Kingdom (GB)"), - (Market.US, "United States (US)"), - (Market.UY, "Uruguay (UY)"), - (Market.UZ, "Uzbekistan (UZ)"), - (Market.VU, "Vanuatu (VU)"), - (Market.VE, "Venezuela (VE)"), - (Market.VN, "Vietnam (VN)"), - (Market.ZM, "Zambia (ZM)"), - (Market.ZW, "Zimbabwe (ZW)"), -]; diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart deleted file mode 100644 index 4dcd9657..00000000 --- a/lib/collections/routes.dart +++ /dev/null @@ -1,227 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/services/kv_store/kv_store.dart'; - -final rootNavigatorKey = GlobalKey(); - -@AutoRouterConfig(replaceInRouteName: 'Screen|Page,Route') -class AppRouter extends RootStackRouter { - final WidgetRef ref; - - AppRouter(this.ref) : super(navigatorKey: rootNavigatorKey); - - @override - List get routes => [ - AutoRoute( - page: RootAppRoute.page, - path: "/", - initial: true, - children: [ - AutoRoute( - path: "home", - page: HomeRoute.page, - initial: true, - guards: [ - AutoRouteGuardCallback( - (resolver, router) async { - final authenticated = await ref - .read(metadataPluginAuthenticatedProvider.future); - - if (!authenticated && !KVStoreService.doneGettingStarted) { - resolver.redirect(const GettingStartedRoute()); - } else { - resolver.next(true); - } - }, - ), - ], - ), - AutoRoute( - path: "home/sections/:sectionId", - page: HomeBrowseSectionItemsRoute.page, - ), - AutoRoute( - path: "search", - page: SearchRoute.page, - ), - AutoRoute( - path: "library", - page: LibraryRoute.page, - children: [ - AutoRoute( - path: "playlists", - page: UserPlaylistsRoute.page, - ), - AutoRoute( - path: "artists", - page: UserArtistsRoute.page, - ), - AutoRoute( - path: "albums", - page: UserAlbumsRoute.page, - ), - AutoRoute( - path: "local", - page: UserLocalLibraryRoute.page, - ), - AutoRoute( - path: "downloads", - page: UserDownloadsRoute.page, - ), - ], - ), - AutoRoute( - path: "local/folder", - page: LocalLibraryRoute.page, - // parentNavigatorKey: shellRouteNavigatorKey, - ), - AutoRoute( - path: "lyrics", - page: LyricsRoute.page, - ), - AutoRoute( - path: "settings", - page: SettingsRoute.page, - ), - AutoRoute( - path: "settings/metadata-provider", - page: SettingsMetadataProviderRoute.page, - ), - AutoRoute( - path: "settings/metadata-provider/metadata-form", - page: SettingsMetadataProviderFormRoute.page, - ), - AutoRoute( - path: "settings/blacklist", - page: BlackListRoute.page, - ), - if (!kIsWeb) - AutoRoute( - path: "settings/logs", - page: LogsRoute.page, - ), - AutoRoute( - path: "settings/about", - page: AboutSpotubeRoute.page, - ), - AutoRoute( - path: "settings/scrobbling", - page: SettingsScrobblingRoute.page, - ), - AutoRoute( - path: "album/:id", - page: AlbumRoute.page, - ), - AutoRoute( - path: "artist/:id", - page: ArtistRoute.page, - ), - AutoRoute( - path: "liked-tracks", - page: LikedPlaylistRoute.page, - ), - AutoRoute( - path: "playlist/:id", - page: PlaylistRoute.page, - guards: [ - AutoRouteGuard.redirect( - (resolver) { - final PlaylistRouteArgs(:id, :playlist) = - resolver.route.args as PlaylistRouteArgs; - if (id == "user-liked-tracks") { - return LikedPlaylistRoute(playlist: playlist); - } - - return null; - }, - ), - ], - ), - AutoRoute( - path: "track/:id", - page: TrackRoute.page, - ), - AutoRoute( - path: "connect", - page: ConnectRoute.page, - ), - AutoRoute( - path: "connect/control", - page: ConnectControlRoute.page, - ), - AutoRoute( - path: "profile", - page: ProfileRoute.page, - ), - AutoRoute( - path: "stats", - page: StatsRoute.page, - ), - AutoRoute( - path: "stats/minutes", - page: StatsMinutesRoute.page, - ), - AutoRoute( - path: "stats/streams", - page: StatsStreamsRoute.page, - ), - AutoRoute( - path: "stats/fees", - page: StatsStreamFeesRoute.page, - ), - AutoRoute( - path: "stats/artists", - page: StatsArtistsRoute.page, - ), - AutoRoute( - path: "stats/albums", - page: StatsAlbumsRoute.page, - ), - AutoRoute( - path: "stats/playlists", - page: StatsPlaylistsRoute.page, - ), - ], - ), - CustomRoute( - transitionsBuilder: TransitionsBuilders.slideBottom, - durationInMilliseconds: 200, - reverseDurationInMilliseconds: 200, - path: "/player/queue", - page: PlayerQueueRoute.page, - ), - CustomRoute( - transitionsBuilder: TransitionsBuilders.slideBottom, - durationInMilliseconds: 200, - reverseDurationInMilliseconds: 200, - path: "/player/sources", - page: PlayerTrackSourcesRoute.page, - ), - CustomRoute( - transitionsBuilder: TransitionsBuilders.slideBottom, - durationInMilliseconds: 200, - reverseDurationInMilliseconds: 200, - path: "/player/lyrics", - page: PlayerLyricsRoute.page, - ), - AutoRoute( - path: "/mini-player", - page: MiniLyricsRoute.page, - // parentNavigatorKey: rootNavigatorKey, - ), - AutoRoute( - path: "/getting-started", - page: GettingStartedRoute.page, - // parentNavigatorKey: rootNavigatorKey, - ), - AutoRoute( - path: "/lastfm-login", - page: LastFMLoginRoute.page, - // parentNavigatorKey: rootNavigatorKey, - ), - ]; -} diff --git a/lib/collections/routes.gr.dart b/lib/collections/routes.gr.dart deleted file mode 100644 index f5ff24bf..00000000 --- a/lib/collections/routes.gr.dart +++ /dev/null @@ -1,960 +0,0 @@ -// dart format width=80 -// GENERATED CODE - DO NOT MODIFY BY HAND - -// ************************************************************************** -// AutoRouterGenerator -// ************************************************************************** - -// ignore_for_file: type=lint -// coverage:ignore-file - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:auto_route/auto_route.dart' as _i41; -import 'package:flutter/material.dart' as _i42; -import 'package:shadcn_flutter/shadcn_flutter.dart' as _i44; -import 'package:spotube/models/metadata/metadata.dart' as _i43; -import 'package:spotube/pages/album/album.dart' as _i2; -import 'package:spotube/pages/artist/artist.dart' as _i3; -import 'package:spotube/pages/connect/connect.dart' as _i6; -import 'package:spotube/pages/connect/control/control.dart' as _i5; -import 'package:spotube/pages/getting_started/getting_started.dart' as _i7; -import 'package:spotube/pages/home/home.dart' as _i9; -import 'package:spotube/pages/home/sections/section_items.dart' as _i8; -import 'package:spotube/pages/lastfm_login/lastfm_login.dart' as _i10; -import 'package:spotube/pages/library/library.dart' as _i11; -import 'package:spotube/pages/library/user_albums.dart' as _i36; -import 'package:spotube/pages/library/user_artists.dart' as _i37; -import 'package:spotube/pages/library/user_downloads.dart' as _i38; -import 'package:spotube/pages/library/user_local_tracks/local_folder.dart' - as _i13; -import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart' - as _i39; -import 'package:spotube/pages/library/user_playlists.dart' as _i40; -import 'package:spotube/pages/lyrics/lyrics.dart' as _i15; -import 'package:spotube/pages/lyrics/mini_lyrics.dart' as _i16; -import 'package:spotube/pages/player/lyrics.dart' as _i17; -import 'package:spotube/pages/player/queue.dart' as _i18; -import 'package:spotube/pages/player/sources.dart' as _i19; -import 'package:spotube/pages/playlist/liked_playlist.dart' as _i12; -import 'package:spotube/pages/playlist/playlist.dart' as _i20; -import 'package:spotube/pages/profile/profile.dart' as _i21; -import 'package:spotube/pages/root/root_app.dart' as _i22; -import 'package:spotube/pages/search/search.dart' as _i23; -import 'package:spotube/pages/settings/about.dart' as _i1; -import 'package:spotube/pages/settings/blacklist.dart' as _i4; -import 'package:spotube/pages/settings/logs.dart' as _i14; -import 'package:spotube/pages/settings/metadata/metadata_form.dart' as _i24; -import 'package:spotube/pages/settings/metadata_plugins.dart' as _i25; -import 'package:spotube/pages/settings/scrobbling/scrobbling.dart' as _i27; -import 'package:spotube/pages/settings/settings.dart' as _i26; -import 'package:spotube/pages/stats/albums/albums.dart' as _i28; -import 'package:spotube/pages/stats/artists/artists.dart' as _i29; -import 'package:spotube/pages/stats/fees/fees.dart' as _i33; -import 'package:spotube/pages/stats/minutes/minutes.dart' as _i30; -import 'package:spotube/pages/stats/playlists/playlists.dart' as _i32; -import 'package:spotube/pages/stats/stats.dart' as _i31; -import 'package:spotube/pages/stats/streams/streams.dart' as _i34; -import 'package:spotube/pages/track/track.dart' as _i35; - -/// generated route for -/// [_i1.AboutSpotubePage] -class AboutSpotubeRoute extends _i41.PageRouteInfo { - const AboutSpotubeRoute({List<_i41.PageRouteInfo>? children}) - : super(AboutSpotubeRoute.name, initialChildren: children); - - static const String name = 'AboutSpotubeRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i1.AboutSpotubePage(); - }, - ); -} - -/// generated route for -/// [_i2.AlbumPage] -class AlbumRoute extends _i41.PageRouteInfo { - AlbumRoute({ - _i42.Key? key, - required String id, - required _i43.SpotubeSimpleAlbumObject album, - List<_i41.PageRouteInfo>? children, - }) : super( - AlbumRoute.name, - args: AlbumRouteArgs(key: key, id: id, album: album), - rawPathParams: {'id': id}, - initialChildren: children, - ); - - static const String name = 'AlbumRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - final args = data.argsAs(); - return _i2.AlbumPage(key: args.key, id: args.id, album: args.album); - }, - ); -} - -class AlbumRouteArgs { - const AlbumRouteArgs({this.key, required this.id, required this.album}); - - final _i42.Key? key; - - final String id; - - final _i43.SpotubeSimpleAlbumObject album; - - @override - String toString() { - return 'AlbumRouteArgs{key: $key, id: $id, album: $album}'; - } -} - -/// generated route for -/// [_i3.ArtistPage] -class ArtistRoute extends _i41.PageRouteInfo { - ArtistRoute({ - required String artistId, - _i42.Key? key, - List<_i41.PageRouteInfo>? children, - }) : super( - ArtistRoute.name, - args: ArtistRouteArgs(artistId: artistId, key: key), - rawPathParams: {'id': artistId}, - initialChildren: children, - ); - - static const String name = 'ArtistRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - final pathParams = data.inheritedPathParams; - final args = data.argsAs( - orElse: () => ArtistRouteArgs(artistId: pathParams.getString('id')), - ); - return _i3.ArtistPage(args.artistId, key: args.key); - }, - ); -} - -class ArtistRouteArgs { - const ArtistRouteArgs({required this.artistId, this.key}); - - final String artistId; - - final _i42.Key? key; - - @override - String toString() { - return 'ArtistRouteArgs{artistId: $artistId, key: $key}'; - } -} - -/// generated route for -/// [_i4.BlackListPage] -class BlackListRoute extends _i41.PageRouteInfo { - const BlackListRoute({List<_i41.PageRouteInfo>? children}) - : super(BlackListRoute.name, initialChildren: children); - - static const String name = 'BlackListRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i4.BlackListPage(); - }, - ); -} - -/// generated route for -/// [_i5.ConnectControlPage] -class ConnectControlRoute extends _i41.PageRouteInfo { - const ConnectControlRoute({List<_i41.PageRouteInfo>? children}) - : super(ConnectControlRoute.name, initialChildren: children); - - static const String name = 'ConnectControlRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i5.ConnectControlPage(); - }, - ); -} - -/// generated route for -/// [_i6.ConnectPage] -class ConnectRoute extends _i41.PageRouteInfo { - const ConnectRoute({List<_i41.PageRouteInfo>? children}) - : super(ConnectRoute.name, initialChildren: children); - - static const String name = 'ConnectRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i6.ConnectPage(); - }, - ); -} - -/// generated route for -/// [_i7.GettingStartedPage] -class GettingStartedRoute extends _i41.PageRouteInfo { - const GettingStartedRoute({List<_i41.PageRouteInfo>? children}) - : super(GettingStartedRoute.name, initialChildren: children); - - static const String name = 'GettingStartedRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i7.GettingStartedPage(); - }, - ); -} - -/// generated route for -/// [_i8.HomeBrowseSectionItemsPage] -class HomeBrowseSectionItemsRoute - extends _i41.PageRouteInfo { - HomeBrowseSectionItemsRoute({ - _i44.Key? key, - required String sectionId, - required _i43.SpotubeBrowseSectionObject section, - List<_i41.PageRouteInfo>? children, - }) : super( - HomeBrowseSectionItemsRoute.name, - args: HomeBrowseSectionItemsRouteArgs( - key: key, - sectionId: sectionId, - section: section, - ), - rawPathParams: {'sectionId': sectionId}, - initialChildren: children, - ); - - static const String name = 'HomeBrowseSectionItemsRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - final args = data.argsAs(); - return _i8.HomeBrowseSectionItemsPage( - key: args.key, - sectionId: args.sectionId, - section: args.section, - ); - }, - ); -} - -class HomeBrowseSectionItemsRouteArgs { - const HomeBrowseSectionItemsRouteArgs({ - this.key, - required this.sectionId, - required this.section, - }); - - final _i44.Key? key; - - final String sectionId; - - final _i43.SpotubeBrowseSectionObject section; - - @override - String toString() { - return 'HomeBrowseSectionItemsRouteArgs{key: $key, sectionId: $sectionId, section: $section}'; - } -} - -/// generated route for -/// [_i9.HomePage] -class HomeRoute extends _i41.PageRouteInfo { - const HomeRoute({List<_i41.PageRouteInfo>? children}) - : super(HomeRoute.name, initialChildren: children); - - static const String name = 'HomeRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i9.HomePage(); - }, - ); -} - -/// generated route for -/// [_i10.LastFMLoginPage] -class LastFMLoginRoute extends _i41.PageRouteInfo { - const LastFMLoginRoute({List<_i41.PageRouteInfo>? children}) - : super(LastFMLoginRoute.name, initialChildren: children); - - static const String name = 'LastFMLoginRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i10.LastFMLoginPage(); - }, - ); -} - -/// generated route for -/// [_i11.LibraryPage] -class LibraryRoute extends _i41.PageRouteInfo { - const LibraryRoute({List<_i41.PageRouteInfo>? children}) - : super(LibraryRoute.name, initialChildren: children); - - static const String name = 'LibraryRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i11.LibraryPage(); - }, - ); -} - -/// generated route for -/// [_i12.LikedPlaylistPage] -class LikedPlaylistRoute extends _i41.PageRouteInfo { - LikedPlaylistRoute({ - _i42.Key? key, - required _i43.SpotubeSimplePlaylistObject playlist, - List<_i41.PageRouteInfo>? children, - }) : super( - LikedPlaylistRoute.name, - args: LikedPlaylistRouteArgs(key: key, playlist: playlist), - initialChildren: children, - ); - - static const String name = 'LikedPlaylistRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - final args = data.argsAs(); - return _i12.LikedPlaylistPage(key: args.key, playlist: args.playlist); - }, - ); -} - -class LikedPlaylistRouteArgs { - const LikedPlaylistRouteArgs({this.key, required this.playlist}); - - final _i42.Key? key; - - final _i43.SpotubeSimplePlaylistObject playlist; - - @override - String toString() { - return 'LikedPlaylistRouteArgs{key: $key, playlist: $playlist}'; - } -} - -/// generated route for -/// [_i13.LocalLibraryPage] -class LocalLibraryRoute extends _i41.PageRouteInfo { - LocalLibraryRoute({ - required String location, - _i42.Key? key, - bool isDownloads = false, - bool isCache = false, - List<_i41.PageRouteInfo>? children, - }) : super( - LocalLibraryRoute.name, - args: LocalLibraryRouteArgs( - location: location, - key: key, - isDownloads: isDownloads, - isCache: isCache, - ), - initialChildren: children, - ); - - static const String name = 'LocalLibraryRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - final args = data.argsAs(); - return _i13.LocalLibraryPage( - args.location, - key: args.key, - isDownloads: args.isDownloads, - isCache: args.isCache, - ); - }, - ); -} - -class LocalLibraryRouteArgs { - const LocalLibraryRouteArgs({ - required this.location, - this.key, - this.isDownloads = false, - this.isCache = false, - }); - - final String location; - - final _i42.Key? key; - - final bool isDownloads; - - final bool isCache; - - @override - String toString() { - return 'LocalLibraryRouteArgs{location: $location, key: $key, isDownloads: $isDownloads, isCache: $isCache}'; - } -} - -/// generated route for -/// [_i14.LogsPage] -class LogsRoute extends _i41.PageRouteInfo { - const LogsRoute({List<_i41.PageRouteInfo>? children}) - : super(LogsRoute.name, initialChildren: children); - - static const String name = 'LogsRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i14.LogsPage(); - }, - ); -} - -/// generated route for -/// [_i15.LyricsPage] -class LyricsRoute extends _i41.PageRouteInfo { - const LyricsRoute({List<_i41.PageRouteInfo>? children}) - : super(LyricsRoute.name, initialChildren: children); - - static const String name = 'LyricsRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i15.LyricsPage(); - }, - ); -} - -/// generated route for -/// [_i16.MiniLyricsPage] -class MiniLyricsRoute extends _i41.PageRouteInfo { - MiniLyricsRoute({ - _i44.Key? key, - required _i44.Size prevSize, - List<_i41.PageRouteInfo>? children, - }) : super( - MiniLyricsRoute.name, - args: MiniLyricsRouteArgs(key: key, prevSize: prevSize), - initialChildren: children, - ); - - static const String name = 'MiniLyricsRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - final args = data.argsAs(); - return _i16.MiniLyricsPage(key: args.key, prevSize: args.prevSize); - }, - ); -} - -class MiniLyricsRouteArgs { - const MiniLyricsRouteArgs({this.key, required this.prevSize}); - - final _i44.Key? key; - - final _i44.Size prevSize; - - @override - String toString() { - return 'MiniLyricsRouteArgs{key: $key, prevSize: $prevSize}'; - } -} - -/// generated route for -/// [_i17.PlayerLyricsPage] -class PlayerLyricsRoute extends _i41.PageRouteInfo { - const PlayerLyricsRoute({List<_i41.PageRouteInfo>? children}) - : super(PlayerLyricsRoute.name, initialChildren: children); - - static const String name = 'PlayerLyricsRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i17.PlayerLyricsPage(); - }, - ); -} - -/// generated route for -/// [_i18.PlayerQueuePage] -class PlayerQueueRoute extends _i41.PageRouteInfo { - const PlayerQueueRoute({List<_i41.PageRouteInfo>? children}) - : super(PlayerQueueRoute.name, initialChildren: children); - - static const String name = 'PlayerQueueRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i18.PlayerQueuePage(); - }, - ); -} - -/// generated route for -/// [_i19.PlayerTrackSourcesPage] -class PlayerTrackSourcesRoute extends _i41.PageRouteInfo { - const PlayerTrackSourcesRoute({List<_i41.PageRouteInfo>? children}) - : super(PlayerTrackSourcesRoute.name, initialChildren: children); - - static const String name = 'PlayerTrackSourcesRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i19.PlayerTrackSourcesPage(); - }, - ); -} - -/// generated route for -/// [_i20.PlaylistPage] -class PlaylistRoute extends _i41.PageRouteInfo { - PlaylistRoute({ - _i42.Key? key, - required String id, - required _i43.SpotubeSimplePlaylistObject playlist, - List<_i41.PageRouteInfo>? children, - }) : super( - PlaylistRoute.name, - args: PlaylistRouteArgs(key: key, id: id, playlist: playlist), - rawPathParams: {'id': id}, - initialChildren: children, - ); - - static const String name = 'PlaylistRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - final args = data.argsAs(); - return _i20.PlaylistPage( - key: args.key, - id: args.id, - playlist: args.playlist, - ); - }, - ); -} - -class PlaylistRouteArgs { - const PlaylistRouteArgs({this.key, required this.id, required this.playlist}); - - final _i42.Key? key; - - final String id; - - final _i43.SpotubeSimplePlaylistObject playlist; - - @override - String toString() { - return 'PlaylistRouteArgs{key: $key, id: $id, playlist: $playlist}'; - } -} - -/// generated route for -/// [_i21.ProfilePage] -class ProfileRoute extends _i41.PageRouteInfo { - const ProfileRoute({List<_i41.PageRouteInfo>? children}) - : super(ProfileRoute.name, initialChildren: children); - - static const String name = 'ProfileRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i21.ProfilePage(); - }, - ); -} - -/// generated route for -/// [_i22.RootAppPage] -class RootAppRoute extends _i41.PageRouteInfo { - const RootAppRoute({List<_i41.PageRouteInfo>? children}) - : super(RootAppRoute.name, initialChildren: children); - - static const String name = 'RootAppRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i22.RootAppPage(); - }, - ); -} - -/// generated route for -/// [_i23.SearchPage] -class SearchRoute extends _i41.PageRouteInfo { - const SearchRoute({List<_i41.PageRouteInfo>? children}) - : super(SearchRoute.name, initialChildren: children); - - static const String name = 'SearchRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i23.SearchPage(); - }, - ); -} - -/// generated route for -/// [_i24.SettingsMetadataProviderFormPage] -class SettingsMetadataProviderFormRoute - extends _i41.PageRouteInfo { - SettingsMetadataProviderFormRoute({ - _i44.Key? key, - required String title, - required List<_i43.MetadataFormFieldObject> fields, - List<_i41.PageRouteInfo>? children, - }) : super( - SettingsMetadataProviderFormRoute.name, - args: SettingsMetadataProviderFormRouteArgs( - key: key, - title: title, - fields: fields, - ), - initialChildren: children, - ); - - static const String name = 'SettingsMetadataProviderFormRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - final args = data.argsAs(); - return _i24.SettingsMetadataProviderFormPage( - key: args.key, - title: args.title, - fields: args.fields, - ); - }, - ); -} - -class SettingsMetadataProviderFormRouteArgs { - const SettingsMetadataProviderFormRouteArgs({ - this.key, - required this.title, - required this.fields, - }); - - final _i44.Key? key; - - final String title; - - final List<_i43.MetadataFormFieldObject> fields; - - @override - String toString() { - return 'SettingsMetadataProviderFormRouteArgs{key: $key, title: $title, fields: $fields}'; - } -} - -/// generated route for -/// [_i25.SettingsMetadataProviderPage] -class SettingsMetadataProviderRoute extends _i41.PageRouteInfo { - const SettingsMetadataProviderRoute({List<_i41.PageRouteInfo>? children}) - : super(SettingsMetadataProviderRoute.name, initialChildren: children); - - static const String name = 'SettingsMetadataProviderRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i25.SettingsMetadataProviderPage(); - }, - ); -} - -/// generated route for -/// [_i26.SettingsPage] -class SettingsRoute extends _i41.PageRouteInfo { - const SettingsRoute({List<_i41.PageRouteInfo>? children}) - : super(SettingsRoute.name, initialChildren: children); - - static const String name = 'SettingsRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i26.SettingsPage(); - }, - ); -} - -/// generated route for -/// [_i27.SettingsScrobblingPage] -class SettingsScrobblingRoute extends _i41.PageRouteInfo { - const SettingsScrobblingRoute({List<_i41.PageRouteInfo>? children}) - : super(SettingsScrobblingRoute.name, initialChildren: children); - - static const String name = 'SettingsScrobblingRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i27.SettingsScrobblingPage(); - }, - ); -} - -/// generated route for -/// [_i28.StatsAlbumsPage] -class StatsAlbumsRoute extends _i41.PageRouteInfo { - const StatsAlbumsRoute({List<_i41.PageRouteInfo>? children}) - : super(StatsAlbumsRoute.name, initialChildren: children); - - static const String name = 'StatsAlbumsRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i28.StatsAlbumsPage(); - }, - ); -} - -/// generated route for -/// [_i29.StatsArtistsPage] -class StatsArtistsRoute extends _i41.PageRouteInfo { - const StatsArtistsRoute({List<_i41.PageRouteInfo>? children}) - : super(StatsArtistsRoute.name, initialChildren: children); - - static const String name = 'StatsArtistsRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i29.StatsArtistsPage(); - }, - ); -} - -/// generated route for -/// [_i30.StatsMinutesPage] -class StatsMinutesRoute extends _i41.PageRouteInfo { - const StatsMinutesRoute({List<_i41.PageRouteInfo>? children}) - : super(StatsMinutesRoute.name, initialChildren: children); - - static const String name = 'StatsMinutesRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i30.StatsMinutesPage(); - }, - ); -} - -/// generated route for -/// [_i31.StatsPage] -class StatsRoute extends _i41.PageRouteInfo { - const StatsRoute({List<_i41.PageRouteInfo>? children}) - : super(StatsRoute.name, initialChildren: children); - - static const String name = 'StatsRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i31.StatsPage(); - }, - ); -} - -/// generated route for -/// [_i32.StatsPlaylistsPage] -class StatsPlaylistsRoute extends _i41.PageRouteInfo { - const StatsPlaylistsRoute({List<_i41.PageRouteInfo>? children}) - : super(StatsPlaylistsRoute.name, initialChildren: children); - - static const String name = 'StatsPlaylistsRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i32.StatsPlaylistsPage(); - }, - ); -} - -/// generated route for -/// [_i33.StatsStreamFeesPage] -class StatsStreamFeesRoute extends _i41.PageRouteInfo { - const StatsStreamFeesRoute({List<_i41.PageRouteInfo>? children}) - : super(StatsStreamFeesRoute.name, initialChildren: children); - - static const String name = 'StatsStreamFeesRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i33.StatsStreamFeesPage(); - }, - ); -} - -/// generated route for -/// [_i34.StatsStreamsPage] -class StatsStreamsRoute extends _i41.PageRouteInfo { - const StatsStreamsRoute({List<_i41.PageRouteInfo>? children}) - : super(StatsStreamsRoute.name, initialChildren: children); - - static const String name = 'StatsStreamsRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i34.StatsStreamsPage(); - }, - ); -} - -/// generated route for -/// [_i35.TrackPage] -class TrackRoute extends _i41.PageRouteInfo { - TrackRoute({ - _i44.Key? key, - required String trackId, - List<_i41.PageRouteInfo>? children, - }) : super( - TrackRoute.name, - args: TrackRouteArgs(key: key, trackId: trackId), - rawPathParams: {'id': trackId}, - initialChildren: children, - ); - - static const String name = 'TrackRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - final pathParams = data.inheritedPathParams; - final args = data.argsAs( - orElse: () => TrackRouteArgs(trackId: pathParams.getString('id')), - ); - return _i35.TrackPage(key: args.key, trackId: args.trackId); - }, - ); -} - -class TrackRouteArgs { - const TrackRouteArgs({this.key, required this.trackId}); - - final _i44.Key? key; - - final String trackId; - - @override - String toString() { - return 'TrackRouteArgs{key: $key, trackId: $trackId}'; - } -} - -/// generated route for -/// [_i36.UserAlbumsPage] -class UserAlbumsRoute extends _i41.PageRouteInfo { - const UserAlbumsRoute({List<_i41.PageRouteInfo>? children}) - : super(UserAlbumsRoute.name, initialChildren: children); - - static const String name = 'UserAlbumsRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i36.UserAlbumsPage(); - }, - ); -} - -/// generated route for -/// [_i37.UserArtistsPage] -class UserArtistsRoute extends _i41.PageRouteInfo { - const UserArtistsRoute({List<_i41.PageRouteInfo>? children}) - : super(UserArtistsRoute.name, initialChildren: children); - - static const String name = 'UserArtistsRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i37.UserArtistsPage(); - }, - ); -} - -/// generated route for -/// [_i38.UserDownloadsPage] -class UserDownloadsRoute extends _i41.PageRouteInfo { - const UserDownloadsRoute({List<_i41.PageRouteInfo>? children}) - : super(UserDownloadsRoute.name, initialChildren: children); - - static const String name = 'UserDownloadsRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i38.UserDownloadsPage(); - }, - ); -} - -/// generated route for -/// [_i39.UserLocalLibraryPage] -class UserLocalLibraryRoute extends _i41.PageRouteInfo { - const UserLocalLibraryRoute({List<_i41.PageRouteInfo>? children}) - : super(UserLocalLibraryRoute.name, initialChildren: children); - - static const String name = 'UserLocalLibraryRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i39.UserLocalLibraryPage(); - }, - ); -} - -/// generated route for -/// [_i40.UserPlaylistsPage] -class UserPlaylistsRoute extends _i41.PageRouteInfo { - const UserPlaylistsRoute({List<_i41.PageRouteInfo>? children}) - : super(UserPlaylistsRoute.name, initialChildren: children); - - static const String name = 'UserPlaylistsRoute'; - - static _i41.PageInfo page = _i41.PageInfo( - name, - builder: (data) { - return const _i40.UserPlaylistsPage(); - }, - ); -} diff --git a/lib/collections/side_bar_tiles.dart b/lib/collections/side_bar_tiles.dart deleted file mode 100644 index c647c9fb..00000000 --- a/lib/collections/side_bar_tiles.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/l10n/l10n.dart'; - -class SideBarTiles { - final IconData icon; - final String title; - final String id; - final String pathPrefix; - final PageRouteInfo route; - - SideBarTiles({ - required this.icon, - required this.title, - required this.id, - required this.route, - required this.pathPrefix, - }); -} - -List getSidebarTileList(AppLocalizations l10n) => [ - SideBarTiles( - id: "home", - pathPrefix: "/home", - route: const HomeRoute(), - icon: SpotubeIcons.home, - title: l10n.browse, - ), - SideBarTiles( - id: "search", - pathPrefix: "/search", - route: const SearchRoute(), - icon: SpotubeIcons.search, - title: l10n.search, - ), - SideBarTiles( - id: "lyrics", - pathPrefix: "/lyrics", - route: const LyricsRoute(), - icon: SpotubeIcons.music, - title: l10n.lyrics, - ), - SideBarTiles( - id: "stats", - pathPrefix: "/stats", - route: const StatsRoute(), - icon: SpotubeIcons.chart, - title: l10n.stats, - ), - ]; - -List getSidebarLibraryTileList(AppLocalizations l10n) => [ - SideBarTiles( - id: "playlists", - pathPrefix: "/library/playlists", - title: l10n.playlists, - route: const UserPlaylistsRoute(), - icon: SpotubeIcons.playlist, - ), - SideBarTiles( - id: "artists", - pathPrefix: "/library/artists", - title: l10n.artists, - route: const UserArtistsRoute(), - icon: SpotubeIcons.artist, - ), - SideBarTiles( - id: "albums", - pathPrefix: "/library/albums", - title: l10n.albums, - route: const UserAlbumsRoute(), - icon: SpotubeIcons.album, - ), - SideBarTiles( - id: "local_library", - pathPrefix: "/library/local", - title: l10n.local_library, - route: const UserLocalLibraryRoute(), - icon: SpotubeIcons.device, - ), - ]; - -List getNavbarTileList(AppLocalizations l10n) => [ - SideBarTiles( - id: "home", - pathPrefix: "/home", - route: const HomeRoute(), - icon: SpotubeIcons.home, - title: l10n.browse, - ), - SideBarTiles( - id: "search", - pathPrefix: "/search", - route: const SearchRoute(), - icon: SpotubeIcons.search, - title: l10n.search, - ), - SideBarTiles( - id: "library", - pathPrefix: "/library", - route: const UserPlaylistsRoute(), - icon: SpotubeIcons.library, - title: l10n.library, - ), - SideBarTiles( - id: "stats", - pathPrefix: "/stats", - route: const StatsRoute(), - icon: SpotubeIcons.chart, - title: l10n.stats, - ), - ]; diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart deleted file mode 100644 index 99d9ff74..00000000 --- a/lib/collections/spotube_icons.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'package:fluentui_system_icons/fluentui_system_icons.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:flutter_feather_icons/flutter_feather_icons.dart'; -import 'package:simple_icons/simple_icons.dart'; - -abstract class SpotubeIcons { - static const home = FluentIcons.home_12_regular; - static const search = FeatherIcons.search; - static const library = FluentIcons.library_16_regular; - static const music = FeatherIcons.music; - static const play = FluentIcons.play_12_regular; - static const pause = FeatherIcons.pause; - static const skipForward = FeatherIcons.skipForward; - static const skipBack = FeatherIcons.skipBack; - static const stop = FeatherIcons.square; - static const filter = FeatherIcons.filter; - static const refresh = FeatherIcons.refreshCw; - static const settings = FeatherIcons.settings; - static const shuffle = FeatherIcons.shuffle; - static const repeat = FluentIcons.arrow_repeat_all_16_regular; - static const repeatOne = Icons.repeat_one_rounded; - static const remove = FeatherIcons.minus; - static const removeFilled = FeatherIcons.minusCircle; - static const add = FeatherIcons.plus; - static const addFilled = FeatherIcons.plusSquare; - static const heart = FeatherIcons.heart; - static const heartFilled = Icons.favorite_rounded; - static const queue = Icons.queue_music_rounded; - static const queueAdd = Icons.add_to_photos_outlined; - static const queueRemove = Icons.remove_outlined; - static const download = FeatherIcons.download; - static const done = FeatherIcons.checkCircle; - static const alternativeRoute = Icons.alt_route_rounded; - static const sort = Icons.sort_rounded; - static const moreVertical = FeatherIcons.moreVertical; - static const moreHorizontal = FeatherIcons.moreHorizontal; - static const share = FeatherIcons.share2; - static const playlistAdd = Icons.playlist_add_rounded; - static const playlistRemove = Icons.playlist_remove_rounded; - static const playlist = Icons.playlist_play_rounded; - static const trash = FeatherIcons.trash2; - static const clock = FeatherIcons.clock; - static const lyrics = Icons.lyrics_rounded; - static const lyricsOff = Icons.lyrics_outlined; - static const noLyrics = Icons.music_off_outlined; - static const logout = FeatherIcons.logOut; - static const login = FeatherIcons.logIn; - static const dashboard = FeatherIcons.grid; - static const darkMode = FeatherIcons.moon; - static const platform = FeatherIcons.smartphone; - static const palette = Icons.palette_outlined; - static const colorBucket = Icons.format_color_fill_rounded; - static const album = FeatherIcons.disc; - static const artist = FeatherIcons.user; - static const audioQuality = Icons.multitrack_audio_rounded; - static const fastForward = FeatherIcons.fastForward; - static const angleRight = FeatherIcons.chevronRight; - static const angleLeft = FeatherIcons.chevronLeft; - static const angleDown = FeatherIcons.chevronDown; - static const shoppingBag = FeatherIcons.shoppingBag; - static const screenSearch = Icons.screen_search_desktop_outlined; - static const save = FeatherIcons.save; - static const barChart = FeatherIcons.barChart2; - static const folder = FeatherIcons.folder; - static const update = FeatherIcons.refreshCcw; - static const info = FeatherIcons.info; - static const userRemove = FeatherIcons.userX; - static const close = FeatherIcons.x; - static const minimize = FeatherIcons.chevronDown; - static const personalized = FeatherIcons.star; - static const genres = FeatherIcons.music; - static const zoomIn = FeatherIcons.zoomIn; - static const zoomOut = FeatherIcons.zoomOut; - static const tray = FeatherIcons.chevronDown; - static const miniPlayer = Icons.picture_in_picture_rounded; - static const maximize = FeatherIcons.maximize2; - static const pinOn = Icons.push_pin_rounded; - static const pinOff = Icons.push_pin_outlined; - static const hoverOn = Icons.back_hand_rounded; - static const hoverOff = Icons.back_hand_outlined; - static const dragHandle = Icons.drag_indicator; - static const lightning = Icons.flash_on_rounded; - static const lightningOutlined = FeatherIcons.zap; - static const colorSync = FeatherIcons.activity; - static const language = FeatherIcons.globe; - static const error = FeatherIcons.alertTriangle; - static const piped = FeatherIcons.cloud; - static const magic = Icons.auto_fix_high_outlined; - static const selectionCheck = Icons.checklist_rounded; - static const volumeHigh = FeatherIcons.volume2; - static const volumeMedium = FeatherIcons.volume1; - static const volumeLow = FeatherIcons.volume; - static const volumeMute = FeatherIcons.volumeX; - static const timer = FeatherIcons.clock; - static const logs = FeatherIcons.fileText; - static const clipboard = FeatherIcons.clipboard; - static const api = FeatherIcons.database; - static const skip = FeatherIcons.fastForward; - static const noWifi = FeatherIcons.wifiOff; - static const wifi = FeatherIcons.wifi; - static const window = Icons.window_rounded; - static const user = FeatherIcons.user; - static const edit = FeatherIcons.edit; - static const web = FeatherIcons.globe; - static const amoled = FeatherIcons.sunset; - static const file = FeatherIcons.file; - static const stream = Icons.stream_rounded; - static const lastFm = SimpleIcons.lastdotfm; - static const eye = FeatherIcons.eye; - static const noEye = FeatherIcons.eyeOff; - static const normalize = FeatherIcons.barChart2; - static const wikipedia = SimpleIcons.wikipedia; - static const discord = SimpleIcons.discord; - static const youtube = SimpleIcons.youtube; - static const radio = FeatherIcons.radio; - static const github = SimpleIcons.github; - static const openCollective = SimpleIcons.opencollective; - static const anonymous = FeatherIcons.user; - static const history = FeatherIcons.clock; - static const connect = FeatherIcons.link; - static const speaker = FeatherIcons.speaker; - static const monitor = FeatherIcons.monitor; - static const power = FeatherIcons.power; - static const bluetooth = FeatherIcons.bluetooth; - static const chart = FeatherIcons.barChart2; - static const folderAdd = FeatherIcons.folderPlus; - static const folderRemove = FeatherIcons.folderMinus; - static const cache = FeatherIcons.hardDrive; - static const export = Icons.file_open_outlined; - static const delete = FeatherIcons.trash2; - static const open = FeatherIcons.externalLink; - static const radioChecked = Icons.radio_button_on_rounded; - static const radioUnchecked = Icons.radio_button_off_rounded; - static const grid = FeatherIcons.grid; - static const list = FeatherIcons.list; - static const device = FeatherIcons.smartphone; - static const engine = FeatherIcons.server; - static const extensions = Icons.extension_rounded; - static const message = FeatherIcons.send; - static const upload = FeatherIcons.uploadCloud; - static const plugin = Icons.extension_outlined; - static const warning = FeatherIcons.alertTriangle; -} diff --git a/lib/components/adaptive/adaptive_list_tile.dart b/lib/components/adaptive/adaptive_list_tile.dart deleted file mode 100644 index c6d00bd4..00000000 --- a/lib/components/adaptive/adaptive_list_tile.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/ui/button_tile.dart'; -import 'package:spotube/extensions/constrains.dart'; - -class AdaptiveListTile extends HookWidget { - final Widget Function(BuildContext, StateSetter?)? trailing; - final Widget? title; - final Widget? subtitle; - final Widget? leading; - final void Function()? onTap; - final bool? breakOn; - - const AdaptiveListTile({ - super.key, - this.trailing, - this.onTap, - this.title, - this.subtitle, - this.leading, - this.breakOn, - }); - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - - return ButtonTile( - title: title, - subtitle: subtitle, - trailing: breakOn ?? mediaQuery.smAndDown - ? null - : trailing?.call(context, null), - leading: leading, - enabled: breakOn ?? mediaQuery.smAndDown, - onPressed: () { - onTap?.call(); - showDialog( - context: context, - barrierDismissible: true, - builder: (context) { - return StatefulBuilder(builder: (context, update) { - return AlertDialog( - title: title != null - ? Row( - crossAxisAlignment: CrossAxisAlignment.center, - spacing: 5, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (leading != null) leading!, - Flexible(child: title!), - ], - ) - : const SizedBox.shrink(), - content: Center(child: trailing?.call(context, update)), - ); - }); - }, - ); - }, - ); - } -} diff --git a/lib/components/adaptive/adaptive_pop_sheet_list.dart b/lib/components/adaptive/adaptive_pop_sheet_list.dart deleted file mode 100644 index 6eba1148..00000000 --- a/lib/components/adaptive/adaptive_pop_sheet_list.dart +++ /dev/null @@ -1,191 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/constrains.dart'; - -class AdaptiveMenuButton extends MenuButton { - final T? value; - const AdaptiveMenuButton({ - super.key, - this.value, - required super.child, - super.subMenu, - super.onPressed, - super.trailing, - super.leading, - super.enabled = true, - super.focusNode, - super.autoClose = true, - super.popoverController, - }) : assert( - value != null || onPressed != null, - 'Either value or onPressed must be provided', - ); -} - -/// An adaptive widget that shows a [PopupMenuButton] when screen size is above -/// or equal to 640px -/// In smaller screen, a [IconButton] with a [openDrawer] is shown -class AdaptivePopSheetList extends StatelessWidget { - final List> Function(BuildContext context) items; - final Widget? icon; - final Widget? child; - final bool useRootNavigator; - - final List? headings; - final String tooltip; - final ValueChanged? onSelected; - - final Offset offset; - - final AbstractButtonStyle variance; - - const AdaptivePopSheetList({ - super.key, - required this.items, - this.icon, - this.child, - this.useRootNavigator = true, - this.headings, - this.onSelected, - required this.tooltip, - this.offset = Offset.zero, - this.variance = ButtonVariance.ghost, - }) : assert( - !(icon != null && child != null), - 'Either icon or child must be provided', - ); - - Future showDropdownMenu(BuildContext context, Offset position) async { - final mediaQuery = MediaQuery.of(context); - List childrenModified(BuildContext context) => - items(context).map((s) { - if (s.onPressed == null) { - return MenuButton( - key: s.key, - autoClose: s.autoClose, - enabled: s.enabled, - leading: s.leading, - focusNode: s.focusNode, - onPressed: (context) { - if (s.value != null) { - onSelected?.call(s.value as T); - } - }, - popoverController: s.popoverController, - subMenu: s.subMenu, - trailing: s.trailing, - child: s.child, - ); - } - return s; - }).toList(); - - if (mediaQuery.mdAndUp) { - await showDropdown( - context: context, - rootOverlay: useRootNavigator, - // heightConstraint: PopoverConstraint.anchorFixedSize, - // constraints: BoxConstraints( - // maxHeight: mediaQuery.size.height * 0.6, - // ), - position: position, - builder: (context) { - return WidgetStatesProvider.boundary( - child: DropdownMenu( - children: childrenModified(context), - ), - ); - }, - ).future; - return; - } - - await openDrawer( - context: context, - draggable: true, - showDragHandle: true, - position: OverlayPosition.bottom, - borderRadius: context.theme.borderRadiusMd, - transformBackdrop: false, - builder: (context) { - final children = childrenModified(context); - return ListView.builder( - itemCount: children.length, - shrinkWrap: true, - itemBuilder: (context, index) { - final data = children[index]; - - return Button( - enabled: data.enabled, - style: ButtonVariance.ghost.copyWith( - padding: (context, state, value) => const EdgeInsets.all(16), - ), - onPressed: () { - data.onPressed?.call(context); - if (data.autoClose) { - closeDrawer(context); - } - }, - leading: data.leading, - trailing: data.trailing, - alignment: Alignment.centerLeft, - child: data.child, - ); - }, - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - - if (mediaQuery.mdAndUp) { - return Tooltip( - tooltip: TooltipContainer( - child: Text(tooltip), - ).call, - child: IconButton( - variance: variance, - icon: icon ?? const Icon(SpotubeIcons.moreVertical), - onPressed: () { - final renderBox = context.findRenderObject() as RenderBox; - final position = RelativeRect.fromRect( - Rect.fromPoints( - renderBox.localToGlobal(Offset.zero, - ancestor: context.findRenderObject()), - renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero), - ancestor: context.findRenderObject()), - ), - Offset.zero & mediaQuery.size, - ); - final offset = Offset(position.left, position.top); - showDropdownMenu(context, offset); - }, - ), - ); - } - - if (child != null) { - return Tooltip( - tooltip: TooltipContainer(child: Text(tooltip)).call, - child: Button( - onPressed: () => showDropdownMenu(context, Offset.zero), - style: variance, - child: IgnorePointer(child: child), - ), - ); - } - - return Tooltip( - tooltip: TooltipContainer(child: Text(tooltip)).call, - child: IconButton( - variance: variance, - icon: icon ?? const Icon(SpotubeIcons.moreVertical), - onPressed: () => showDropdownMenu(context, Offset.zero), - ), - ); - } -} diff --git a/lib/components/adaptive/adaptive_select_tile.dart b/lib/components/adaptive/adaptive_select_tile.dart deleted file mode 100644 index afa982af..00000000 --- a/lib/components/adaptive/adaptive_select_tile.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:flutter/material.dart' show ListTile, ListTileControlAffinity; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/constrains.dart'; - -class AdaptiveSelectTile extends HookWidget { - final Widget title; - final Widget? subtitle; - final Widget? secondary; - final List? trailing; - final ListTileControlAffinity? controlAffinity; - final T value; - final ValueChanged? onChanged; - - final List> options; - - /// Show the smaller value when the breakpoint is reached - /// - /// If false, the control will be hidden when the breakpoint is reached - /// - /// Defaults to `true` - final bool showValueWhenUnfolded; - - final bool? breakLayout; - - final BoxConstraints? popupConstraints; - final PopoverConstraint? popupWidthConstraint; - - const AdaptiveSelectTile({ - required this.title, - required this.value, - required this.onChanged, - required this.options, - this.controlAffinity = ListTileControlAffinity.trailing, - this.subtitle, - this.secondary, - this.trailing, - this.breakLayout, - this.showValueWhenUnfolded = true, - super.key, - this.popupConstraints, - this.popupWidthConstraint, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final mediaQuery = MediaQuery.sizeOf(context); - - Widget? control = Select( - itemBuilder: (context, item) { - return options.firstWhere((element) => element.value == item).child; - }, - value: value, - onChanged: onChanged, - popupConstraints: popupConstraints ?? const BoxConstraints(maxWidth: 200), - popupWidthConstraint: popupWidthConstraint ?? PopoverConstraint.flexible, - autoClosePopover: true, - popup: (context) { - return SelectPopup( - autoClose: true, - items: SelectItemBuilder( - childCount: options.length, - builder: (context, index) { - return options[index]; - }, - ), - ); - }, - ); - - if (mediaQuery.smAndDown) { - if (showValueWhenUnfolded) { - control = OutlineBadge( - child: options.firstWhere((element) => element.value == value).child, - ); - } else { - control = null; - } - } - - return ListTile( - title: title, - subtitle: subtitle, - leading: controlAffinity != ListTileControlAffinity.leading - ? secondary - : control, - trailing: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - spacing: 5, - children: [ - ...?trailing, - if (controlAffinity == ListTileControlAffinity.leading && - secondary != null) - secondary! - else if (controlAffinity == ListTileControlAffinity.trailing && - control != null) - control, - ], - ), - onTap: breakLayout ?? mediaQuery.mdAndUp - ? null - : () { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - content: Flexible( - child: ListView.builder( - shrinkWrap: true, - itemCount: options.length, - itemBuilder: (context, index) { - final item = options[index]; - - return ListTile( - iconColor: theme.colorScheme.primary, - leading: item.value == value - ? const Icon(SpotubeIcons.radioChecked) - : const Icon(SpotubeIcons.radioUnchecked), - title: item.child, - onTap: () { - onChanged?.call(item.value); - Navigator.of(context).pop(); - }, - ); - }, - ), - ), - ); - }, - ); - }, - ); - } -} diff --git a/lib/components/button/back_button.dart b/lib/components/button/back_button.dart deleted file mode 100644 index dc899616..00000000 --- a/lib/components/button/back_button.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; - -class BackButton extends StatelessWidget { - final Color? color; - final IconData icon; - const BackButton({ - super.key, - this.color, - this.icon = SpotubeIcons.angleLeft, - }); - - @override - Widget build(BuildContext context) { - return IconButton.ghost( - size: const ButtonSize(1.2), - icon: Icon(icon, color: color), - onPressed: () => Navigator.of(context).pop(), - ); - } -} diff --git a/lib/components/dialogs/confirm_download_dialog.dart b/lib/components/dialogs/confirm_download_dialog.dart deleted file mode 100644 index a2df0e9c..00000000 --- a/lib/components/dialogs/confirm_download_dialog.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; - -class ConfirmDownloadDialog extends StatelessWidget { - const ConfirmDownloadDialog({super.key}); - - @override - Widget build(BuildContext context) { - final screenSize = MediaQuery.sizeOf(context); - - return ConstrainedBox( - constraints: BoxConstraints(maxWidth: Breakpoints.sm), - child: AlertDialog( - title: Row( - spacing: 10, - children: [ - Text(context.l10n.are_you_sure), - const UniversalImage( - path: - "https://c.tenor.com/kHcmsxlKHEAAAAAM/rock-one-eyebrow-raised-rock-staring.gif", - height: 40, - width: 40, - ) - ], - ), - content: Expanded( - flex: screenSize.smAndUp ? 0 : 1, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.download_warning, - textAlign: TextAlign.justify, - ), - const SizedBox(height: 10), - Text( - context.l10n.download_ip_ban_warning, - style: const TextStyle( - color: Colors.red, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.justify, - ), - const SizedBox(height: 10), - Text( - context.l10n.by_clicking_accept_terms, - ), - const SizedBox(height: 10), - BulletPoint(context.l10n.download_agreement_1), - const SizedBox(height: 10), - BulletPoint(context.l10n.download_agreement_2), - const SizedBox(height: 10), - BulletPoint(context.l10n.download_agreement_3), - ], - ), - ), - ), - actions: [ - Button.outline( - child: Text(context.l10n.decline), - onPressed: () { - Navigator.pop(context, false); - }, - ), - Button.destructive( - onPressed: () => Navigator.of(context).pop(true), - child: Text(context.l10n.accept), - ), - ], - ), - ); - } -} - -class BulletPoint extends StatelessWidget { - final String text; - const BulletPoint(this.text, {super.key}); - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text("\u2022"), - const SizedBox(width: 5), - Flexible(child: Text(text)), - ], - ); - } -} diff --git a/lib/components/dialogs/link_open_permission_dialog.dart b/lib/components/dialogs/link_open_permission_dialog.dart deleted file mode 100644 index a7212d0a..00000000 --- a/lib/components/dialogs/link_open_permission_dialog.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -class LinkOpenPermissionDialog extends StatelessWidget { - final String? href; - const LinkOpenPermissionDialog({super.key, this.href}); - - @override - Widget build(BuildContext context) { - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 450), - child: AlertDialog( - title: Row( - spacing: 8, - children: [ - const Icon(SpotubeIcons.warning), - Text(context.l10n.open_link_in_browser), - ], - ), - content: Text.rich( - TextSpan( - children: [ - TextSpan( - text: - "${context.l10n.do_you_want_to_open_the_following_link}:\n", - ), - if (href != null) - TextSpan( - text: "$href\n\n", - style: const TextStyle(color: Colors.blue), - ), - TextSpan(text: context.l10n.unsafe_url_warning), - ], - ), - ), - actions: [ - Button.ghost( - onPressed: () => Navigator.of(context).pop(false), - child: Text(context.l10n.cancel), - ), - Button.ghost( - onPressed: () { - if (href != null) { - Clipboard.setData(ClipboardData(text: href!)); - } - Navigator.of(context).pop(false); - }, - child: Text(context.l10n.copy_link), - ), - Button.destructive( - onPressed: () { - if (href != null) { - launchUrlString( - href!, - mode: LaunchMode.externalApplication, - ); - } - Navigator.of(context).pop(true); - }, - child: Text(context.l10n.open), - ), - ], - ), - ); - } -} diff --git a/lib/components/dialogs/playlist_add_track_dialog.dart b/lib/components/dialogs/playlist_add_track_dialog.dart deleted file mode 100644 index 09d831ea..00000000 --- a/lib/components/dialogs/playlist_add_track_dialog.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/metadata_plugin/library/playlists.dart'; -import 'package:spotube/provider/metadata_plugin/core/user.dart'; - -class PlaylistAddTrackDialog extends HookConsumerWidget { - /// The id of the playlist this dialog was opened from - final String? openFromPlaylist; - final List tracks; - const PlaylistAddTrackDialog({ - required this.tracks, - required this.openFromPlaylist, - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final typography = Theme.of(context).typography; - final userPlaylists = ref.watch(metadataPluginSavedPlaylistsProvider); - final favoritePlaylistsNotifier = - ref.watch(metadataPluginSavedPlaylistsProvider.notifier); - - final me = ref.watch(metadataPluginUserProvider); - - final filteredPlaylists = useMemoized( - () => - userPlaylists.asData?.value.items - .where( - (playlist) => - playlist.owner.id == me.asData?.value?.id && - playlist.id != openFromPlaylist, - ) - .toList() ?? - [], - [userPlaylists.asData?.value, me.asData?.value?.id, openFromPlaylist], - ); - - final playlistsCheck = useState({}); - - useEffect(() { - if (userPlaylists.asData?.value != null) { - favoritePlaylistsNotifier.fetchAll(); - } - return null; - }, [userPlaylists.asData?.value]); - - Future onAdd() async { - final selectedPlaylists = playlistsCheck.value.entries - .where((entry) => entry.value) - .map((entry) => entry.key); - - await Future.wait( - selectedPlaylists.map( - (playlistId) => favoritePlaylistsNotifier.addTracks( - playlistId, - tracks.map((e) => e.id).toList(), - ), - ), - ).then((_) => context.mounted ? Navigator.pop(context, true) : null); - } - - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: AlertDialog( - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.l10n.add_to_playlist, - style: typography.large, - ), - const Spacer(), - const PlaylistCreateDialogButton(), - ], - ), - actions: [ - OutlineButton( - child: Text(context.l10n.cancel), - onPressed: () { - Navigator.pop(context, false); - }, - ), - PrimaryButton( - onPressed: onAdd, - child: Text(context.l10n.add), - ), - ], - content: SizedBox( - height: 300, - child: userPlaylists.isLoading - ? const Center(child: CircularProgressIndicator()) - : ListView.builder( - shrinkWrap: true, - itemCount: filteredPlaylists.length, - itemBuilder: (context, index) { - final playlist = filteredPlaylists.elementAt(index); - return Button.ghost( - style: ButtonVariance.ghost.copyWith( - padding: (context, _, __) { - return const EdgeInsets.symmetric(vertical: 8); - }, - ), - leading: Avatar( - initials: Avatar.getInitials(playlist.name), - provider: UniversalImage.imageProvider( - playlist.images.asUrlString( - placeholder: ImagePlaceholder.collection, - ), - ), - ), - trailing: Checkbox( - state: (playlistsCheck.value[playlist.id] ?? false) - ? CheckboxState.checked - : CheckboxState.unchecked, - onChanged: (val) { - playlistsCheck.value = { - ...playlistsCheck.value, - playlist.id: val == CheckboxState.checked, - }; - }, - ), - onPressed: () { - playlistsCheck.value = { - ...playlistsCheck.value, - playlist.id: - !(playlistsCheck.value[playlist.id] ?? false), - }; - }, - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text(playlist.name), - ), - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/components/dialogs/prompt_dialog.dart b/lib/components/dialogs/prompt_dialog.dart deleted file mode 100644 index 3498bf02..00000000 --- a/lib/components/dialogs/prompt_dialog.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/extensions/context.dart'; - -Future showPromptDialog({ - required BuildContext context, - required String title, - required String message, - String okText = "Ok", - String? cancelText = "Cancel", -}) async { - return showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text(title), - content: Text(message), - actions: [ - if (cancelText != null) - Button.outline( - onPressed: () => Navigator.of(context).pop(false), - child: Text( - cancelText == "Cancel" ? context.l10n.cancel : cancelText, - ), - ), - Button.primary( - child: Text(okText == "Ok" ? context.l10n.ok : okText), - onPressed: () => Navigator.of(context).pop(true), - ), - ], - ); - }, - ).then((value) => value ?? false); -} diff --git a/lib/components/dialogs/replace_downloaded_dialog.dart b/lib/components/dialogs/replace_downloaded_dialog.dart deleted file mode 100644 index 5b5b194e..00000000 --- a/lib/components/dialogs/replace_downloaded_dialog.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -final replaceDownloadedFileState = StateProvider((ref) => null); - -class ReplaceDownloadedDialog extends ConsumerWidget { - final SpotubeTrackObject track; - const ReplaceDownloadedDialog({required this.track, super.key}); - - @override - Widget build(BuildContext context, ref) { - final replaceAll = ref.watch(replaceDownloadedFileState); - - return AlertDialog( - title: Text(context.l10n.track_exists(track.name)), - content: RadioGroup( - value: replaceAll, - onChanged: (value) { - ref.read(replaceDownloadedFileState.notifier).state = value; - }, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.do_you_want_to_replace), - const Gap(16), - RadioItem( - value: true, - trailing: Text(context.l10n.replace_downloaded_tracks), - ), - const Gap(8), - RadioItem( - value: false, - trailing: Text(context.l10n.skip_download_tracks), - ), - ], - ), - ), - actions: [ - Button.outline( - onPressed: replaceAll == true - ? null - : () { - Navigator.pop(context, false); - }, - child: Text(context.l10n.skip), - ), - Button.primary( - onPressed: replaceAll == false - ? null - : () { - Navigator.pop(context, true); - }, - child: Text(context.l10n.replace), - ), - ], - ); - } -} diff --git a/lib/components/dialogs/select_device_dialog.dart b/lib/components/dialogs/select_device_dialog.dart deleted file mode 100644 index 5392a403..00000000 --- a/lib/components/dialogs/select_device_dialog.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/connect/clients.dart'; - -class SelectDeviceDialog extends HookConsumerWidget { - const SelectDeviceDialog({super.key}); - - @override - Widget build(BuildContext context, ref) { - final isRemoteService = useState(false); - - final connectClients = ref.watch(connectClientsProvider); - final remoteService = connectClients.asData!.value.resolvedService!; - - return AlertDialog( - title: Text(context.l10n.choose_the_device), - content: RadioGroup( - value: isRemoteService.value, - onChanged: (value) { - isRemoteService.value = value; - }, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.multiple_device_connected), - const Gap(16), - RadioItem( - trailing: Text(remoteService.name), - value: true, - ), - const Gap(8), - RadioItem( - trailing: Text(context.l10n.this_device), - value: false, - ), - ], - ), - ), - actions: [ - Button.primary( - onPressed: () { - Navigator.of(context).pop(isRemoteService.value); - }, - child: Text(context.l10n.select), - ), - ], - ); - } -} - -Future showSelectDeviceDialog( - BuildContext context, WidgetRef ref) async { - final connectClients = ref.read(connectClientsProvider); - - if (connectClients.asData?.value.resolvedService == null) { - return false; - } - - final isRemote = await showDialog( - context: context, - builder: (context) => const SelectDeviceDialog(), - ); - - return isRemote; -} diff --git a/lib/components/dialogs/track_details_dialog.dart b/lib/components/dialogs/track_details_dialog.dart deleted file mode 100644 index 9d35a6fb..00000000 --- a/lib/components/dialogs/track_details_dialog.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/links/artist_link.dart'; -import 'package:spotube/components/links/hyper_link.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/duration.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/server/sourced_track_provider.dart'; - -class TrackDetailsDialog extends HookConsumerWidget { - final SpotubeFullTrackObject track; - const TrackDetailsDialog({ - super.key, - required this.track, - }); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - final sourcedTrack = ref.read(sourcedTrackProvider(track)); - - final detailsMap = { - context.l10n.title: track.name, - context.l10n.artist: ArtistLink( - artists: track.artists, - mainAxisAlignment: WrapAlignment.start, - textStyle: const TextStyle(color: Colors.blue), - hideOverflowArtist: false, - ), - // context.l10n.album: LinkText( - // track.album!.name!, - // AlbumRoute(album: track.album!, id: track.album!.id!), - // overflow: TextOverflow.ellipsis, - // style: const TextStyle(color: Colors.blue), - // ), - context.l10n.duration: sourcedTrack.asData != null - ? sourcedTrack.asData!.value.info.duration.toHumanReadableString() - : Duration(milliseconds: track.durationMs).toHumanReadableString(), - if (track.album.releaseDate != null) - context.l10n.released: track.album.releaseDate, - }; - - final sourceInfo = sourcedTrack.asData?.value.info; - - final ytTracksDetailsMap = sourceInfo == null - ? {} - : { - context.l10n.youtube: Hyperlink( - "https://piped.video/watch?v=${sourceInfo.id}", - "https://piped.video/watch?v=${sourceInfo.id}", - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - context.l10n.channel: Text(sourceInfo.artists.join(", ")), - if (sourcedTrack.asData?.value.url != null) - context.l10n.streamUrl: Hyperlink( - sourcedTrack.asData!.value.url ?? "", - sourcedTrack.asData!.value.url ?? "", - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - }; - - return AlertDialog( - surfaceBlur: 0, - surfaceOpacity: 1, - title: Row( - spacing: 8, - children: [ - const Icon(SpotubeIcons.info), - Text( - context.l10n.details, - style: theme.typography.h4, - ), - ], - ), - content: SizedBox( - width: mediaQuery.mdAndUp ? double.infinity : 700, - child: Table( - columnWidths: const { - 0: FixedTableSize(95), - 1: FixedTableSize(10), - 2: FlexTableSize(), - }, - theme: const TableTheme( - backgroundColor: Colors.transparent, - cellTheme: TableCellTheme( - backgroundColor: WidgetStatePropertyAll(Colors.transparent), - ), - ), - rowHeights: const {0: FixedTableSize(40)}, - rows: [ - for (final entry in detailsMap.entries) - TableRow( - cells: [ - TableCell( - child: Text( - entry.key, - style: theme.typography.bold, - ), - ), - const TableCell( - child: Text(":"), - ), - TableCell( - child: entry.value is Widget - ? entry.value as Widget - : (entry.value is String) - ? Text( - entry.value as String, - style: theme.typography.normal, - ) - : const Text(""), - ), - ], - ), - for (final entry in ytTracksDetailsMap.entries) - TableRow( - cells: [ - TableCell( - child: Text( - entry.key, - style: theme.typography.bold, - ), - ), - const TableCell( - child: Text(":"), - ), - TableCell( - child: entry.value is Widget - ? entry.value as Widget - : Text( - entry.value, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.typography.normal, - ), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/components/expandable_search/expandable_search.dart b/lib/components/expandable_search/expandable_search.dart deleted file mode 100644 index 279a3e5f..00000000 --- a/lib/components/expandable_search/expandable_search.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/context.dart'; - -class ExpandableSearchField extends StatelessWidget { - final bool isFiltering; - final ValueChanged onChangeFiltering; - final TextEditingController searchController; - final FocusNode searchFocus; - - const ExpandableSearchField({ - super.key, - required this.isFiltering, - required this.onChangeFiltering, - required this.searchController, - required this.searchFocus, - }); - - @override - Widget build(BuildContext context) { - return AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: isFiltering ? 1 : 0, - child: AnimatedSize( - duration: const Duration(milliseconds: 200), - child: SizedBox( - height: isFiltering ? 50 : 0, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: CallbackShortcuts( - bindings: { - LogicalKeySet(LogicalKeyboardKey.escape): () { - onChangeFiltering(false); - searchController.clear(); - searchFocus.unfocus(); - } - }, - child: TextField( - focusNode: searchFocus, - controller: searchController, - placeholder: Text(context.l10n.search_tracks), - features: const [ - InputFeature.leading(Icon(SpotubeIcons.search)) - ], - ), - ), - ), - ), - ), - ); - } -} - -class ExpandableSearchButton extends StatelessWidget { - final bool isFiltering; - final FocusNode searchFocus; - final Widget icon; - final ValueChanged? onPressed; - - const ExpandableSearchButton({ - super.key, - required this.isFiltering, - required this.searchFocus, - this.icon = const Icon(SpotubeIcons.filter), - this.onPressed, - }); - - @override - Widget build(BuildContext context) { - return IconButton( - icon: icon, - variance: isFiltering ? ButtonVariance.secondary : ButtonVariance.outline, - onPressed: () { - if (isFiltering) { - searchFocus.requestFocus(); - } else { - searchFocus.unfocus(); - } - onPressed?.call(!isFiltering); - }, - ); - } -} diff --git a/lib/components/fallbacks/anonymous_fallback.dart b/lib/components/fallbacks/anonymous_fallback.dart deleted file mode 100644 index cb6028a7..00000000 --- a/lib/components/fallbacks/anonymous_fallback.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_undraw/flutter_undraw.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; - -import 'package:spotube/utils/platform.dart'; - -class AnonymousFallback extends ConsumerWidget { - final Widget? child; - const AnonymousFallback({ - super.key, - this.child, - }); - - @override - Widget build(BuildContext context, ref) { - final isLoggedIn = ref.watch(metadataPluginAuthenticatedProvider); - - if (isLoggedIn.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - - if (isLoggedIn.asData?.value == true && child != null) return child!; - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 10, - children: [ - Undraw( - illustration: kIsMobile - ? UndrawIllustration.accessDenied - : UndrawIllustration.secureLogin, - height: 200 * context.theme.scaling, - color: context.theme.colorScheme.primary, - ), - Text(context.l10n.not_logged_in), - Button.primary( - child: Text(context.l10n.login), - onPressed: () => context.navigateTo(const SettingsRoute()), - ) - ], - ), - ); - } -} diff --git a/lib/components/fallbacks/error_box.dart b/lib/components/fallbacks/error_box.dart deleted file mode 100644 index fd56cb58..00000000 --- a/lib/components/fallbacks/error_box.dart +++ /dev/null @@ -1,138 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/context.dart'; - -class ErrorBox extends StatelessWidget { - final Object error; - final VoidCallback? onRetry; - const ErrorBox({ - super.key, - required this.error, - this.onRetry, - }); - - @override - Widget build(BuildContext context) { - // Make a monospace error log view. Make sure it's only 4 lines - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - spacing: 12, - children: [ - Basic( - leading: const Icon(SpotubeIcons.error), - contentSpacing: 8, - title: Text(context.l10n.an_error_occurred), - ), - Card( - padding: const EdgeInsets.all(8.0), - filled: true, - fillColor: context.theme.colorScheme.muted, - child: Text( - error.toString(), - style: TextStyle( - // Use monospace - fontFamily: 'Ubuntu Mono', - color: context.theme.colorScheme.mutedForeground, - fontSize: 14, - ), - maxLines: 6, - overflow: TextOverflow.ellipsis, - ), - ), - // Show a dialog with full log and a retry button as well - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Button.text( - leading: const Icon(SpotubeIcons.logs), - onPressed: () { - showDialog( - context: context, - builder: (context) { - return ConstrainedBox( - constraints: BoxConstraints( - maxWidth: 480, - maxHeight: - MediaQuery.of(context).size.height * 0.8, - ), - child: AlertDialog( - padding: const EdgeInsets.all(12), - title: Row( - spacing: 8, - children: [ - const Icon(SpotubeIcons.logs), - Text(context.l10n.logs), - const Spacer(), - IconButton.ghost( - icon: const Icon(SpotubeIcons.close), - onPressed: () => context.maybePop(), - ) - ], - ), - actions: [ - HookBuilder(builder: (context) { - final copied = useState(false); - - return Button.ghost( - leading: copied.value - ? const Icon(SpotubeIcons.done) - : const Icon(SpotubeIcons.clipboard), - child: Text(context.l10n.copy_to_clipboard), - onPressed: () { - Clipboard.setData( - ClipboardData(text: error.toString()), - ); - copied.value = true; - }, - ); - }) - ], - content: SingleChildScrollView( - child: Card( - padding: const EdgeInsets.all(8.0), - filled: true, - fillColor: context.theme.colorScheme.muted, - child: SelectableText( - error.toString(), - style: TextStyle( - // Use monospace - fontFamily: 'Ubuntu Mono', - color: context - .theme.colorScheme.mutedForeground, - fontSize: 16, - ), - ), - ), - ), - ), - ); - }, - ); - }, - child: Text(context.l10n.view_logs), - ), - if (onRetry != null) - Button.text( - leading: const Icon(SpotubeIcons.refresh), - onPressed: onRetry, - child: Text(context.l10n.retry), - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/components/fallbacks/no_default_metadata_plugin.dart b/lib/components/fallbacks/no_default_metadata_plugin.dart deleted file mode 100644 index 1cabcdb1..00000000 --- a/lib/components/fallbacks/no_default_metadata_plugin.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter_undraw/flutter_undraw.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/context.dart'; - -class NoDefaultMetadataPlugin extends StatelessWidget { - const NoDefaultMetadataPlugin({super.key}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 10, - children: [ - Undraw( - height: 200 * context.theme.scaling, - illustration: UndrawIllustration.stars, - color: context.theme.colorScheme.primary, - ), - AutoSizeText( - context.l10n.no_default_metadata_provider_selected, - style: context.theme.typography.h4, - maxLines: 1, - ), - Button.primary( - leading: const Icon(SpotubeIcons.extensions), - child: Text(context.l10n.manage_metadata_providers), - onPressed: () { - context.pushRoute(const SettingsMetadataProviderRoute()); - }, - ), - ], - ), - ); - } -} diff --git a/lib/components/fallbacks/not_found.dart b/lib/components/fallbacks/not_found.dart deleted file mode 100644 index 9a994446..00000000 --- a/lib/components/fallbacks/not_found.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter_undraw/flutter_undraw.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/extensions/context.dart'; - -class NotFound extends StatelessWidget { - const NotFound({super.key}); - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Undraw( - illustration: UndrawIllustration.empty, - height: 200 * context.theme.scaling, - color: context.theme.colorScheme.primary, - ), - const Gap(10), - Text( - context.l10n.nothing_found, - textAlign: TextAlign.center, - ).muted().small() - ], - ); - } -} diff --git a/lib/components/form/checkbox_form_field.dart b/lib/components/form/checkbox_form_field.dart deleted file mode 100644 index 0e794833..00000000 --- a/lib/components/form/checkbox_form_field.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -class CheckboxFormBuilderField extends StatelessWidget { - final String name; - final FormFieldValidator? validator; - - final ValueChanged? onChanged; - final Widget? leading; - final Widget? trailing; - final bool tristate; - const CheckboxFormBuilderField({ - super.key, - required this.name, - this.validator, - this.onChanged, - this.leading, - this.trailing, - this.tristate = false, - }); - - @override - Widget build(BuildContext context) { - return FormBuilderField( - name: name, - validator: validator, - builder: (field) { - return Checkbox( - state: tristate && field.value == null - ? CheckboxState.indeterminate - : field.value == true - ? CheckboxState.checked - : CheckboxState.unchecked, - onChanged: (state) { - field.didChange(state == CheckboxState.checked); - onChanged?.call(state); - }, - leading: leading, - trailing: trailing, - tristate: tristate, - ); - }, - ); - } -} diff --git a/lib/components/form/text_form_field.dart b/lib/components/form/text_form_field.dart deleted file mode 100644 index dc92c257..00000000 --- a/lib/components/form/text_form_field.dart +++ /dev/null @@ -1,184 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; - -class TextFormBuilderField extends StatelessWidget { - final String name; - final FormFieldValidator? validator; - final Widget? label; - - final TextEditingController? controller; - final bool filled; - final Widget? placeholder; - // final AlignmentGeometry? placeholderAlignment; - // final AlignmentGeometry? leadingAlignment; - // final AlignmentGeometry? trailingAlignment; - final Border? border; - final List features; - final EdgeInsetsGeometry? padding; - final ValueChanged? onSubmitted; - final VoidCallback? onEditingComplete; - final FocusNode? focusNode; - final VoidCallback? onTap; - final bool enabled; - final bool readOnly; - final bool obscureText; - final String obscuringCharacter; - final String? initialValue; - final int? maxLength; - final MaxLengthEnforcement? maxLengthEnforcement; - final int? maxLines; - final int? minLines; - final BorderRadiusGeometry? borderRadius; - final TextAlign textAlign; - final bool expands; - final TextAlignVertical? textAlignVertical; - final UndoHistoryController? undoController; - final ValueChanged? onChanged; - final Iterable? autofillHints; - final void Function(PointerDownEvent event)? onTapOutside; - final List? inputFormatters; - final TextStyle? style; - // final EditableTextContextMenuBuilder? contextMenuBuilder; - // final bool useNativeContextMenu; - // final bool? isCollapsed; - final TextInputType? keyboardType; - final TextInputAction? textInputAction; - final Clip clipBehavior; - final bool autofocus; - final WidgetStatesController? statesController; - - const TextFormBuilderField({ - super.key, - required this.name, - this.label, - this.validator, - this.controller, - this.maxLength, - this.maxLengthEnforcement, - this.maxLines = 1, - this.minLines, - this.filled = false, - this.placeholder, - this.border, - this.padding, - this.onSubmitted, - this.onEditingComplete, - this.focusNode, - this.onTap, - this.enabled = true, - this.readOnly = false, - this.obscureText = false, - this.obscuringCharacter = '•', - this.initialValue, - this.borderRadius, - this.keyboardType, - this.textAlign = TextAlign.start, - this.expands = false, - this.textAlignVertical = TextAlignVertical.center, - this.autofillHints, - this.undoController, - this.onChanged, - this.onTapOutside, - this.inputFormatters, - this.style, - // this.contextMenuBuilder = TextField.defaultContextMenuBuilder, - // this.useNativeContextMenu = false, - // this.isCollapsed, - this.textInputAction, - this.clipBehavior = Clip.hardEdge, - this.autofocus = false, - // this.placeholderAlignment, - // this.leadingAlignment, - // this.trailingAlignment, - this.statesController, - this.features = const [], - }); - - @override - Widget build(BuildContext context) { - return FormBuilderField( - name: name, - validator: validator, - onChanged: (value) { - if (value == null) return; - onChanged?.call(value); - }, - builder: (field) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - spacing: 5, - children: [ - if (label != null) - DefaultTextStyle( - style: context.theme.typography.semiBold.copyWith( - color: field.hasError - ? context.theme.colorScheme.destructive - : context.theme.colorScheme.foreground, - ), - child: label!, - ), - TextField( - controller: controller, - maxLength: maxLength, - maxLengthEnforcement: maxLengthEnforcement, - maxLines: maxLines, - minLines: minLines, - filled: filled, - placeholder: placeholder, - border: border, - features: features, - padding: padding, - onSubmitted: (value) { - field.validate(); - field.save(); - onSubmitted?.call(value); - }, - onEditingComplete: () { - field.save(); - onEditingComplete?.call(); - }, - focusNode: focusNode, - onTap: onTap, - enabled: enabled, - readOnly: readOnly, - obscureText: obscureText, - obscuringCharacter: obscuringCharacter, - initialValue: field.value, - borderRadius: borderRadius, - textAlign: textAlign, - expands: expands, - textAlignVertical: textAlignVertical, - autofillHints: autofillHints, - undoController: undoController, - onChanged: (value) { - field.didChange(value); - }, - onTapOutside: onTapOutside, - inputFormatters: inputFormatters, - style: style, - // contextMenuBuilder: contextMenuBuilder, - // useNativeContextMenu: useNativeContextMenu, - // isCollapsed: isCollapsed, - keyboardType: keyboardType, - textInputAction: textInputAction, - clipBehavior: clipBehavior, - autofocus: autofocus, - // placeholderAlignment: placeholderAlignment, - // leadingAlignment: leadingAlignment, - // trailingAlignment: trailingAlignment, - statesController: statesController, - ), - if (field.hasError) - Text( - field.errorText ?? "", - style: TextStyle( - color: context.theme.colorScheme.destructive, - ), - ), - ], - ), - ); - } -} diff --git a/lib/components/framework/app_pop_scope.dart b/lib/components/framework/app_pop_scope.dart deleted file mode 100644 index fe923958..00000000 --- a/lib/components/framework/app_pop_scope.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'dart:io'; - -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -/// A temporary workaround for [WillPopScope] and [PopScope] not working in GoRouter -/// https://github.com/flutter/flutter/issues/140869#issuecomment-2247181468 -class AppPopScope extends StatefulWidget { - final Widget child; - - final PopInvokedCallback? onPopInvoked; - - final bool canPop; - - const AppPopScope({ - super.key, - required this.child, - this.canPop = true, - this.onPopInvoked, - }); - - @override - State createState() => _AppPopScopeState(); -} - -class _AppPopScopeState extends State { - final bool _enable = Platform.isAndroid; - ModalRoute? _route; - BackButtonDispatcher? _parentBackBtnDispatcher; - ChildBackButtonDispatcher? _backBtnDispatcher; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _route = ModalRoute.of(context); - _updateBackButtonDispatcher(); - } - - @override - void activate() { - super.activate(); - _updateBackButtonDispatcher(); - } - - @override - void deactivate() { - super.deactivate(); - _disposeBackBtnDispatcher(); - } - - @override - void dispose() { - _disposeBackBtnDispatcher(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return PopScope( - canPop: widget.canPop, - onPopInvoked: widget.onPopInvoked, - child: widget.child, - ); - } - - void _updateBackButtonDispatcher() { - if (!_enable) return; - - var dispatcher = Router.maybeOf(context)?.backButtonDispatcher; - if (dispatcher != _parentBackBtnDispatcher) { - _disposeBackBtnDispatcher(); - _parentBackBtnDispatcher = dispatcher; - if (dispatcher is BackButtonDispatcher && - dispatcher is! ChildBackButtonDispatcher) { - dispatcher = dispatcher.createChildBackButtonDispatcher(); - } - _backBtnDispatcher = dispatcher as ChildBackButtonDispatcher; - } - _backBtnDispatcher?.removeCallback(_handleBackButton); - _backBtnDispatcher?.addCallback(_handleBackButton); - _backBtnDispatcher?.takePriority(); - } - - void _disposeBackBtnDispatcher() { - _backBtnDispatcher?.removeCallback(_handleBackButton); - if (_backBtnDispatcher is ChildBackButtonDispatcher) { - final child = _backBtnDispatcher as ChildBackButtonDispatcher; - _parentBackBtnDispatcher?.forget(child); - } - _backBtnDispatcher = null; - _parentBackBtnDispatcher = null; - } - - bool get _onlyRoute => _route != null && _route!.isFirst && _route!.isCurrent; - - Future _handleBackButton() async { - if (_onlyRoute) { - widget.onPopInvoked?.call(widget.canPop); - if (!widget.canPop) { - return true; - } - } - return false; - } -} diff --git a/lib/components/heart_button/heart_button.dart b/lib/components/heart_button/heart_button.dart deleted file mode 100644 index 14a0572f..00000000 --- a/lib/components/heart_button/heart_button.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -import 'package:spotube/components/heart_button/use_track_toggle_like.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/library/tracks.dart'; -import 'package:spotube/provider/metadata_plugin/core/user.dart'; - -class HeartButton extends HookConsumerWidget { - final bool isLiked; - final void Function()? onPressed; - final IconData? icon; - final Color? color; - final String? tooltip; - final AbstractButtonStyle variance; - final ButtonSize size; - const HeartButton({ - required this.isLiked, - required this.onPressed, - this.color, - this.tooltip, - this.icon, - this.variance = ButtonVariance.ghost, - this.size = ButtonSize.normal, - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final authenticated = ref.watch(metadataPluginAuthenticatedProvider); - - if (authenticated.asData?.value != true) return const SizedBox.shrink(); - - return Tooltip( - tooltip: TooltipContainer(child: Text(tooltip ?? "")).call, - child: IconButton( - variance: variance, - size: size, - enabled: onPressed != null, - icon: AnimatedSwitcher( - switchInCurve: Curves.fastOutSlowIn, - switchOutCurve: Curves.fastOutSlowIn, - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) { - return ScaleTransition( - scale: animation, - child: child, - ); - }, - child: Icon( - icon ?? - (isLiked - ? Icons.favorite_rounded - : Icons.favorite_outline_rounded), - key: ValueKey(isLiked), - color: color ?? (isLiked ? color ?? Colors.red : null), - ), - ), - onPressed: onPressed, - ), - ); - } -} - -class TrackHeartButton extends HookConsumerWidget { - final SpotubeTrackObject track; - const TrackHeartButton({ - super.key, - required this.track, - }); - - @override - Widget build(BuildContext context, ref) { - final savedTracks = ref.watch(metadataPluginSavedTracksProvider); - final me = ref.watch(metadataPluginUserProvider); - final (:isLiked, :isLoading, :toggleTrackLike) = - useTrackToggleLike(track, ref); - - if (me.isLoading) { - return const CircularProgressIndicator(); - } - - return HeartButton( - tooltip: isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - isLiked: isLiked, - onPressed: savedTracks.asData?.value == null || isLoading - ? null - : () { - toggleTrackLike(track); - }, - ); - } -} diff --git a/lib/components/heart_button/use_track_toggle_like.dart b/lib/components/heart_button/use_track_toggle_like.dart deleted file mode 100644 index af961578..00000000 --- a/lib/components/heart_button/use_track_toggle_like.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/library/tracks.dart'; - -typedef UseTrackToggleLike = ({ - bool isLiked, - bool isLoading, - Future Function(SpotubeTrackObject track) toggleTrackLike, -}); - -UseTrackToggleLike useTrackToggleLike(SpotubeTrackObject track, WidgetRef ref) { - final savedTracksNotifier = - ref.watch(metadataPluginSavedTracksProvider.notifier); - - final isSavedTrack = ref.watch(metadataPluginIsSavedTrackProvider(track.id)); - - return ( - isLiked: isSavedTrack.asData?.value ?? false, - isLoading: isSavedTrack.isLoading, - toggleTrackLike: (track) async { - final isLikedTrack = await ref.read( - metadataPluginIsSavedTrackProvider(track.id).future, - ); - - if (isLikedTrack) { - await savedTracksNotifier.removeFavorite([track]); - } else { - await savedTracksNotifier.addFavorite([track]); - } - }, - ); -} diff --git a/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart deleted file mode 100644 index 3ac90a06..00000000 --- a/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/modules/album/album_card.dart'; -import 'package:spotube/modules/artist/artist_card.dart'; -import 'package:spotube/modules/playlist/playlist_card.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; - -class HorizontalPlaybuttonCardView extends HookWidget { - final Widget title; - final List items; - final Widget? error; - final VoidCallback onFetchMore; - final bool isLoadingNextPage; - final bool hasNextPage; - final Widget? titleTrailing; - - HorizontalPlaybuttonCardView({ - required this.title, - required this.items, - required this.hasNextPage, - required this.onFetchMore, - required this.isLoadingNextPage, - this.titleTrailing, - this.error, - super.key, - }) : assert( - items.every( - (item) => - item is SpotubeSimpleAlbumObject || - item is SpotubeSimplePlaylistObject || - item is SpotubeFullArtistObject, - ), - ); - - @override - Widget build(BuildContext context) { - final scrollController = useScrollController(); - final isArtist = items.every((s) => s is SpotubeFullArtistObject); - final scale = context.theme.scaling; - - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: DefaultTextStyle( - style: context.theme.typography.h4.copyWith( - color: context.theme.colorScheme.foreground, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - child: title, - ), - ), - if (titleTrailing != null) titleTrailing!, - ], - ), - if (error != null) - error! - else - SizedBox( - height: isArtist ? 250 : 225, - child: NotificationListener( - // disable multiple scrollbar to use this - onNotification: (notification) => true, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: PointerDeviceKind.values.toSet(), - ), - child: items.isEmpty - ? ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: 5, - itemBuilder: (context, index) { - return AlbumCard(FakeData.albumSimple); - }, - ) - : InfiniteList( - scrollController: scrollController, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(vertical: 8.0), - itemCount: items.length, - onFetchData: onFetchMore, - loadingBuilder: (context) => Skeletonizer( - enabled: true, - child: isArtist - ? ArtistCard(FakeData.artist) - : AlbumCard(FakeData.albumSimple), - ), - isLoading: isLoadingNextPage, - hasReachedMax: !hasNextPage, - separatorBuilder: (context, index) => Gap(12 * scale), - itemBuilder: (context, index) { - final item = items[index]; - - return switch (item) { - SpotubeSimplePlaylistObject() => PlaylistCard( - item as SpotubeSimplePlaylistObject), - SpotubeSimpleAlbumObject() => - AlbumCard(item as SpotubeSimpleAlbumObject), - SpotubeFullArtistObject() => - ArtistCard(item as SpotubeFullArtistObject), - _ => const SizedBox.shrink(), - }; - }), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/components/hover_builder.dart b/lib/components/hover_builder.dart deleted file mode 100644 index 7793e744..00000000 --- a/lib/components/hover_builder.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -class HoverBuilder extends HookWidget { - final bool? permanentState; - final Widget Function(BuildContext context, bool isHovering) builder; - const HoverBuilder({ - required this.builder, - this.permanentState, - super.key, - }); - - @override - Widget build(BuildContext context) { - final hovering = useState(false); - - if (permanentState != null) { - return builder(context, permanentState!); - } - - return MouseRegion( - onEnter: (_) { - if (!hovering.value) hovering.value = true; - }, - onExit: (_) { - if (hovering.value) hovering.value = false; - }, - child: builder(context, hovering.value), - ); - } -} diff --git a/lib/components/image/universal_image.dart b/lib/components/image/universal_image.dart deleted file mode 100644 index e157f96a..00000000 --- a/lib/components/image/universal_image.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/collections/assets.gen.dart'; - -class UniversalImage extends HookWidget { - final String path; - final double? height; - final double? width; - final double scale; - final String? placeholder; - final BoxFit? fit; - const UniversalImage({ - required this.path, - this.height, - this.width, - this.placeholder, - this.fit, - this.scale = 1, - super.key, - }); - - static ImageProvider imageProvider( - String path, { - final double? height, - final double? width, - final double scale = 1, - }) { - if (path.startsWith("http")) { - return CachedNetworkImageProvider( - path, - maxHeight: height?.toInt(), - maxWidth: width?.toInt(), - cacheKey: path, - scale: scale, - ); - } else if (path.startsWith("assets/")) { - return AssetImage(path); - } else if (Uri.tryParse(path) != null) { - return FileImage(File(path), scale: scale); - } - return MemoryImage(base64Decode(path), scale: scale); - } - - @override - Widget build(BuildContext context) { - if (path.startsWith("http")) { - return FadeInImage( - image: CachedNetworkImageProvider( - path, - maxHeight: height?.toInt(), - maxWidth: width?.toInt(), - cacheKey: path, - scale: scale, - ), - height: height, - width: width, - placeholder: AssetImage(placeholder ?? Assets.images.placeholder.path), - imageErrorBuilder: (context, error, stackTrace) { - return Image.asset( - placeholder ?? Assets.images.placeholder.path, - width: width, - height: height, - cacheHeight: height?.toInt(), - cacheWidth: width?.toInt(), - scale: scale, - ); - }, - fit: fit, - ); - } else if (Uri.tryParse(path) != null && !path.startsWith("assets")) { - return Image.file( - File(path), - width: width, - height: height, - cacheHeight: height?.toInt(), - cacheWidth: width?.toInt(), - scale: scale, - fit: fit, - errorBuilder: (context, error, stackTrace) { - return Image.asset( - placeholder ?? Assets.images.placeholder.path, - width: width, - height: height, - cacheHeight: height?.toInt(), - cacheWidth: width?.toInt(), - scale: scale, - ); - }, - ); - } else if (path.startsWith("assets")) { - return Image.asset( - path, - width: width, - height: height, - cacheHeight: height?.toInt(), - cacheWidth: width?.toInt(), - scale: scale, - fit: fit, - errorBuilder: (context, error, stackTrace) { - return Image.asset( - placeholder ?? Assets.images.placeholder.path, - width: width, - height: height, - cacheHeight: height?.toInt(), - cacheWidth: width?.toInt(), - scale: scale, - ); - }, - ); - } - - return Image.memory( - base64Decode(path), - width: width, - height: height, - cacheHeight: height?.toInt(), - cacheWidth: width?.toInt(), - scale: scale, - fit: fit, - errorBuilder: (context, error, stackTrace) { - return Image.asset( - placeholder ?? Assets.images.placeholder.path, - width: width, - height: height, - cacheHeight: height?.toInt(), - cacheWidth: width?.toInt(), - scale: scale, - ); - }, - ); - } -} diff --git a/lib/components/inter_scrollbar/inter_scrollbar.dart b/lib/components/inter_scrollbar/inter_scrollbar.dart deleted file mode 100644 index 415ba6da..00000000 --- a/lib/components/inter_scrollbar/inter_scrollbar.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:draggable_scrollbar/draggable_scrollbar.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/utils/platform.dart'; - -class InterScrollbar extends HookWidget { - final Widget child; - final ScrollController controller; - - const InterScrollbar({ - super.key, - required this.child, - required this.controller, - }); - - @override - Widget build(BuildContext context) { - if (kIsDesktop) return child; - - return DraggableScrollbar.semicircle( - controller: controller, - child: child, - ); - } -} diff --git a/lib/components/links/anchor_button.dart b/lib/components/links/anchor_button.dart deleted file mode 100644 index a0b3fa73..00000000 --- a/lib/components/links/anchor_button.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -class AnchorButton extends HookWidget { - final String text; - final TextStyle style; - final TextAlign? textAlign; - final TextOverflow? overflow; - final void Function()? onTap; - final int? maxLines; - - const AnchorButton( - this.text, { - super.key, - this.onTap, - this.textAlign, - this.overflow, - this.maxLines, - this.style = const TextStyle(), - }); - - @override - Widget build(BuildContext context) { - var hover = useState(false); - var tap = useState(false); - - return GestureDetector( - onTapDown: (event) => tap.value = true, - onTapUp: (event) => tap.value = false, - onTap: onTap, - child: MouseRegion( - cursor: WidgetStateMouseCursor.clickable, - child: Text( - text, - style: style.copyWith( - decoration: - hover.value || tap.value ? TextDecoration.underline : null, - ), - maxLines: maxLines, - textAlign: textAlign, - overflow: overflow, - ), - onEnter: (event) => hover.value = true, - onExit: (event) => hover.value = false, - ), - ); - } -} diff --git a/lib/components/links/artist_link.dart b/lib/components/links/artist_link.dart deleted file mode 100644 index dc093345..00000000 --- a/lib/components/links/artist_link.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/components/links/anchor_button.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -class ArtistLink extends StatelessWidget { - final List artists; - final WrapCrossAlignment crossAxisAlignment; - final WrapAlignment mainAxisAlignment; - final TextStyle textStyle; - final bool hideOverflowArtist; - final void Function(String route)? onRouteChange; - final VoidCallback? onOverflowArtistClick; - - const ArtistLink({ - super.key, - required this.artists, - this.crossAxisAlignment = WrapCrossAlignment.center, - this.mainAxisAlignment = WrapAlignment.center, - this.textStyle = const TextStyle(), - this.onRouteChange, - this.hideOverflowArtist = true, - this.onOverflowArtistClick, - }) : assert(hideOverflowArtist ? onOverflowArtistClick != null : true); - - @override - Widget build(BuildContext context) { - final ThemeData(:colorScheme) = Theme.of(context); - - return Wrap( - crossAxisAlignment: crossAxisAlignment, - alignment: mainAxisAlignment, - children: [ - ...(hideOverflowArtist ? artists.take(3).toList() : artists) - .asMap() - .entries - .map( - (artist) => Builder(builder: (context) { - return AnchorButton( - (artist.key != artists.length - 1) - ? "${artist.value.name}, " - : artist.value.name, - onTap: () { - if (onRouteChange != null) { - onRouteChange?.call("/artist/${artist.value.id}"); - } else { - context - .navigateTo(ArtistRoute(artistId: artist.value.id)); - } - }, - overflow: TextOverflow.ellipsis, - style: textStyle, - ); - }), - ), - if (hideOverflowArtist && artists.length > 3) - AnchorButton( - context.l10n.and_n_more(artists.length - 3), - onTap: () { - onOverflowArtistClick?.call(); - }, - overflow: TextOverflow.ellipsis, - style: textStyle.copyWith( - color: colorScheme.secondary, - decoration: TextDecoration.underline, - ), - ), - ], - ); - } -} diff --git a/lib/components/links/hyper_link.dart b/lib/components/links/hyper_link.dart deleted file mode 100644 index 647edaca..00000000 --- a/lib/components/links/hyper_link.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/links/anchor_button.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -class Hyperlink extends StatelessWidget { - final String text; - final TextStyle style; - final TextAlign? textAlign; - final TextOverflow? overflow; - final String url; - final int? maxLines; - - const Hyperlink( - this.text, - this.url, { - super.key, - this.textAlign, - this.overflow, - this.style = const TextStyle(), - this.maxLines, - }); - - @override - Widget build(BuildContext context) { - return AnchorButton( - text, - onTap: () async { - await launchUrlString( - url, - mode: LaunchMode.externalApplication, - ); - }, - key: key, - overflow: overflow, - maxLines: maxLines, - style: style.copyWith(color: Colors.blue), - textAlign: textAlign, - ); - } -} diff --git a/lib/components/links/link_text.dart b/lib/components/links/link_text.dart deleted file mode 100644 index c64ae93d..00000000 --- a/lib/components/links/link_text.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/links/anchor_button.dart'; - -class LinkText extends StatelessWidget { - final String text; - final TextStyle style; - final TextAlign? textAlign; - final TextOverflow? overflow; - final PageRouteInfo route; - final int? maxLines; - - final bool push; - const LinkText( - this.text, - this.route, { - super.key, - this.textAlign, - this.overflow, - this.style = const TextStyle(), - this.maxLines, - this.push = false, - }); - - @override - Widget build(BuildContext context) { - return AnchorButton( - text, - onTap: () { - if (push) { - context.navigateTo(route); - } else { - context.navigateTo(route); - } - }, - key: key, - overflow: overflow, - style: style, - textAlign: textAlign, - maxLines: maxLines, - ); - } -} diff --git a/lib/components/markdown/markdown.dart b/lib/components/markdown/markdown.dart deleted file mode 100644 index 1fd4ac5b..00000000 --- a/lib/components/markdown/markdown.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/dialogs/link_open_permission_dialog.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -class AppMarkdown extends StatelessWidget { - final String data; - const AppMarkdown({ - super.key, - required this.data, - }); - - @override - Widget build(BuildContext context) { - return MarkdownBody( - data: data, - imageBuilder: (uri, title, alt) { - final url = uri.toString(); - return CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - ); - }, - onTapLink: (text, href, title) async { - final allowOpeningLink = await showDialog( - context: context, - builder: (context) { - return LinkOpenPermissionDialog(href: href); - }, - ); - - if (href != null && allowOpeningLink == true) { - launchUrlString( - href, - mode: LaunchMode.externalApplication, - ); - } - }, - ); - } -} diff --git a/lib/components/playbutton_view/playbutton_card.dart b/lib/components/playbutton_view/playbutton_card.dart deleted file mode 100644 index ea28c738..00000000 --- a/lib/components/playbutton_view/playbutton_card.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/string.dart'; -import 'package:spotube/utils/platform.dart'; - -class PlaybuttonCard extends StatelessWidget { - final void Function()? onTap; - final void Function()? onPlaybuttonPressed; - final void Function()? onAddToQueuePressed; - final String? description; - - final String? imageUrl; - final Widget? image; - final bool isPlaying; - final bool isLoading; - final String title; - final bool isOwner; - - const PlaybuttonCard({ - required this.isPlaying, - required this.isLoading, - required this.title, - this.description, - this.onPlaybuttonPressed, - this.onAddToQueuePressed, - this.onTap, - this.isOwner = false, - this.imageUrl, - this.image, - super.key, - }) : assert( - imageUrl != null || image != null, - "imageUrl and image can't be null at the same time", - ); - - @override - Widget build(BuildContext context) { - final unescapeHtml = description?.unescapeHtml().cleanHtml() ?? ""; - final scale = context.theme.scaling; - - return SizedBox( - width: 150 * scale, - child: CardImage( - image: Stack( - children: [ - if (imageUrl != null) - Container( - width: 150 * scale, - height: 150 * scale, - decoration: BoxDecoration( - borderRadius: context.theme.borderRadiusMd, - image: DecorationImage( - image: UniversalImage.imageProvider( - imageUrl!, - height: 200 * scale, - width: 200 * scale, - ), - fit: BoxFit.cover, - ), - ), - ) - else - SizedBox( - width: 150 * scale, - height: 150 * scale, - child: ClipRRect( - borderRadius: context.theme.borderRadiusMd, - child: image!, - ), - ), - StatedWidget.builder( - builder: (context, states) { - return Positioned( - right: 8, - bottom: 8, - child: Column( - children: [ - AnimatedScale( - curve: Curves.easeOutBack, - duration: const Duration(milliseconds: 300), - scale: (states.contains(WidgetState.hovered) || - kIsMobile) && - !isLoading - ? 1 - : 0.7, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 300), - opacity: (states.contains(WidgetState.hovered) || - kIsMobile) && - !isLoading - ? 1 - : 0, - child: IconButton.secondary( - icon: const Icon(SpotubeIcons.queueAdd), - onPressed: onAddToQueuePressed, - size: ButtonSize.small, - ), - ), - ), - const Gap(5), - AnimatedScale( - curve: Curves.easeOutBack, - duration: const Duration(milliseconds: 150), - scale: states.contains(WidgetState.hovered) || - kIsMobile || - isPlaying || - isLoading - ? 1 - : 0.7, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: states.contains(WidgetState.hovered) || - kIsMobile || - isPlaying || - isLoading - ? 1 - : 0, - child: IconButton.secondary( - icon: switch ((isLoading, isPlaying)) { - (true, _) => const CircularProgressIndicator( - size: 15, - ), - (false, false) => const Icon(SpotubeIcons.play), - (false, true) => const Icon(SpotubeIcons.pause) - }, - enabled: !isLoading, - onPressed: onPlaybuttonPressed, - size: ButtonSize.small, - ), - ), - ), - ], - ), - ); - }, - ), - if (isOwner) - const Positioned( - right: 5, - top: 5, - child: SecondaryBadge( - style: ButtonStyle.secondaryIcon( - shape: ButtonShape.circle, - size: ButtonSize.small, - ), - child: Icon(SpotubeIcons.user), - ), - ), - ], - ), - title: Tooltip( - tooltip: TooltipContainer(child: Text(title)).call, - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - subtitle: Text( - unescapeHtml.isEmpty ? "\n" : unescapeHtml, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - onPressed: onTap, - ), - ); - } -} diff --git a/lib/components/playbutton_view/playbutton_tile.dart b/lib/components/playbutton_view/playbutton_tile.dart deleted file mode 100644 index 7470105d..00000000 --- a/lib/components/playbutton_view/playbutton_tile.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/string.dart'; - -class PlaybuttonTile extends StatelessWidget { - final void Function()? onTap; - final void Function()? onPlaybuttonPressed; - final void Function()? onAddToQueuePressed; - final String? description; - - final String? imageUrl; - final Widget? image; - final bool isPlaying; - final bool isLoading; - final String title; - final bool isOwner; - - const PlaybuttonTile({ - required this.isPlaying, - required this.isLoading, - required this.title, - this.description, - this.onPlaybuttonPressed, - this.onAddToQueuePressed, - this.onTap, - this.isOwner = false, - this.imageUrl, - this.image, - super.key, - }) : assert( - imageUrl != null || image != null, - "imageUrl and image can't be null at the same time", - ); - - @override - Widget build(BuildContext context) { - final cleanDescription = description?.unescapeHtml().cleanHtml() ?? ""; - final scale = context.theme.scaling; - - return Button( - leading: imageUrl != null - ? Container( - width: 50 * scale, - height: 50 * scale, - decoration: BoxDecoration( - borderRadius: context.theme.borderRadiusMd, - image: DecorationImage( - image: UniversalImage.imageProvider(imageUrl!), - fit: BoxFit.cover, - ), - ), - ) - : SizedBox( - width: 50 * scale, - height: 50 * scale, - child: ClipRRect( - borderRadius: context.theme.borderRadiusMd, - child: image, - ), - ), - style: ButtonVariance.ghost.copyWith( - padding: (context, states, value) { - return (ButtonVariance.ghost.padding(context, states) as EdgeInsets) - .copyWith(right: 0, left: 0); - }, - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Tooltip( - tooltip: TooltipContainer(child: Text(context.l10n.add_to_queue)).call, - child: IconButton.outline( - icon: const Icon(SpotubeIcons.queueAdd), - onPressed: onAddToQueuePressed, - enabled: !isLoading, - ), - ), - const Gap(8), - Tooltip( - tooltip: TooltipContainer(child: Text(context.l10n.play)).call, - child: IconButton.secondary( - icon: switch ((isLoading, isPlaying)) { - (true, _) => const CircularProgressIndicator( - size: 22, - ), - (false, false) => const Icon(SpotubeIcons.play), - (false, true) => const Icon(SpotubeIcons.pause) - }, - onPressed: onPlaybuttonPressed, - enabled: !isLoading, - ), - ), - ], - ), - enabled: !isLoading, - onPressed: onTap, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title), - if (cleanDescription.isNotEmpty) - Text( - description!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ).xSmall().muted(), - ], - ), - ); - } -} diff --git a/lib/components/playbutton_view/playbutton_view.dart b/lib/components/playbutton_view/playbutton_view.dart deleted file mode 100644 index 7880bb8c..00000000 --- a/lib/components/playbutton_view/playbutton_view.dart +++ /dev/null @@ -1,204 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_undraw/flutter_undraw.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/playbutton_view/playbutton_card.dart'; -import 'package:spotube/components/playbutton_view/playbutton_tile.dart'; -import 'package:spotube/components/waypoint.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; - -const _dummyPlaybuttonCard = PlaybuttonCard( - imageUrl: 'https://placehold.co/150x150.png', - isLoading: false, - isPlaying: false, - title: "Playbutton", - description: "A really cool playbutton", - isOwner: false, -); - -const _dummyPlaybuttonTile = PlaybuttonTile( - imageUrl: 'https://placehold.co/150x150.png', - isLoading: false, - isPlaying: false, - title: "Playbutton", - description: "A really cool playbutton", - isOwner: false, -); - -/// A [PlaybuttonCard] grid/list view (selectable) sliver widget -/// with support for infinite scrolling -class PlaybuttonView extends StatelessWidget { - final int itemCount; - final Widget Function(BuildContext context, int index) gridItemBuilder; - final Widget Function(BuildContext context, int index) listItemBuilder; - final bool hasMore; - final bool isLoading; - final VoidCallback onRequestMore; - final ScrollController controller; - - final Widget? leading; - - const PlaybuttonView({ - super.key, - required this.itemCount, - required this.gridItemBuilder, - required this.listItemBuilder, - required this.hasMore, - required this.isLoading, - required this.onRequestMore, - required this.controller, - this.leading, - }); - - @override - Widget build(BuildContext context) { - final scale = context.theme.scaling; - - return SliverLayoutBuilder( - builder: (context, constrains) => HookBuilder(builder: (context) { - final isGrid = useState(constrains.mdAndUp); - final hasUserInteracted = useRef(false); - - useEffect(() { - if (hasUserInteracted.value) return null; - if (isGrid.value != constrains.mdAndUp) { - isGrid.value = constrains.mdAndUp; - } - return null; - }, [constrains]); - - return SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (leading != null) leading!, - Toggle( - value: isGrid.value, - style: - const ButtonStyle.outline(density: ButtonDensity.icon), - onChanged: (value) { - isGrid.value = value; - hasUserInteracted.value = true; - }, - child: const Icon(SpotubeIcons.grid), - ), - const SizedBox(width: 8), - Toggle( - value: !isGrid.value, - style: - const ButtonStyle.outline(density: ButtonDensity.icon), - onChanged: (value) { - isGrid.value = !value; - hasUserInteracted.value = true; - }, - child: const Icon(SpotubeIcons.list), - ), - ], - ), - ), - const SliverGap(10), - // Toggle between grid and list view - switch ((isGrid.value, isLoading)) { - (true, _) => !isLoading && itemCount == 0 - ? SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 8), - sliver: SliverToBoxAdapter( - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 10, - children: [ - Undraw( - height: 200 * context.theme.scaling, - illustration: UndrawIllustration.taken, - color: Theme.of(context).colorScheme.primary, - ), - Text( - context.l10n.nothing_found, - textAlign: TextAlign.center, - ).muted().small() - ], - ), - ), - ) - : SliverGrid.builder( - itemCount: isLoading ? 6 : itemCount + 1, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 150 * scale, - mainAxisExtent: 225 * scale, - crossAxisSpacing: 12 * scale, - mainAxisSpacing: 12 * scale, - ), - itemBuilder: (context, index) { - if (isLoading) { - return const Skeletonizer( - enabled: true, - child: _dummyPlaybuttonCard, - ); - } - - if (index == itemCount) { - if (!hasMore) return const SizedBox.shrink(); - return Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: onRequestMore, - child: const Skeletonizer( - enabled: true, - child: _dummyPlaybuttonCard, - ), - ); - } - - return gridItemBuilder(context, index); - }, - ), - (false, true) => Skeletonizer.sliver( - enabled: true, - child: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => _dummyPlaybuttonTile, - childCount: 6, - ), - ), - ), - (false, false) => SliverInfiniteList( - itemCount: itemCount, - loadingBuilder: (context) => const Skeletonizer( - enabled: true, - child: _dummyPlaybuttonTile, - ), - itemBuilder: listItemBuilder, - onFetchData: onRequestMore, - hasReachedMax: !hasMore, - isLoading: isLoading, - emptyBuilder: (context) { - return Column( - mainAxisSize: MainAxisSize.min, - spacing: 10, - children: [ - Undraw( - height: 200 * context.theme.scaling, - illustration: UndrawIllustration.taken, - color: Theme.of(context).colorScheme.primary, - ), - Text( - context.l10n.nothing_found, - textAlign: TextAlign.center, - ).muted().small() - ], - ); - }, - ), - } - ], - ); - }), - ); - } -} diff --git a/lib/components/shimmers/shimmer_lyrics.dart b/lib/components/shimmers/shimmer_lyrics.dart deleted file mode 100644 index 9312865e..00000000 --- a/lib/components/shimmers/shimmer_lyrics.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -import 'package:skeletonizer/skeletonizer.dart'; - -class ShimmerLyrics extends HookWidget { - const ShimmerLyrics({super.key}); - - @override - Widget build(BuildContext context) { - return Skeletonizer( - enabled: true, - child: ListView.builder( - itemCount: 30, - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemBuilder: (context, index) { - final texts = [ - "Lorem ipsum", - "consectetur.", - "Sed", - "Sed non risus", - ]..shuffle(); - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (final text in texts) ...[ - Text(text), - if (text != texts.last) const Gap(10), - ], - ], - ); - }, - ), - ); - } -} diff --git a/lib/components/titlebar/titlebar.dart b/lib/components/titlebar/titlebar.dart deleted file mode 100644 index 778f0b09..00000000 --- a/lib/components/titlebar/titlebar.dart +++ /dev/null @@ -1,135 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/components/button/back_button.dart'; -import 'package:spotube/components/titlebar/titlebar_buttons.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:window_manager/window_manager.dart'; - -final kTitlebarVisible = kIsWindows || kIsLinux; - -class TitleBar extends HookConsumerWidget implements PreferredSizeWidget { - final bool automaticallyImplyLeading; - final List trailing; - final List leading; - final Widget? child; - final Widget? title; - final Widget? header; // small widget placed on top of title - final Widget? subtitle; // small widget placed below title - final bool - trailingExpanded; // expand the trailing instead of the main content - final AlignmentGeometry alignment; - final Color? backgroundColor; - final Color? foregroundColor; - final double? leadingGap; - final double? trailingGap; - final EdgeInsetsGeometry? padding; - final double? height; - final bool useSafeArea; - final double? surfaceBlur; - final double? surfaceOpacity; - - const TitleBar({ - super.key, - this.automaticallyImplyLeading = true, - this.trailing = const [], - this.leading = const [], - this.title, - this.header, - this.subtitle, - this.child, - this.trailingExpanded = false, - this.alignment = Alignment.center, - this.padding, - this.backgroundColor, - this.foregroundColor, - this.leadingGap, - this.trailingGap, - this.height, - this.surfaceBlur, - this.surfaceOpacity, - this.useSafeArea = false, - }); - - void onDrag(WidgetRef ref) { - final systemTitleBar = - ref.read(userPreferencesProvider.select((s) => s.systemTitleBar)); - if (kIsDesktop && !systemTitleBar) { - windowManager.startDragging(); - } - } - - @override - Widget build(BuildContext context, ref) { - final hasLeadingOrCanPop = leading.isNotEmpty || Navigator.canPop(context); - final lastClicked = useRef(DateTime.now().millisecondsSinceEpoch); - - return SizedBox( - height: height ?? (48 * context.theme.scaling), - child: LayoutBuilder( - builder: (context, constraints) { - final hasFullscreen = - MediaQuery.sizeOf(context).width == constraints.maxWidth; - - final canPop = leading.isEmpty && - automaticallyImplyLeading && - (Navigator.canPop(context) || context.watchRouter.canPop()); - - return GestureDetector( - onHorizontalDragStart: (_) => onDrag(ref), - onVerticalDragStart: (_) => onDrag(ref), - onTapDown: (details) async { - final systemTitlebar = ref.read( - userPreferencesProvider.select((s) => s.systemTitleBar)); - if (!kIsDesktop || systemTitlebar) return; - - int currMills = DateTime.now().millisecondsSinceEpoch; - - if ((currMills - lastClicked.value) < 500) { - if (await windowManager.isMaximized()) { - await windowManager.unmaximize(); - } else { - await windowManager.maximize(); - } - } else { - lastClicked.value = currMills; - } - }, - child: AppBar( - leading: canPop ? [const BackButton()] : leading, - trailing: [ - ...trailing, - Align( - alignment: Alignment.topRight, - child: - WindowTitleBarButtons(foregroundColor: foregroundColor), - ), - ], - title: title, - header: header, - subtitle: subtitle, - trailingExpanded: trailingExpanded, - alignment: alignment, - padding: padding ?? EdgeInsets.zero, - backgroundColor: backgroundColor, - leadingGap: leadingGap, - trailingGap: trailingGap, - height: height ?? (48 * context.theme.scaling), - surfaceBlur: surfaceBlur, - surfaceOpacity: surfaceOpacity, - useSafeArea: useSafeArea, - child: child, - ).withPadding( - left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0), - ); - }, - ), - ); - } - - @override - Size get preferredSize => Size.fromHeight(height ?? 48); -} diff --git a/lib/components/titlebar/titlebar_buttons.dart b/lib/components/titlebar/titlebar_buttons.dart deleted file mode 100644 index 30d88508..00000000 --- a/lib/components/titlebar/titlebar_buttons.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/components/hover_builder.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/components/titlebar/titlebar_icon_buttons.dart'; - -import 'package:spotube/hooks/configurators/use_window_listener.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:titlebar_buttons/titlebar_buttons.dart'; -import 'package:window_manager/window_manager.dart'; - -class WindowTitleBarButtons extends HookConsumerWidget { - final Color? foregroundColor; - const WindowTitleBarButtons({ - super.key, - this.foregroundColor, - }); - - @override - Widget build(BuildContext context, ref) { - final preferences = ref.watch(userPreferencesProvider); - final isMaximized = useState(null); - const type = ThemeType.auto; - - Future onClose() async { - await windowManager.close(); - } - - useWindowListener( - onWindowMaximize: () { - isMaximized.value = true; - }, - onWindowUnmaximize: () { - isMaximized.value = false; - }, - ); - - useEffect(() { - if (kIsDesktop) { - windowManager.isMaximized().then((value) { - isMaximized.value = value; - }); - } - return null; - }, []); - - if (!kTitlebarVisible || preferences.systemTitleBar) { - return const SizedBox.shrink(); - } - - if (kIsWindows) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadcnWindowButton( - icon: MinimizeIcon(color: context.theme.colorScheme.foreground), - onPressed: windowManager.minimize, - ), - if (isMaximized.value != true) - ShadcnWindowButton( - icon: MaximizeIcon(color: context.theme.colorScheme.foreground), - onPressed: () { - windowManager.maximize(); - isMaximized.value = true; - }, - ) - else - ShadcnWindowButton( - icon: RestoreIcon(color: context.theme.colorScheme.foreground), - onPressed: () { - windowManager.unmaximize(); - isMaximized.value = false; - }, - ), - HoverBuilder(builder: (context, isHovered) { - return ShadcnWindowButton( - icon: CloseIcon( - color: isHovered - ? Colors.white - : context.theme.colorScheme.foreground, - ), - onPressed: onClose, - hoverBackgroundColor: const Color(0xFFD32F2F), - ); - }), - ], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DecoratedMinimizeButton( - type: type, - onPressed: windowManager.minimize, - ), - DecoratedMaximizeButton( - type: type, - onPressed: () async { - if (await windowManager.isMaximized()) { - await windowManager.unmaximize(); - isMaximized.value = false; - } else { - await windowManager.maximize(); - isMaximized.value = true; - } - }, - ), - DecoratedCloseButton( - type: type, - onPressed: onClose, - ), - ], - ); - } -} diff --git a/lib/components/titlebar/titlebar_icon_buttons.dart b/lib/components/titlebar/titlebar_icon_buttons.dart deleted file mode 100644 index 0a3f6178..00000000 --- a/lib/components/titlebar/titlebar_icon_buttons.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'dart:math'; - -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -class ShadcnWindowButton extends StatelessWidget { - final Widget icon; - final VoidCallback onPressed; - final Color? hoverBackgroundColor; - - const ShadcnWindowButton({ - super.key, - required this.icon, - required this.onPressed, - this.hoverBackgroundColor, - }); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 45, - height: 32, - child: IconButton( - variance: ButtonVariance.ghost.copyWith( - decoration: (context, states, value) { - final decoration = ButtonVariance.ghost.decoration(context, states) - as BoxDecoration; - if (hoverBackgroundColor != null && - states.contains(WidgetState.hovered)) { - return decoration.copyWith( - borderRadius: BorderRadius.zero, - color: hoverBackgroundColor, - ); - } - - return decoration.copyWith( - borderRadius: BorderRadius.zero, - ); - }, - ), - icon: icon, - onPressed: onPressed, - ), - ); - } -} - -/// Close -class CloseIcon extends StatelessWidget { - final Color color; - const CloseIcon({super.key, required this.color}); - @override - Widget build(BuildContext context) => Align( - alignment: Alignment.topLeft, - child: Stack(children: [ - // Use rotated containers instead of a painter because it renders slightly crisper than a painter for some reason. - Transform.rotate( - angle: pi * .25, - child: - Center(child: Container(width: 14, height: 1, color: color))), - Transform.rotate( - angle: pi * -.25, - child: - Center(child: Container(width: 14, height: 1, color: color))), - ]), - ); -} - -/// Maximize -class MaximizeIcon extends StatelessWidget { - final Color color; - const MaximizeIcon({super.key, required this.color}); - @override - Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color)); -} - -class _MaximizePainter extends _IconPainter { - _MaximizePainter(super.color); - @override - void paint(Canvas canvas, Size size) { - Paint p = getPaint(color); - canvas.drawRect(Rect.fromLTRB(0, 0, size.width - 1, size.height - 1), p); - } -} - -/// Restore -class RestoreIcon extends StatelessWidget { - final Color color; - const RestoreIcon({ - super.key, - required this.color, - }); - @override - Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color)); -} - -class _RestorePainter extends _IconPainter { - _RestorePainter(super.color); - @override - void paint(Canvas canvas, Size size) { - Paint p = getPaint(color); - canvas.drawRect(Rect.fromLTRB(0, 2, size.width - 2, size.height), p); - canvas.drawLine(const Offset(2, 2), const Offset(2, 0), p); - canvas.drawLine(const Offset(2, 0), Offset(size.width, 0), p); - canvas.drawLine( - Offset(size.width, 0), Offset(size.width, size.height - 2), p); - canvas.drawLine(Offset(size.width, size.height - 2), - Offset(size.width - 2, size.height - 2), p); - } -} - -/// Minimize -class MinimizeIcon extends StatelessWidget { - final Color color; - const MinimizeIcon({super.key, required this.color}); - @override - Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color)); -} - -class _MinimizePainter extends _IconPainter { - _MinimizePainter(super.color); - @override - void paint(Canvas canvas, Size size) { - Paint p = getPaint(color); - canvas.drawLine( - Offset(0, size.height / 2), Offset(size.width, size.height / 2), p); - } -} - -/// Helpers -abstract class _IconPainter extends CustomPainter { - _IconPainter(this.color); - final Color color; - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} - -class _AlignedPaint extends StatelessWidget { - const _AlignedPaint(this.painter); - final CustomPainter painter; - - @override - Widget build(BuildContext context) { - return Align( - alignment: Alignment.center, - child: CustomPaint(size: const Size(10, 10), painter: painter), - ); - } -} - -Paint getPaint(Color color, [bool isAntiAlias = false]) => Paint() - ..color = color - ..style = PaintingStyle.stroke - ..isAntiAlias = isAntiAlias - ..strokeWidth = 1; diff --git a/lib/components/track_presentation/presentation_actions.dart b/lib/components/track_presentation/presentation_actions.dart deleted file mode 100644 index 61202a48..00000000 --- a/lib/components/track_presentation/presentation_actions.dart +++ /dev/null @@ -1,234 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; -import 'package:spotube/components/dialogs/confirm_download_dialog.dart'; -import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; -import 'package:spotube/components/track_presentation/presentation_props.dart'; -import 'package:spotube/components/track_presentation/presentation_state.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; - -ToastOverlay showToastForAction( - BuildContext context, - String action, - int count, -) { - final message = switch (action) { - "download" => (context.l10n.download_count(count), SpotubeIcons.download), - "add-to-playlist" => ( - context.l10n.add_count_to_playlist(count), - SpotubeIcons.playlistAdd - ), - "add-to-queue" => ( - context.l10n.add_count_to_queue(count), - SpotubeIcons.queueAdd - ), - "play-next" => ( - context.l10n.play_count_next(count), - SpotubeIcons.lightning - ), - _ => ("", SpotubeIcons.error), - }; - - return showToast( - context: context, - location: ToastLocation.topRight, - builder: (context, overlay) { - return SurfaceCard( - child: Basic( - leading: Icon(message.$2), - title: Text(message.$1), - leadingAlignment: Alignment.center, - trailing: IconButton.ghost( - size: ButtonSize.small, - icon: const Icon(SpotubeIcons.close), - onPressed: () { - overlay.close(); - }, - ), - ), - ); - }, - ); -} - -class TrackPresentationActionsSection extends HookConsumerWidget { - const TrackPresentationActionsSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final options = TrackPresentationOptions.of(context); - - ref.watch(downloadManagerProvider); - final downloader = ref.watch(downloadManagerProvider.notifier); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryActionsProvider); - - final state = ref.watch(presentationStateProvider(options.collection)); - final notifier = - ref.watch(presentationStateProvider(options.collection).notifier); - final selectedTracks = state.selectedTracks; - - Future actionDownloadTracks({ - required BuildContext context, - required List tracks, - required String action, - }) async { - final fullTrackObjects = - tracks.whereType().toList(); - final confirmed = await showDialog( - context: context, - builder: (context) { - return const ConfirmDownloadDialog(); - }, - ) ?? - false; - if (confirmed != true) return; - downloader.addAllToQueue(fullTrackObjects); - notifier.deselectAllTracks(); - if (!context.mounted) return; - showToastForAction(context, action, fullTrackObjects.length); - } - - return AdaptivePopSheetList( - tooltip: context.l10n.more_actions, - headings: [ - Text( - context.l10n.more_actions, - style: context.theme.typography.large, - ), - ], - onSelected: (action) async { - var tracks = selectedTracks; - - if (selectedTracks.isEmpty) { - tracks = await options.pagination.onFetchAll(); - - notifier.selectAllTracks(); - } - - if (!context.mounted) return; - - switch (action) { - case "download": - await actionDownloadTracks( - context: context, - tracks: tracks, - action: action, - ); - break; - case "add-to-playlist": - { - if (context.mounted) { - final worked = await showDialog( - context: context, - builder: (context) { - return PlaylistAddTrackDialog( - openFromPlaylist: options.collectionId, - tracks: tracks.toList(), - ); - }, - ); - - if (!context.mounted || worked != true) return; - showToastForAction(context, action, tracks.length); - } - break; - } - case "play-next": - { - playlistNotifier.addTracksAtFirst(tracks); - playlistNotifier.addCollection(options.collectionId); - if (options.collection is SpotubeSimpleAlbumObject) { - historyNotifier.addAlbums( - [options.collection as SpotubeSimpleAlbumObject]); - } else { - historyNotifier.addPlaylists( - [options.collection as SpotubeSimplePlaylistObject]); - } - notifier.deselectAllTracks(); - if (!context.mounted) return; - showToastForAction(context, action, tracks.length); - break; - } - case "add-to-queue": - { - playlistNotifier.addTracks(tracks); - playlistNotifier.addCollection(options.collectionId); - if (options.collection is SpotubeSimpleAlbumObject) { - historyNotifier.addAlbums( - [options.collection as SpotubeSimpleAlbumObject]); - } else { - historyNotifier.addPlaylists( - [options.collection as SpotubeSimplePlaylistObject]); - } - notifier.deselectAllTracks(); - if (!context.mounted) return; - showToastForAction(context, action, tracks.length); - break; - } - default: - } - - if (!context.mounted) return; - }, - icon: const Icon(SpotubeIcons.moreVertical), - variance: ButtonVariance.outline, - items: (context) => [ - AdaptiveMenuButton( - value: "download", - leading: const Icon(SpotubeIcons.download), - child: selectedTracks.isEmpty || - selectedTracks.length == options.tracks.length - ? Text( - context.l10n.download_all, - ) - : Text( - context.l10n.download_count(selectedTracks.length), - ), - ), - AdaptiveMenuButton( - value: "add-to-playlist", - leading: const Icon(SpotubeIcons.playlistAdd), - child: selectedTracks.isEmpty || - selectedTracks.length == options.tracks.length - ? Text( - context.l10n.add_all_to_playlist, - ) - : Text( - context.l10n.add_count_to_playlist(selectedTracks.length), - ), - ), - AdaptiveMenuButton( - value: "add-to-queue", - leading: const Icon(SpotubeIcons.queueAdd), - child: selectedTracks.isEmpty || - selectedTracks.length == options.tracks.length - ? Text( - context.l10n.add_all_to_queue, - ) - : Text( - context.l10n.add_count_to_queue(selectedTracks.length), - ), - ), - AdaptiveMenuButton( - value: "play-next", - leading: const Icon(SpotubeIcons.lightning), - child: selectedTracks.isEmpty || - selectedTracks.length == options.tracks.length - ? Text( - context.l10n.play_all_next, - ) - : Text( - context.l10n.play_count_next(selectedTracks.length), - ), - ), - ], - ); - } -} diff --git a/lib/components/track_presentation/presentation_list.dart b/lib/components/track_presentation/presentation_list.dart deleted file mode 100644 index 19772c7c..00000000 --- a/lib/components/track_presentation/presentation_list.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_undraw/flutter_undraw.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/components/fallbacks/error_box.dart'; -import 'package:spotube/components/track_presentation/presentation_props.dart'; -import 'package:spotube/components/track_presentation/presentation_state.dart'; -import 'package:spotube/components/track_presentation/use_track_tile_play_callback.dart'; -import 'package:spotube/components/track_tile/track_tile.dart'; -import 'package:spotube/components/track_presentation/use_is_user_playlist.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; - -class PresentationListSection extends HookConsumerWidget { - const PresentationListSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final options = TrackPresentationOptions.of(context); - final playlist = ref.watch(audioPlayerProvider); - final state = ref.watch(presentationStateProvider(options.collection)); - final notifier = - ref.read(presentationStateProvider(options.collection).notifier); - final isUserPlaylist = useIsUserPlaylist(ref, options.collectionId); - - final onTileTap = useTrackTilePlayCallback(ref); - - if (state.presentationTracks.isEmpty && !options.pagination.isLoading) { - if (options.error != null) { - return SliverToBoxAdapter( - child: Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: ErrorBox( - error: options.error!, - onRetry: options.pagination.onRefresh, - ), - ), - ), - ); - } - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Undraw( - illustration: UndrawIllustration.dreamer, - color: context.theme.colorScheme.primary, - height: 200 * context.theme.scaling, - ), - Text( - isUserPlaylist - ? context.l10n.no_tracks_added_yet - : context.l10n.no_tracks, - textAlign: TextAlign.center, - ).muted().small(), - ], - ), - ), - ); - } - - return SliverInfiniteList( - isLoading: options.pagination.isLoading, - onFetchData: options.pagination.onFetchMore, - itemCount: state.presentationTracks.length, - hasReachedMax: !options.pagination.hasNextPage, - loadingBuilder: (context) { - return Skeletonizer( - enabled: true, - child: TrackTile( - index: 0, - playlist: playlist, - track: FakeData.track, - ), - ); - }, - emptyBuilder: (context) => Skeletonizer( - enabled: true, - child: Column( - children: List.generate( - 10, - (index) => TrackTile( - track: FakeData.track, - index: index, - playlist: playlist, - ), - ), - ), - ), - itemBuilder: (context, index) => HookBuilder(builder: (context) { - final track = state.presentationTracks[index]; - final isSelected = useMemoized( - () => state.selectedTracks.any((e) => e.id == track.id), - [track.id, state.selectedTracks], - ); - return TrackTile( - userPlaylist: isUserPlaylist, - playlistId: options.collectionId, - index: index, - playlist: playlist, - track: track, - selected: isSelected, - onTap: () => onTileTap(track, index), - onChanged: state.selectedTracks.isEmpty - ? null - : (isSelected) { - if (isSelected == true) { - notifier.selectTrack(track); - } else { - notifier.deselectTrack(track); - } - }, - onLongPress: () { - notifier.selectTrack(track); - HapticFeedback.selectionClick(); - }, - ); - }), - ); - } -} diff --git a/lib/components/track_presentation/presentation_modifiers.dart b/lib/components/track_presentation/presentation_modifiers.dart deleted file mode 100644 index 42c3cb4f..00000000 --- a/lib/components/track_presentation/presentation_modifiers.dart +++ /dev/null @@ -1,131 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/track_presentation/sort_tracks_dropdown.dart'; -import 'package:spotube/components/track_presentation/presentation_actions.dart'; -import 'package:spotube/components/track_presentation/presentation_props.dart'; -import 'package:spotube/components/track_presentation/presentation_state.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; - -class TrackPresentationModifiersSection extends HookConsumerWidget { - final FocusNode? focusNode; - const TrackPresentationModifiersSection({ - super.key, - this.focusNode, - }); - - @override - Widget build(BuildContext context, ref) { - final options = TrackPresentationOptions.of(context); - final state = ref.watch(presentationStateProvider(options.collection)); - final notifier = ref.watch( - presentationStateProvider(options.collection).notifier, - ); - - final controller = useShadcnTextEditingController(); - final scale = context.theme.scaling; - - return LayoutBuilder(builder: (context, constrains) { - return Padding( - padding: EdgeInsets.symmetric( - horizontal: (constrains.mdAndUp ? 16 : 8) * scale, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Checkbox( - state: state.selectedTracks.length == options.tracks.length - ? CheckboxState.checked - : CheckboxState.unchecked, - onChanged: (value) { - if (value == CheckboxState.checked) { - notifier.selectAllTracks(); - } else { - notifier.deselectAllTracks(); - } - }, - ), - ], - ), - Flexible( - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - Flexible( - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: 320 * scale, - maxHeight: 38 * scale, - ), - child: TextField( - controller: controller, - focusNode: focusNode, - placeholder: Text(context.l10n.search_tracks), - onChanged: (value) { - if (value.isEmpty) { - notifier.clearFilter(); - } else { - notifier.filterTracks(value); - } - }, - features: [ - InputFeature.leading( - Icon( - SpotubeIcons.search, - color: context.theme.colorScheme.mutedForeground, - ), - ), - InputFeature.trailing( - ListenableBuilder( - listenable: controller, - builder: (context, _) { - return AnimatedCrossFade( - duration: const Duration(milliseconds: 300), - crossFadeState: controller.text.isEmpty - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: - const SizedBox.square(dimension: 20), - secondChild: AnimatedScale( - duration: - const Duration(milliseconds: 300), - scale: controller.text.isEmpty ? 0 : 1, - child: IconButton.ghost( - size: const ButtonSize(.6), - icon: const Icon(SpotubeIcons.close), - onPressed: () { - controller.clear(); - notifier.clearFilter(); - }, - ), - ), - ); - }), - ) - ], - ), - ), - ), - SortTracksDropdown( - value: state.sortBy, - onChanged: (value) { - notifier.sortTracks(value); - }, - ), - const TrackPresentationActionsSection(), - ], - ), - ), - ], - ), - ); - }); - } -} diff --git a/lib/components/track_presentation/presentation_props.dart b/lib/components/track_presentation/presentation_props.dart deleted file mode 100644 index 1992487f..00000000 --- a/lib/components/track_presentation/presentation_props.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'dart:async'; - -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -class PaginationProps { - final bool hasNextPage; - final bool isLoading; - final VoidCallback onFetchMore; - final Future Function() onRefresh; - final Future> Function() onFetchAll; - - const PaginationProps({ - required this.hasNextPage, - required this.isLoading, - required this.onFetchMore, - required this.onFetchAll, - required this.onRefresh, - }); - - @override - operator ==(Object other) { - return other is PaginationProps && - other.hasNextPage == hasNextPage && - other.isLoading == isLoading && - other.onFetchMore == onFetchMore && - other.onFetchAll == onFetchAll && - other.onRefresh == onRefresh; - } - - @override - int get hashCode => - super.hashCode ^ - hasNextPage.hashCode ^ - isLoading.hashCode ^ - onFetchMore.hashCode ^ - onFetchAll.hashCode ^ - onRefresh.hashCode; -} - -class TrackPresentationOptions { - final Object collection; - final String title; - final String? description; - final String? owner; - final String? ownerImage; - final String image; - final String routePath; - final List tracks; - final PaginationProps pagination; - final bool isLiked; - final String? shareUrl; - final Object? error; - - // events - final FutureOr Function()? onHeart; // if null heart button will hidden - - const TrackPresentationOptions({ - required this.collection, - required this.title, - this.description, - this.owner, - this.ownerImage, - required this.image, - required this.tracks, - required this.pagination, - required this.routePath, - this.shareUrl, - this.isLiked = false, - this.onHeart, - this.error, - }) : assert(collection is SpotubeSimpleAlbumObject || - collection is SpotubeSimplePlaylistObject); - - String get collectionId => collection is SpotubeSimpleAlbumObject - ? (collection as SpotubeSimpleAlbumObject).id - : (collection as SpotubeSimplePlaylistObject).id; - - static TrackPresentationOptions of(BuildContext context) { - return Data.of(context); - } - - @override - operator ==(Object other) { - return other is TrackPresentationOptions && - other.collection == collection && - other.title == title && - other.description == description && - other.image == image && - other.routePath == routePath && - other.tracks == tracks && - other.pagination == pagination && - other.isLiked == isLiked && - other.shareUrl == shareUrl && - other.onHeart == onHeart && - other.error == error; - } - - @override - int get hashCode => - super.hashCode ^ - collection.hashCode ^ - title.hashCode ^ - description.hashCode ^ - image.hashCode ^ - routePath.hashCode ^ - tracks.hashCode ^ - pagination.hashCode ^ - isLiked.hashCode ^ - shareUrl.hashCode ^ - onHeart.hashCode ^ - error.hashCode; -} diff --git a/lib/components/track_presentation/presentation_state.dart b/lib/components/track_presentation/presentation_state.dart deleted file mode 100644 index 32b7353a..00000000 --- a/lib/components/track_presentation/presentation_state.dart +++ /dev/null @@ -1,180 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart'; -import 'package:spotube/provider/metadata_plugin/library/tracks.dart'; -import 'package:spotube/provider/metadata_plugin/tracks/album.dart'; -import 'package:spotube/provider/metadata_plugin/tracks/playlist.dart'; -import 'package:spotube/utils/service_utils.dart'; - -class PresentationState { - final List selectedTracks; - final List presentationTracks; - final SortBy sortBy; - - const PresentationState({ - required this.selectedTracks, - required this.presentationTracks, - required this.sortBy, - }); - - PresentationState copyWith({ - List? selectedTracks, - List? presentationTracks, - SortBy? sortBy, - }) { - return PresentationState( - selectedTracks: selectedTracks ?? this.selectedTracks, - presentationTracks: presentationTracks ?? this.presentationTracks, - sortBy: sortBy ?? this.sortBy, - ); - } -} - -class PresentationStateNotifier - extends AutoDisposeFamilyNotifier { - @override - PresentationState build(collection) { - if (arg case SpotubeSimplePlaylistObject() || SpotubeSimpleAlbumObject()) { - if (isSavedTrackPlaylist) { - ref.listen( - metadataPluginSavedTracksProvider, - (previous, next) { - next.whenData((value) { - state = state.copyWith( - presentationTracks: ServiceUtils.sortTracks( - value.items, - state.sortBy, - ), - ); - }); - }, - ); - } else { - ref.listen( - arg is SpotubeSimplePlaylistObject - ? metadataPluginPlaylistTracksProvider( - (arg as SpotubeSimplePlaylistObject).id) - : metadataPluginAlbumTracksProvider( - (arg as SpotubeSimpleAlbumObject).id), - (previous, next) { - next.whenData((value) { - state = state.copyWith( - presentationTracks: ServiceUtils.sortTracks( - value.items, - state.sortBy, - ), - ); - }); - }, - ); - } - } - - return PresentationState( - selectedTracks: [], - presentationTracks: tracks, - sortBy: SortBy.none, - ); - } - - bool get isSavedTrackPlaylist => - arg is SpotubeSimplePlaylistObject && - (arg as SpotubeSimplePlaylistObject).id == "user-liked-tracks"; - - List get tracks { - assert( - arg is SpotubeSimplePlaylistObject || arg is SpotubeSimpleAlbumObject, - "arg must be SpotubeSimplePlaylistObject or SpotubeSimpleAlbumObject", - ); - - final isPlaylist = arg is SpotubeSimplePlaylistObject; - - final tracks = switch ((isPlaylist, isSavedTrackPlaylist)) { - (true, true) => - ref.read(metadataPluginSavedTracksProvider).asData?.value.items, - (true, false) => ref - .read(metadataPluginPlaylistTracksProvider( - (arg as SpotubeSimplePlaylistObject).id)) - .asData - ?.value - .items, - _ => ref - .read(metadataPluginAlbumTracksProvider( - (arg as SpotubeSimpleAlbumObject).id)) - .asData - ?.value - .items, - } ?? - []; - - return tracks; - } - - void selectTrack(SpotubeTrackObject track) { - if (state.selectedTracks.any((e) => e.id == track.id)) { - return; - } - - state = state.copyWith( - selectedTracks: [...state.selectedTracks, track], - ); - } - - void selectAllTracks() { - state = state.copyWith( - selectedTracks: tracks, - ); - } - - void deselectTrack(SpotubeTrackObject track) { - state = state.copyWith( - selectedTracks: state.selectedTracks.where((e) => e != track).toList(), - ); - } - - void deselectAllTracks() { - state = state.copyWith( - selectedTracks: [], - ); - } - - void filterTracks(String query) { - if (query.isEmpty) { - return; - } - - state = state.copyWith( - presentationTracks: ServiceUtils.sortTracks( - tracks - .map((e) => (weightedRatio(e.name, query), e)) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(), - state.sortBy, - ), - ); - } - - void clearFilter() { - state = state.copyWith( - presentationTracks: ServiceUtils.sortTracks(tracks, state.sortBy), - ); - } - - void sortTracks(SortBy sortBy) { - state = state.copyWith( - presentationTracks: sortBy == SortBy.none - ? tracks - : ServiceUtils.sortTracks(state.presentationTracks, sortBy), - sortBy: sortBy, - ); - } -} - -final presentationStateProvider = AutoDisposeNotifierProviderFamily< - PresentationStateNotifier, PresentationState, Object>( - () => PresentationStateNotifier(), -); diff --git a/lib/components/track_presentation/presentation_top.dart b/lib/components/track_presentation/presentation_top.dart deleted file mode 100644 index d2576cc0..00000000 --- a/lib/components/track_presentation/presentation_top.dart +++ /dev/null @@ -1,261 +0,0 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/services.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/heart_button/heart_button.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/track_presentation/presentation_props.dart'; -import 'package:spotube/components/track_presentation/use_action_callbacks.dart'; -import 'package:spotube/components/track_presentation/use_is_user_playlist.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; - -class TrackPresentationTopSection extends HookConsumerWidget { - const TrackPresentationTopSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.sizeOf(context); - final options = TrackPresentationOptions.of(context); - final scale = context.theme.scaling; - final isUserPlaylist = useIsUserPlaylist(ref, options.collectionId); - - final decorationImage = DecorationImage( - image: UniversalImage.imageProvider(options.image), - fit: BoxFit.cover, - ); - - final imageDimension = mediaQuery.mdAndUp ? 200 : 120; - - final (:isLoading, :isActive, :onPlay, :onShuffle, :onAddToQueue) = - useActionCallbacks(ref); - - final playbackActions = Row( - spacing: 8 * scale, - children: [ - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.shuffle_playlist), - ).call, - child: IconButton.secondary( - icon: isLoading - ? const Center( - child: - CircularProgressIndicator(onSurface: false, size: 20), - ) - : const Icon(SpotubeIcons.shuffle), - enabled: !isLoading && !isActive, - onPressed: onShuffle, - ), - ), - if (mediaQuery.width <= 320) - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.add_to_queue), - ).call, - child: IconButton.secondary( - icon: const Icon(SpotubeIcons.queueAdd), - enabled: !isLoading && !isActive, - onPressed: onAddToQueue, - ), - ) - else - Button.secondary( - leading: const Icon(SpotubeIcons.add), - enabled: !isLoading && !isActive, - onPressed: onAddToQueue, - child: Text(context.l10n.queue), - ), - Button.primary( - alignment: Alignment.center, - leading: switch ((isActive, isLoading)) { - (true, false) => const Icon(SpotubeIcons.pause), - (false, true) => const Center( - child: CircularProgressIndicator(onSurface: true, size: 18), - ), - _ => const Icon(SpotubeIcons.play), - }, - onPressed: onPlay, - enabled: !isLoading && !isActive, - child: isActive ? Text(context.l10n.pause) : Text(context.l10n.play), - ), - ], - ); - - final additionalActions = Row( - spacing: 8 * scale, - children: [ - if (isUserPlaylist) - IconButton.outline( - size: ButtonSize.small, - icon: const Icon(SpotubeIcons.edit), - onPressed: () { - showDialog( - context: context, - builder: (context) { - return PlaylistCreateDialog( - playlistId: options.collectionId, - trackIds: options.tracks.map((e) => e.id).toList(), - ); - }, - ); - }, - ), - if (options.shareUrl != null) - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.share), - ).call, - child: IconButton.outline( - icon: const Icon(SpotubeIcons.share), - size: ButtonSize.small, - onPressed: () async { - await Clipboard.setData( - ClipboardData(text: options.shareUrl!), - ); - - if (!context.mounted) return; - - showToast( - context: context, - location: ToastLocation.topRight, - builder: (context, overlay) { - return SurfaceCard( - child: Text( - context.l10n - .copied_shareurl_to_clipboard(options.shareUrl!), - ).small(), - ); - }, - ); - }, - ), - ), - if (options.onHeart != null) - HeartButton( - isLiked: options.isLiked, - tooltip: options.isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - variance: ButtonVariance.outline, - size: ButtonSize.small, - onPressed: options.onHeart, - ), - ], - ); - - return SliverMainAxisGroup( - slivers: [ - if (mediaQuery.mdAndUp) SliverGap(16 * scale), - SliverPadding( - padding: EdgeInsets.symmetric( - horizontal: (mediaQuery.mdAndUp ? 16 : 8.0) * scale, - ), - sliver: SliverList.list( - children: [ - DecoratedBox( - decoration: BoxDecoration( - image: decorationImage, - borderRadius: BorderRadius.circular(45), - ), - child: OutlinedContainer( - surfaceOpacity: context.theme.surfaceOpacity, - surfaceBlur: context.theme.surfaceBlur, - padding: EdgeInsets.all(24 * scale), - borderRadius: BorderRadius.circular(22 * scale), - borderWidth: 2, - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 16 * scale, - children: [ - Row( - spacing: 16 * scale, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - height: imageDimension * scale, - width: imageDimension * scale, - decoration: BoxDecoration( - borderRadius: context.theme.borderRadiusXl, - image: decorationImage, - ), - ), - Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AutoSizeText( - options.title, - maxLines: 2, - minFontSize: 16, - style: context.theme.typography.h3, - ), - if (options.description != null) - AutoSizeText( - options.description!, - maxLines: 2, - minFontSize: 14, - maxFontSize: 18, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: context - .theme.colorScheme.mutedForeground, - fontSize: 18, - ), - ), - const Gap(16), - Flex( - crossAxisAlignment: CrossAxisAlignment.start, - direction: mediaQuery.smAndUp - ? Axis.horizontal - : Axis.vertical, - spacing: 8 * scale, - children: [ - if (options.owner != null) - OutlineBadge( - leading: options.ownerImage != null - ? Avatar( - initials: - options.owner?[0] ?? "U", - provider: UniversalImage - .imageProvider( - options.ownerImage!, - ), - size: 20 * scale, - ) - : null, - child: Text( - options.owner!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ).small(), - ), - additionalActions, - ], - ), - if (mediaQuery.mdAndUp) ...[ - const Gap(16), - playbackActions - ], - ], - ), - ), - ], - ), - if (mediaQuery.smAndDown) playbackActions, - ], - ), - ), - ), - ], - ), - ) - ], - ); - } -} diff --git a/lib/components/track_presentation/sort_tracks_dropdown.dart b/lib/components/track_presentation/sort_tracks_dropdown.dart deleted file mode 100644 index 0a07cbad..00000000 --- a/lib/components/track_presentation/sort_tracks_dropdown.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart'; -import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; -import 'package:spotube/extensions/context.dart'; - -class SortTracksDropdown extends StatelessWidget { - final SortBy? value; - final void Function(SortBy)? onChanged; - const SortTracksDropdown({ - this.onChanged, - this.value, - super.key, - }); - - @override - Widget build(BuildContext context) { - return AdaptivePopSheetList( - variance: ButtonVariance.outline, - headings: [ - Text(context.l10n.sort_tracks), - ], - onSelected: onChanged, - tooltip: context.l10n.sort_tracks, - icon: const Icon(SpotubeIcons.sort), - items: (context) => [ - AdaptiveMenuButton( - value: SortBy.none, - enabled: value != SortBy.none, - child: Text(context.l10n.none), - ), - AdaptiveMenuButton( - value: SortBy.ascending, - enabled: value != SortBy.ascending, - child: Text(context.l10n.sort_a_z), - ), - AdaptiveMenuButton( - value: SortBy.descending, - enabled: value != SortBy.descending, - child: Text(context.l10n.sort_z_a), - ), - AdaptiveMenuButton( - value: SortBy.newest, - enabled: value != SortBy.newest, - child: Text(context.l10n.sort_newest), - ), - AdaptiveMenuButton( - value: SortBy.oldest, - enabled: value != SortBy.oldest, - child: Text(context.l10n.sort_oldest), - ), - AdaptiveMenuButton( - value: SortBy.duration, - enabled: value != SortBy.duration, - child: Text(context.l10n.sort_duration), - ), - AdaptiveMenuButton( - value: SortBy.artist, - enabled: value != SortBy.artist, - child: Text(context.l10n.sort_artist), - ), - AdaptiveMenuButton( - value: SortBy.album, - enabled: value != SortBy.album, - child: Text(context.l10n.sort_album), - ), - ], - ); - } -} diff --git a/lib/components/track_presentation/track_presentation.dart b/lib/components/track_presentation/track_presentation.dart deleted file mode 100644 index 2b2a9f6f..00000000 --- a/lib/components/track_presentation/track_presentation.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/components/track_presentation/presentation_list.dart'; -import 'package:spotube/components/track_presentation/presentation_props.dart'; -import 'package:spotube/components/track_presentation/presentation_top.dart'; -import 'package:spotube/components/track_presentation/presentation_modifiers.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/utils/platform.dart'; - -class TrackPresentation extends HookConsumerWidget { - final TrackPresentationOptions options; - const TrackPresentation({ - super.key, - required this.options, - }); - - @override - Widget build(BuildContext context, ref) { - final scrollController = useScrollController(); - final focusNode = useFocusNode(); - final scale = context.theme.scaling; - - useEffect(() { - if (!kIsMobile) return null; - void listener() { - if (!scrollController.hasClients) return; - - if (focusNode.hasFocus) { - scrollController.animateTo( - 300 * scale, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - } - - focusNode.addListener(listener); - return () { - focusNode.removeListener(listener); - }; - }, [focusNode, scrollController, scale]); - - return Data.inherit( - data: options, - child: SafeArea( - bottom: false, - child: Scaffold( - headers: const [TitleBar()], - child: CustomScrollView( - controller: scrollController, - slivers: [ - const TrackPresentationTopSection(), - const SliverGap(16), - SliverList.list( - children: [ - TrackPresentationModifiersSection( - focusNode: focusNode, - ), - LayoutBuilder(builder: (context, constrains) { - return Basic( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - leading: constrains.mdAndUp ? const Text(" #") : null, - title: Row( - children: [ - Expanded( - flex: constrains.lgAndUp ? 5 : 6, - child: Text(context.l10n.title), - ), - if (constrains.mdAndUp) - Expanded( - flex: 3, - child: Text(context.l10n.album), - ), - Text(context.l10n.duration), - ], - ), - ).small().muted(); - }), - ], - ), - const PresentationListSection(), - const SliverSafeArea(sliver: SliverGap(10)), - ], - ), - ), - ), - ); - } -} diff --git a/lib/components/track_presentation/use_action_callbacks.dart b/lib/components/track_presentation/use_action_callbacks.dart deleted file mode 100644 index 6707dd36..00000000 --- a/lib/components/track_presentation/use_action_callbacks.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/widgets.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/track_presentation/presentation_actions.dart'; -import 'package:spotube/components/track_presentation/presentation_props.dart'; - -import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/logger/logger.dart'; - -typedef UseActionCallbacks = ({ - bool isActive, - bool isLoading, - Future Function() onShuffle, - Future Function() onPlay, - VoidCallback onAddToQueue, -}); - -UseActionCallbacks useActionCallbacks(WidgetRef ref) { - final isLoading = useState(false); - final context = useContext(); - final options = TrackPresentationOptions.of(context); - final playlist = ref.watch(audioPlayerProvider); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryActionsProvider); - - final isActive = useMemoized( - () => playlist.collections.contains(options.collectionId), - [playlist.collections, options.collectionId], - ); - - final onShuffle = useCallback(() async { - try { - isLoading.value = true; - - final initialTracks = options.tracks; - if (!context.mounted) return; - - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - if (isRemoteDevice == null) return; - if (isRemoteDevice) { - final allTracks = await options.pagination.onFetchAll(); - final remotePlayback = ref.read(connectProvider.notifier); - await remotePlayback.load( - options.collection is SpotubeSimpleAlbumObject - ? WebSocketLoadEventData.album( - tracks: allTracks, - collection: options.collection as SpotubeSimpleAlbumObject, - initialIndex: Random().nextInt(allTracks.length)) - : WebSocketLoadEventData.playlist( - tracks: allTracks, - collection: options.collection as SpotubeSimplePlaylistObject, - initialIndex: Random().nextInt(allTracks.length), - ), - ); - await remotePlayback.setShuffle(true); - } else { - await playlistNotifier.load( - initialTracks, - autoPlay: true, - initialIndex: Random().nextInt(initialTracks.length), - ); - await audioPlayer.setShuffle(true); - playlistNotifier.addCollection(options.collectionId); - if (options.collection is SpotubeSimpleAlbumObject) { - historyNotifier - .addAlbums([options.collection as SpotubeSimpleAlbumObject]); - } else { - historyNotifier.addPlaylists( - [options.collection as SpotubeSimplePlaylistObject]); - } - - final allTracks = await options.pagination.onFetchAll(); - - await playlistNotifier.addTracks( - allTracks.sublist(initialTracks.length), - ); - } - } catch (e, stack) { - AppLogger.reportError(e, stack); - rethrow; - } finally { - isLoading.value = false; - } - }, [options, playlistNotifier, historyNotifier]); - - final onPlay = useCallback(() async { - try { - isLoading.value = true; - - final initialTracks = options.tracks; - - if (!context.mounted) return; - - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - if (isRemoteDevice == null) return; - if (isRemoteDevice) { - final allTracks = await options.pagination.onFetchAll(); - - final remotePlayback = ref.read(connectProvider.notifier); - await remotePlayback.load( - options.collection is SpotubeSimpleAlbumObject - ? WebSocketLoadEventData.album( - tracks: allTracks, - collection: options.collection as SpotubeSimpleAlbumObject, - ) - : WebSocketLoadEventData.playlist( - tracks: allTracks, - collection: options.collection as SpotubeSimplePlaylistObject, - ), - ); - } else { - if (initialTracks.isEmpty) return; - - await playlistNotifier.load(initialTracks, autoPlay: true); - playlistNotifier.addCollection(options.collectionId); - - if (options.collection is SpotubeSimpleAlbumObject) { - historyNotifier.addAlbums( - [options.collection as SpotubeSimpleAlbumObject], - ); - } else { - historyNotifier.addPlaylists( - [options.collection as SpotubeSimplePlaylistObject], - ); - } - - final allTracks = await options.pagination.onFetchAll(); - - await playlistNotifier.addTracks( - allTracks.sublist(initialTracks.length), - ); - } - } catch (e, stack) { - AppLogger.reportError(e, stack); - rethrow; - } finally { - if (context.mounted) { - isLoading.value = false; - } - } - }, [options, playlistNotifier, historyNotifier]); - - final onAddToQueue = useCallback(() { - final tracks = options.tracks; - playlistNotifier.addTracks(tracks); - playlistNotifier.addCollection(options.collectionId); - if (options.collection is SpotubeSimpleAlbumObject) { - historyNotifier - .addAlbums([options.collection as SpotubeSimpleAlbumObject]); - } else { - historyNotifier - .addPlaylists([options.collection as SpotubeSimplePlaylistObject]); - } - if (!context.mounted) return; - showToastForAction(context, "add-to-queue", tracks.length); - }, [options, playlistNotifier, historyNotifier]); - - return ( - isActive: isActive, - isLoading: isLoading.value, - onShuffle: onShuffle, - onPlay: onPlay, - onAddToQueue: onAddToQueue, - ); -} diff --git a/lib/components/track_presentation/use_is_user_playlist.dart b/lib/components/track_presentation/use_is_user_playlist.dart deleted file mode 100644 index 8792f6e7..00000000 --- a/lib/components/track_presentation/use_is_user_playlist.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/metadata_plugin/library/playlists.dart'; -import 'package:spotube/provider/metadata_plugin/core/user.dart'; - -bool useIsUserPlaylist(WidgetRef ref, String playlistId) { - final userPlaylistsQuery = ref.watch(metadataPluginSavedPlaylistsProvider); - final me = ref.watch(metadataPluginUserProvider); - - return useMemoized( - () => - userPlaylistsQuery.asData?.value.items.any((e) => - e.id == playlistId && - me.asData?.value != null && - e.owner.id == me.asData?.value?.id) ?? - false, - [userPlaylistsQuery.asData?.value, playlistId, me.asData?.value], - ); -} diff --git a/lib/components/track_presentation/use_track_tile_play_callback.dart b/lib/components/track_presentation/use_track_tile_play_callback.dart deleted file mode 100644 index 99f44f1e..00000000 --- a/lib/components/track_presentation/use_track_tile_play_callback.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/track_presentation/presentation_props.dart'; -import 'package:spotube/components/track_presentation/presentation_state.dart'; -import 'package:spotube/extensions/list.dart'; - -import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/history/history.dart'; - -Future Function(SpotubeTrackObject track, int index) - useTrackTilePlayCallback( - WidgetRef ref, -) { - final context = useContext(); - final options = TrackPresentationOptions.of(context); - final playlist = ref.watch(audioPlayerProvider); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryActionsProvider); - - final isActive = useMemoized( - () => playlist.collections.contains(options.collectionId), - [playlist.collections, options.collectionId], - ); - - final onTapTrackTile = - useCallback((SpotubeTrackObject track, int index) async { - final state = ref.read(presentationStateProvider(options.collection)); - final notifier = - ref.read(presentationStateProvider(options.collection).notifier); - - if (state.selectedTracks.isNotEmpty) { - if (state.selectedTracks.contains(track)) { - notifier.deselectTrack(track); - } else { - notifier.selectTrack(track); - } - return; - } - - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - if (isRemoteDevice == null) return; - - if (isRemoteDevice) { - final remotePlayback = ref.read(connectProvider.notifier); - final remoteQueue = ref.read(queueProvider); - if (remoteQueue.collections.contains(options.collectionId) || - remoteQueue.tracks.any((s) => s.id == track.id)) { - await playlistNotifier.jumpToTrack(track); - } else { - final tracks = await options.pagination.onFetchAll(); - await remotePlayback.load( - options.collection is SpotubeSimpleAlbumObject - ? WebSocketLoadEventData.album( - tracks: tracks, - collection: options.collection as SpotubeSimpleAlbumObject, - initialIndex: index, - ) - : WebSocketLoadEventData.playlist( - tracks: tracks, - collection: options.collection as SpotubeSimplePlaylistObject, - initialIndex: index, - ), - ); - } - } else { - if (isActive || playlist.tracks.containsBy(track, (a) => a.id)) { - await playlistNotifier.jumpToTrack(track); - } else { - final tracks = await options.pagination.onFetchAll(); - await playlistNotifier.load( - tracks, - initialIndex: index, - autoPlay: true, - ); - playlistNotifier.addCollection(options.collectionId); - if (options.collection is SpotubeSimpleAlbumObject) { - historyNotifier - .addAlbums([options.collection as SpotubeSimpleAlbumObject]); - } else { - historyNotifier.addPlaylists( - [options.collection as SpotubeSimplePlaylistObject]); - } - } - } - }, [isActive, playlist, options, playlistNotifier, historyNotifier]); - - return onTapTrackTile; -} diff --git a/lib/components/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart deleted file mode 100644 index 7d14493e..00000000 --- a/lib/components/track_tile/track_options.dart +++ /dev/null @@ -1,283 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/routes.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/ui/button_tile.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/track_options/track_options_provider.dart'; - -/// [track] must be a [SpotubeFullTrackObject] or [SpotubeLocalTrackObject] -class TrackOptions extends HookConsumerWidget { - final SpotubeTrackObject track; - final bool userPlaylist; - final String? playlistId; - final Widget? icon; - final VoidCallback? onTapItem; - - const TrackOptions({ - super.key, - required this.track, - this.userPlaylist = false, - this.playlistId, - this.icon, - this.onTapItem, - }) : assert( - track is SpotubeFullTrackObject || track is SpotubeLocalTrackObject, - "Track must be a SpotubeFullTrackObject, SpotubeLocalTrackObject", - ); - - @override - Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.of(context); - - final trackOptionActions = ref.watch(trackOptionActionsProvider(track)); - final ( - :isBlacklisted, - :isInDownloadQueue, - :isInQueue, - :isActiveTrack, - :isAuthenticated, - :isLiked, - :downloadTask - ) = ref.watch(trackOptionsStateProvider(track)); - final isLocalTrack = track is SpotubeLocalTrackObject; - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - if (isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.delete, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.trash), - title: Text(context.l10n.delete), - ), - if (mediaQuery.smAndDown && !isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.album, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.album), - title: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.go_to_album), - Text( - track.album.name, - style: context.theme.typography.xSmall, - ), - ], - ), - ), - if (!isInQueue) ...[ - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.addToQueue, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.queueAdd), - title: Text(context.l10n.add_to_queue), - ), - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.playNext, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.lightning), - title: Text(context.l10n.play_next), - ), - ] else - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.removeFromQueue, - playlistId, - ); - onTapItem?.call(); - }, - enabled: !isActiveTrack, - leading: const Icon(SpotubeIcons.queueRemove), - title: Text(context.l10n.remove_from_queue), - ), - if (isAuthenticated && !isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.favorite, - playlistId, - ); - onTapItem?.call(); - }, - leading: isLiked - ? const Icon( - SpotubeIcons.heartFilled, - color: Colors.pink, - ) - : const Icon(SpotubeIcons.heart), - title: Text( - isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - ), - ), - if (isAuthenticated && !isLocalTrack) ...[ - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.startRadio, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.radio), - title: Text(context.l10n.start_a_radio), - ), - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.addToPlaylist, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.playlistAdd), - title: Text(context.l10n.add_to_playlist), - ), - ], - if (userPlaylist && isAuthenticated && !isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.removeFromPlaylist, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.removeFilled), - title: Text(context.l10n.remove_from_playlist), - ), - if (!isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.download, - playlistId, - ); - onTapItem?.call(); - }, - enabled: !isInDownloadQueue, - leading: isInDownloadQueue - ? StreamBuilder( - stream: downloadTask?.downloadedBytesStream, - builder: (context, snapshot) { - final progress = downloadTask?.totalSizeBytes == null || - downloadTask?.totalSizeBytes == 0 - ? 0 - : (snapshot.data ?? 0) / - downloadTask!.totalSizeBytes!; - return CircularProgressIndicator( - value: progress.toDouble(), - ); - }, - ) - : const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_track), - ), - if (!isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.blacklist, - playlistId, - ); - onTapItem?.call(); - }, - leading: Icon( - SpotubeIcons.playlistRemove, - color: isBlacklisted != true ? Colors.red[400] : null, - ), - title: Text( - isBlacklisted == true - ? context.l10n.remove_from_blacklist - : context.l10n.add_to_blacklist, - style: TextStyle( - color: isBlacklisted != true ? Colors.red[400] : null, - ), - ), - ), - if (!isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.share, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.share), - title: Text(context.l10n.share), - ), - if (!isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.details, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.info), - title: Text(context.l10n.details), - ), - ], - ); - } -} diff --git a/lib/components/track_tile/track_options_button.dart b/lib/components/track_tile/track_options_button.dart deleted file mode 100644 index 51fff5ea..00000000 --- a/lib/components/track_tile/track_options_button.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/links/artist_link.dart'; -import 'package:spotube/components/track_tile/track_options.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -class TrackOptionsButton extends HookConsumerWidget { - final SpotubeTrackObject track; - final bool userPlaylist; - final String? playlistId; - const TrackOptionsButton({ - super.key, - required this.track, - required this.userPlaylist, - this.playlistId, - }); - - static OverlayCompleter showOptions( - BuildContext context, - Offset offset, - SpotubeTrackObject track, { - bool userPlaylist = false, - String? playlistId, - }) { - return showPopover( - context: context, - position: offset, - alignment: Alignment.bottomRight, - builder: (context) { - return SizedBox( - width: 220 * context.theme.scaling, - child: Card( - padding: const EdgeInsets.all(8), - child: TrackOptions( - track: track, - playlistId: playlistId, - userPlaylist: userPlaylist, - onTapItem: () { - closeOverlay(context); - }, - ), - ), - ); - }, - ); - } - - @override - Widget build(BuildContext context, ref) { - final imageProvider = useMemoized( - () => UniversalImage.imageProvider( - (track.album.images).smallest(ImagePlaceholder.albumArt), - ), - [track.album.images], - ); - - return IconButton.ghost( - icon: const Icon(SpotubeIcons.moreHorizontal), - onPressed: () { - final mediaQuery = MediaQuery.sizeOf(context); - - if (mediaQuery.lgAndUp) { - final renderBox = context.findRenderObject() as RenderBox; - final position = RelativeRect.fromRect( - Rect.fromPoints( - renderBox.localToGlobal(Offset.zero, - ancestor: context.findRenderObject()), - renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero), - ancestor: context.findRenderObject()), - ), - Offset.zero & mediaQuery, - ); - final offset = Offset(position.left, position.top); - showOptions( - context, - offset, - track, - userPlaylist: userPlaylist, - playlistId: playlistId, - ); - } else { - openDrawer( - context: context, - position: OverlayPosition.bottom, - draggable: true, - showDragHandle: true, - borderRadius: context.theme.borderRadiusMd, - transformBackdrop: false, - builder: (context) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - Basic( - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - borderRadius: context.theme.borderRadiusMd, - image: DecorationImage( - fit: BoxFit.cover, - image: imageProvider, - ), - ), - ), - title: Text( - track.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ).semiBold(), - subtitle: Align( - alignment: Alignment.centerLeft, - child: ArtistLink( - artists: track.artists, - onOverflowArtistClick: () => context.navigateTo( - TrackRoute(trackId: track.id), - ), - ), - ), - ), - const Divider(), - TrackOptions( - track: track, - userPlaylist: userPlaylist, - playlistId: playlistId, - onTapItem: () { - closeDrawer(context); - }, - ), - ], - ), - ); - }, - ); - } - }, - ); - } -} diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart deleted file mode 100644 index ec3f50f3..00000000 --- a/lib/components/track_tile/track_tile.dart +++ /dev/null @@ -1,347 +0,0 @@ -import 'dart:async'; - -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/hover_builder.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/links/artist_link.dart'; -import 'package:spotube/components/links/link_text.dart'; -import 'package:spotube/components/track_tile/track_options_button.dart'; -import 'package:spotube/components/ui/button_tile.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/duration.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/querying_track_info.dart'; -import 'package:spotube/provider/audio_player/state.dart'; -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/utils/platform.dart'; - -final isBlacklistedProvider = - Provider.autoDispose.family( - (ref, track) { - ref.watch(blacklistProvider); - final blacklist = ref.read(blacklistProvider.notifier); - return blacklist.contains(track); - }, -); - -final _overlay = ValueNotifier?>(null); - -class TrackTile extends HookConsumerWidget { - /// [index] will not be shown if null - final int? index; - final SpotubeTrackObject track; - final bool selected; - final bool selectionMode; - final ValueChanged? onChanged; - final Future Function()? onTap; - final VoidCallback? onLongPress; - final bool userPlaylist; - final String? playlistId; - final AudioPlayerState playlist; - - final List? leadingActions; - - const TrackTile({ - super.key, - this.index, - required this.track, - this.selected = false, - this.selectionMode = false, - required this.playlist, - this.onTap, - this.onLongPress, - this.onChanged, - this.userPlaylist = false, - this.playlistId, - this.leadingActions, - }); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - - final isBlackListed = ref.watch(isBlacklistedProvider(track)); - - final isLoading = useState(false); - - final isPlaying = playlist.activeTrack?.id == track.id; - - final isSelected = isPlaying || isLoading.value; - - final imageProvider = useMemoized( - () => UniversalImage.imageProvider( - (track.album.images).smallest(ImagePlaceholder.albumArt), - ), - [track.album.images], - ); - - // Treat either explicit selectionMode or presence of onChanged as selection - // context. Some lists enable selection by providing `onChanged` without - // toggling a dedicated `selectionMode` flag (e.g. playlists), so we must - // disable inner navigation in both cases. - final effectiveSelection = selectionMode || onChanged != null; - - return LayoutBuilder(builder: (context, constrains) { - return Listener( - onPointerDown: (event) { - if (event.buttons != kSecondaryMouseButton) return; - if (_overlay.value != null) { - _overlay.value?.remove(); - _overlay.value = null; - } - _overlay.value = TrackOptionsButton.showOptions( - context, - Offset.zero, - track, - userPlaylist: userPlaylist, - playlistId: playlistId, - ); - }, - child: HoverBuilder( - permanentState: isSelected || constrains.smAndDown ? true : null, - builder: (context, isHovering) => ButtonTile( - selected: isSelected, - onPressed: () async { - if (isBlackListed) return; - try { - isLoading.value = true; - await onTap?.call(); - } finally { - if (context.mounted) { - isLoading.value = false; - } - } - }, - onLongPress: onLongPress, - style: (isBlackListed - ? ButtonVariance.destructive - : ButtonVariance.ghost) - .copyWith( - padding: (context, states, value) => - const EdgeInsets.symmetric(vertical: 8, horizontal: 0), - ), - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ...?leadingActions, - AnimatedCrossFade( - duration: const Duration(milliseconds: 300), - crossFadeState: index != null && onChanged == null - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - firstChild: Checkbox( - state: selected - ? CheckboxState.checked - : CheckboxState.unchecked, - onChanged: (state) => - onChanged?.call(state == CheckboxState.checked), - ), - secondChild: constrains.smAndDown - ? const SizedBox(width: 16) - : SizedBox( - width: 50, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text( - '${(index ?? 0) + 1}', - maxLines: 1, - style: theme.typography.small, - textAlign: TextAlign.center, - ), - ), - ), - ), - Stack( - children: [ - Container( - height: 40, - width: 40, - decoration: BoxDecoration( - borderRadius: theme.borderRadiusMd, - image: DecorationImage( - fit: BoxFit.cover, - image: imageProvider, - ), - ), - ), - Positioned.fill( - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - decoration: BoxDecoration( - borderRadius: theme.borderRadiusMd, - color: isHovering - ? Colors.black.withAlpha(102) - : Colors.transparent, - ), - ), - ), - Positioned.fill( - child: Center( - child: Skeleton.ignore( - child: Consumer( - builder: (context, ref, _) { - final isFetchingActiveTrack = - ref.watch(queryingTrackInfoProvider); - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: switch (( - isPlaying, - isFetchingActiveTrack, - isPlaying, - isHovering, - isLoading.value - )) { - (true, true, _, _, _) || - (_, _, _, _, true) => - const SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator(), - ), - (_, _, true, _, _) => Icon( - SpotubeIcons.pause, - color: theme.colorScheme.primary, - ), - (_, _, _, true, _) => const Icon( - SpotubeIcons.play, - color: Colors.white, - ), - _ => const SizedBox.shrink(), - }, - ); - }, - ), - ), - ), - ), - ], - ), - ], - ), - title: Row( - children: [ - Expanded( - flex: 6, - child: AbsorbPointer( - absorbing: selectionMode, - child: switch (track) { - SpotubeLocalTrackObject() => Text( - track.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - _ => Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Button( - style: ButtonVariance.link.copyWith( - padding: (context, states, value) => - EdgeInsets.zero, - ), - onPressed: effectiveSelection - ? null - : () { - context - .navigateTo(TrackRoute(trackId: track.id)); - }, - child: Text( - track.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - }, - ), - ), - if (constrains.mdAndUp) ...[ - const SizedBox(width: 8), - Expanded( - flex: 4, - child: switch (track) { - SpotubeLocalTrackObject() => Text( - track.album.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - _ => Align( - alignment: Alignment.centerLeft, - child: LinkText( - track.album.name, - AlbumRoute( - album: track.album, - id: track.album.id, - ), - push: true, - overflow: TextOverflow.ellipsis, - ), - ) - }, - ), - ], - ], - ), - subtitle: Align( - alignment: Alignment.centerLeft, - child: track is SpotubeLocalTrackObject - ? Text( - track.artists.asString(), - ) - : ClipRect( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 40), - child: AbsorbPointer( - absorbing: effectiveSelection, - child: ArtistLink( - artists: track.artists, - onOverflowArtistClick: effectiveSelection - ? () {} - : () { - context.navigateTo( - TrackRoute(trackId: track.id), - ); - }, - ), - ), - ), - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 8), - Text( - Duration(milliseconds: track.durationMs) - .toHumanReadableString(padZero: false), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Builder( - builder: (context) { - return TrackOptionsButton( - track: track, - userPlaylist: userPlaylist, - playlistId: playlistId, - ); - }, - ), - if (kIsDesktop) const Gap(10), - ], - ), - ), - ), - ); - }); - } -} diff --git a/lib/components/ui/button_tile.dart b/lib/components/ui/button_tile.dart deleted file mode 100644 index e31a09a5..00000000 --- a/lib/components/ui/button_tile.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -class ButtonTile extends StatelessWidget { - final Widget? title; - final Widget? subtitle; - final Widget? leading; - final Widget? trailing; - final bool enabled; - final VoidCallback? onPressed; - final VoidCallback? onLongPress; - final bool selected; - final AbstractButtonStyle style; - final EdgeInsets? padding; - - const ButtonTile({ - super.key, - this.title, - this.subtitle, - this.leading, - this.trailing, - this.enabled = true, - this.onPressed, - this.onLongPress, - this.selected = false, - this.padding, - this.style = ButtonVariance.outline, - }); - - @override - Widget build(BuildContext context) { - final ThemeData(:colorScheme, :typography) = Theme.of(context); - - return GestureDetector( - onLongPress: onLongPress, - child: Button( - enabled: enabled, - onPressed: onPressed, - style: style.copyWith( - padding: - padding != null ? (context, states, value) => padding! : null, - decoration: (context, states, value) { - final decoration = - style.decoration(context, states) as BoxDecoration; - - if (selected) { - return switch (style) { - ButtonVariance.outline => decoration.copyWith( - border: Border.all( - color: colorScheme.primary, - width: 1.0, - ), - color: colorScheme.primary.withAlpha(25), - ), - ButtonVariance.ghost || _ => decoration.copyWith( - color: colorScheme.primary.withAlpha(25), - ), - }; - } - - return decoration; - }, - iconTheme: (context, states, value) { - final iconTheme = style.iconTheme(context, states); - - if (selected && style == ButtonVariance.outline) { - return iconTheme.copyWith( - color: colorScheme.primary, - ); - } - - return iconTheme; - }, - textStyle: (context, states, value) { - final textStyle = style.textStyle(context, states); - - if (selected && style == ButtonVariance.outline) { - return textStyle.copyWith( - color: colorScheme.primary, - ); - } - - return textStyle; - }, - ), - alignment: Alignment.centerLeft, - child: SizedBox( - width: double.infinity, - child: Basic( - padding: EdgeInsets.zero, - leadingAlignment: Alignment.center, - trailingAlignment: Alignment.center, - leading: leading, - title: title, - subtitle: - style == ButtonVariance.outline && selected && subtitle != null - ? DefaultTextStyle( - style: typography.xSmall.copyWith( - color: colorScheme.primary, - ), - child: subtitle!, - ) - : subtitle, - trailing: trailing, - ), - ), - ), - ); - } -} diff --git a/lib/components/waypoint.dart b/lib/components/waypoint.dart deleted file mode 100644 index cf00e29b..00000000 --- a/lib/components/waypoint.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:visibility_detector/visibility_detector.dart'; - -class Waypoint extends HookWidget { - final FutureOr Function()? onTouchEdge; - final Widget? child; - final ScrollController controller; - final bool isGrid; - - const Waypoint({ - super.key, - required this.controller, - this.isGrid = false, - this.onTouchEdge, - this.child, - }); - - @override - Widget build(BuildContext context) { - useEffect(() { - if (isGrid) { - return null; - } - Future listener() async { - // nextPageTrigger will have a value equivalent to 80% of the list size. - final nextPageTrigger = 0.8 * controller.position.maxScrollExtent; - - // scrollController fetches the next paginated data when the current - // position of the user on the screen has surpassed - if (controller.position.pixels >= nextPageTrigger && context.mounted) { - await onTouchEdge?.call(); - } - } - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (controller.hasClients && context.mounted) { - listener(); - controller.addListener(listener); - } - }); - return () => controller.removeListener(listener); - }, [controller, onTouchEdge]); - - if (isGrid) { - return VisibilityDetector( - key: const Key("waypoint"), - onVisibilityChanged: (info) { - if (info.visibleFraction > 0) { - onTouchEdge?.call(); - } - }, - child: child ?? Container(), - ); - } - - return child ?? Container(); - } -} diff --git a/lib/extensions/button_variance.dart b/lib/extensions/button_variance.dart deleted file mode 100644 index cf66d528..00000000 --- a/lib/extensions/button_variance.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -extension CopyWithButtonVarianceExtension on ButtonVariance { - ButtonVariance copyWith({ - ButtonStateProperty? padding, - ButtonStateProperty? decoration, - ButtonStateProperty? mouseCursor, - ButtonStateProperty? iconTheme, - ButtonStateProperty? margin, - ButtonStateProperty? textStyle, - }) { - return ButtonVariance( - padding: padding ?? this.padding, - decoration: decoration ?? this.decoration, - mouseCursor: mouseCursor ?? this.mouseCursor, - iconTheme: iconTheme ?? this.iconTheme, - margin: margin ?? this.margin, - textStyle: textStyle ?? this.textStyle, - ); - } -} diff --git a/lib/extensions/color.dart b/lib/extensions/color.dart deleted file mode 100644 index bc7d65a2..00000000 --- a/lib/extensions/color.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -extension ColorAlterer on Color { - Color darken(double amount) { - assert(amount >= 0 && amount <= 1); - final hsl = HSLColor.fromColor(this); - final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0)); - return hslDark.toColor(); - } - - Color lighten(double amount) { - assert(amount >= 0 && amount <= 1); - final hsl = HSLColor.fromColor(this); - final hslLight = - hsl.withLightness((hsl.lightness + amount).clamp(0.0, 1.0)); - return hslLight.toColor(); - } - - bool isLight() { - final luminance = computeLuminance(); - return luminance > 0.5; - } - - bool isDark() { - final luminance = computeLuminance(); - return luminance <= 0.5; - } -} diff --git a/lib/extensions/constrains.dart b/lib/extensions/constrains.dart deleted file mode 100644 index b7353c4f..00000000 --- a/lib/extensions/constrains.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart'; - -enum Breakpoint { - xs, - sm, - md, - lg, - xl, - xxl; - - bool operator <=(Breakpoint other) => index <= other.index; - bool operator <(Breakpoint other) => index < other.index; - bool operator >(Breakpoint other) => index > other.index; - bool operator >=(Breakpoint other) => index >= other.index; -} - -// ignore: constant_identifier_names -const Breakpoints = ( - xs: 480.0, - sm: 640.0, - md: 820.0, - lg: 1024.0, - xl: 1280.0, -); - -extension SliverBreakpoints on SliverConstraints { - bool get isXs => crossAxisExtent <= Breakpoints.xs; - bool get isSm => - crossAxisExtent > Breakpoints.xs && crossAxisExtent <= Breakpoints.sm; - bool get isMd => - crossAxisExtent > Breakpoints.sm && crossAxisExtent <= Breakpoints.md; - bool get isLg => - crossAxisExtent > Breakpoints.md && crossAxisExtent <= Breakpoints.lg; - bool get isXl => - crossAxisExtent > Breakpoints.lg && crossAxisExtent <= Breakpoints.xl; - bool get is2Xl => crossAxisExtent > Breakpoints.xl; - - Breakpoint get breakpoint { - if (isXs) return Breakpoint.xs; - if (isSm) return Breakpoint.sm; - if (isMd) return Breakpoint.md; - if (isLg) return Breakpoint.lg; - if (isXl) return Breakpoint.xl; - return Breakpoint.xxl; - } - - bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl; - bool get mdAndUp => isMd || isLg || isXl || is2Xl; - bool get lgAndUp => isLg || isXl || is2Xl; - bool get xlAndUp => isXl || is2Xl; - - bool get smAndDown => isXs || isSm; - bool get mdAndDown => isXs || isSm || isMd; - bool get lgAndDown => isXs || isSm || isMd || isLg; - bool get xlAndDown => isXs || isSm || isMd || isLg || isXl; -} - -extension ContainerBreakpoints on BoxConstraints { - bool get isXs => biggest.width <= Breakpoints.xs; - bool get isSm => - biggest.width > Breakpoints.xs && biggest.width <= Breakpoints.sm; - bool get isMd => - biggest.width > Breakpoints.sm && biggest.width <= Breakpoints.md; - bool get isLg => - biggest.width > Breakpoints.md && biggest.width <= Breakpoints.lg; - bool get isXl => - biggest.width > Breakpoints.lg && biggest.width <= Breakpoints.xl; - bool get is2Xl => biggest.width > Breakpoints.xl; - - Breakpoint get breakpoint { - if (isXs) return Breakpoint.xs; - if (isSm) return Breakpoint.sm; - if (isMd) return Breakpoint.md; - if (isLg) return Breakpoint.lg; - if (isXl) return Breakpoint.xl; - return Breakpoint.xxl; - } - - bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl; - bool get mdAndUp => isMd || isLg || isXl || is2Xl; - bool get lgAndUp => isLg || isXl || is2Xl; - bool get xlAndUp => isXl || is2Xl; - - bool get smAndDown => isXs || isSm; - bool get mdAndDown => isXs || isSm || isMd; - bool get lgAndDown => isXs || isSm || isMd || isLg; - bool get xlAndDown => isXs || isSm || isMd || isLg || isXl; -} - -extension ScreenBreakpoints on MediaQueryData { - bool get isXs => size.width <= Breakpoints.xs; - bool get isSm => size.width > Breakpoints.xs && size.width <= Breakpoints.sm; - bool get isMd => size.width > Breakpoints.sm && size.width <= Breakpoints.md; - bool get isLg => size.width > Breakpoints.md && size.width <= Breakpoints.lg; - bool get isXl => size.width > Breakpoints.lg && size.width <= Breakpoints.xl; - bool get is2Xl => size.width > Breakpoints.xl; - - bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl; - bool get mdAndUp => isMd || isLg || isXl || is2Xl; - bool get lgAndUp => isLg || isXl || is2Xl; - bool get xlAndUp => isXl || is2Xl; - - bool get smAndDown => isXs || isSm; - bool get mdAndDown => isXs || isSm || isMd; - bool get lgAndDown => isXs || isSm || isMd || isLg; - bool get xlAndDown => isXs || isSm || isMd || isLg || isXl; -} - -extension SizeBreakpoints on Size { - bool get isXs => width <= Breakpoints.xs; - bool get isSm => width > Breakpoints.xs && width <= Breakpoints.sm; - bool get isMd => width > Breakpoints.sm && width <= Breakpoints.md; - bool get isLg => width > Breakpoints.md && width <= Breakpoints.lg; - bool get isXl => width > Breakpoints.lg && width <= Breakpoints.xl; - bool get is2Xl => width > Breakpoints.xl; - - bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl; - bool get mdAndUp => isMd || isLg || isXl || is2Xl; - bool get lgAndUp => isLg || isXl || is2Xl; - bool get xlAndUp => isXl || is2Xl; - - bool get smAndDown => isXs || isSm; - bool get mdAndDown => isXs || isSm || isMd; - bool get lgAndDown => isXs || isSm || isMd || isLg; - bool get xlAndDown => isXs || isSm || isMd || isLg || isXl; -} diff --git a/lib/extensions/context.dart b/lib/extensions/context.dart deleted file mode 100644 index 29fbb7ca..00000000 --- a/lib/extensions/context.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/l10n/l10n.dart'; - -extension AppLocale on BuildContext { - AppLocalizations get l10n => AppLocalizations.of(this)!; -} diff --git a/lib/extensions/dio.dart b/lib/extensions/dio.dart deleted file mode 100644 index 81bb1e70..00000000 --- a/lib/extensions/dio.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'dart:io'; -import 'package:dio/dio.dart'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; - -extension ChunkDownloaderDioExtension on Dio { - Future chunkDownload( - String urlPath, - dynamic savePath, { - ProgressCallback? onReceiveProgress, - Map? queryParameters, - CancelToken? cancelToken, - bool deleteOnError = true, - FileAccessMode fileAccessMode = FileAccessMode.write, - String lengthHeader = Headers.contentLengthHeader, - Object? data, - Options? options, - int connections = 4, - }) async { - final targetFile = File(savePath.toString()); - final tempRootDir = await getTemporaryDirectory(); - final tempSaveDir = Directory( - join( - tempRootDir.path, - 'Spotube', - '.chunk_dl_${targetFile.uri.pathSegments.last}', - ), - ); - if (await tempSaveDir.exists()) await tempSaveDir.delete(recursive: true); - await tempSaveDir.create(recursive: true); - - try { - int? totalLength; - bool supportsRange = false; - - Response? headResp; - try { - headResp = await head( - urlPath, - queryParameters: queryParameters, - options: Options( - headers: {'Range': 'bytes=0-0'}, - followRedirects: true, - ), - ); - } catch (_) { - // Some servers reject HEAD -> ignore - } - - final lengthStr = headResp?.headers[lengthHeader]?.first; - if (lengthStr != null) { - final parsed = int.tryParse(lengthStr); - if (parsed != null && parsed > 1) { - totalLength = parsed; - } - } - - supportsRange = headResp?.statusCode == 206 || - headResp?.headers.value(HttpHeaders.acceptRangesHeader) == 'bytes'; - - if (totalLength == null || totalLength <= 1) { - final resp = await get( - urlPath, - options: Options( - responseType: ResponseType.stream, - ), - queryParameters: queryParameters, - cancelToken: cancelToken, - ); - - final len = int.tryParse(resp.headers[lengthHeader]?.first ?? ''); - if (len == null || len <= 1) { - // can’t safely chunk — fallback - return download( - urlPath, - savePath, - onReceiveProgress: onReceiveProgress, - queryParameters: queryParameters, - cancelToken: cancelToken, - deleteOnError: deleteOnError, - options: options, - data: data, - ); - } - - totalLength = len; - supportsRange = - resp.headers.value(HttpHeaders.acceptRangesHeader)?.toLowerCase() == - 'bytes'; - } - - if (!supportsRange || connections <= 1) { - return download( - urlPath, - savePath, - onReceiveProgress: onReceiveProgress, - queryParameters: queryParameters, - cancelToken: cancelToken, - deleteOnError: deleteOnError, - options: options, - data: data, - ); - } - - final chunkSize = (totalLength / connections).ceil(); - int downloaded = 0; - - final partFiles = List.generate( - connections, - (i) => File(join(tempSaveDir.path, 'part_$i')), - ); - - final futures = List.generate(connections, (i) async { - final start = i * chunkSize; - final end = (i + 1) * chunkSize - 1; - if (start >= totalLength!) return; - - final resp = await get( - urlPath, - options: Options( - responseType: ResponseType.stream, - headers: {'Range': 'bytes=$start-$end'}, - ), - queryParameters: queryParameters, - cancelToken: cancelToken, - ); - - final file = partFiles[i]; - if (await file.exists()) await file.delete(); - await file.create(recursive: true); - final sink = file.openWrite(); - - await for (final chunk in resp.data!.stream) { - sink.add(chunk); - downloaded += chunk.length; - onReceiveProgress?.call(downloaded, totalLength); - } - - await sink.close(); - }); - - await Future.wait(futures); - - final targetSink = targetFile.openWrite(); - for (final f in partFiles) { - await targetSink.addStream(f.openRead()); - } - await targetSink.close(); - - await tempSaveDir.delete(recursive: true); - - return Response( - requestOptions: RequestOptions(path: urlPath), - data: targetFile, - statusCode: 200, - statusMessage: 'Chunked download completed ($connections connections)', - ); - } catch (e) { - if (deleteOnError) { - if (await targetFile.exists()) await targetFile.delete(); - if (await tempSaveDir.exists()) { - await tempSaveDir.delete(recursive: true); - } - } - rethrow; - } - } -} diff --git a/lib/extensions/duration.dart b/lib/extensions/duration.dart deleted file mode 100644 index ff670b1a..00000000 --- a/lib/extensions/duration.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:duration/locale.dart'; -import 'package:duration/duration.dart'; - -extension DurationToHumanReadableString on Duration { - String toHumanReadableString({padZero = true}) { - final mm = inMinutes - .remainder(60) - .toString() - .padLeft(2, !padZero && inHours == 0 ? '' : "0"); - final ss = inSeconds.remainder(60).toString().padLeft(2, "0"); - - if (inHours > 0) { - final hh = inHours.toString().padLeft(2, !padZero ? '' : "0"); - return "$hh:$mm:$ss"; - } - - return "$mm:$ss"; - } - - String format({ - DurationTersity tersity = DurationTersity.second, - DurationTersity upperTersity = DurationTersity.week, - DurationLocale locale = const EnglishDurationLocale(), - String? spacer, - String? delimiter, - String? conjugation, - bool abbreviated = false, - }) => - printDuration( - this, - tersity: tersity, - upperTersity: upperTersity, - locale: locale, - spacer: spacer, - delimiter: delimiter, - conjugation: conjugation, - abbreviated: abbreviated, - ); -} - -extension ParseDuration on Duration { - static Duration fromString(String duration) { - final parts = duration.split(':').reversed.toList(); - final seconds = int.parse(parts[0]); - final minutes = parts.length > 1 ? int.parse(parts[1]) : 0; - final hours = parts.length > 2 ? int.parse(parts[2]) : 0; - return Duration(hours: hours, minutes: minutes, seconds: seconds); - } -} diff --git a/lib/extensions/list.dart b/lib/extensions/list.dart deleted file mode 100644 index ddd36e4d..00000000 --- a/lib/extensions/list.dart +++ /dev/null @@ -1,19 +0,0 @@ -extension UniqueItemExtension on List { - List unique(bool Function(T a, T b) equals) { - final copy = []; - - for (final item in this) { - if (copy.any((element) => equals(element, item))) continue; - copy.add(item); - } - - return copy; - } - - bool containsBy(T item, dynamic Function(T a) fn) { - for (final el in this) { - if (fn(el) == fn(item)) return true; - } - return false; - } -} diff --git a/lib/extensions/map.dart b/lib/extensions/map.dart deleted file mode 100644 index 48f2935c..00000000 --- a/lib/extensions/map.dart +++ /dev/null @@ -1,15 +0,0 @@ -extension CastDeepMaps on Map { - Map castKeyDeep() { - return cast().map((key, value) { - if (value is Map) { - return MapEntry(key, value.castKeyDeep()); - } else if (value is List) { - return MapEntry( - key, - value.map((e) => e is Map ? e.castKeyDeep() : e).toList(), - ); - } - return MapEntry(key, value); - }); - } -} diff --git a/lib/extensions/string.dart b/lib/extensions/string.dart deleted file mode 100644 index 94123fe3..00000000 --- a/lib/extensions/string.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:html_unescape/html_unescape.dart'; -import 'package:html/parser.dart'; - -final htmlEscape = HtmlUnescape(); - -extension UnescapeHtml on String { - String cleanHtml() => parse("

$this

").documentElement!.text; - String unescapeHtml() => htmlEscape.convert(this); -} - -extension NullableUnescapeHtml on String? { - String? cleanHtml() => this?.cleanHtml(); - String? unescapeHtml() => this?.unescapeHtml(); -} - -extension StringExtension on String { - String capitalize() { - return "${this[0].toUpperCase()}${substring(1)}"; - } -} diff --git a/lib/generated_plugin_registrant.dart b/lib/generated_plugin_registrant.dart deleted file mode 100644 index a25a1f5f..00000000 --- a/lib/generated_plugin_registrant.dart +++ /dev/null @@ -1,23 +0,0 @@ -// -// Generated file. Do not edit. -// - -// ignore_for_file: directives_ordering -// ignore_for_file: lines_longer_than_80_chars -// ignore_for_file: depend_on_referenced_packages - -import 'package:audio_service_web/audio_service_web.dart'; -import 'package:audio_session/audio_session_web.dart'; -import 'package:shared_preferences_web/shared_preferences_web.dart'; -import 'package:url_launcher_web/url_launcher_web.dart'; - -import 'package:flutter_web_plugins/flutter_web_plugins.dart'; - -// ignore: public_member_api_docs -void registerPlugins(Registrar registrar) { - AudioServiceWeb.registerWith(registrar); - AudioSessionWeb.registerWith(registrar); - SharedPreferencesPlugin.registerWith(registrar); - UrlLauncherPlugin.registerWith(registrar); - registrar.registerMessageHandler(); -} diff --git a/lib/hooks/configurators/use_check_yt_dlp_installed.dart b/lib/hooks/configurators/use_check_yt_dlp_installed.dart deleted file mode 100644 index 1d948258..00000000 --- a/lib/hooks/configurators/use_check_yt_dlp_installed.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/modules/settings/youtube_engine_not_installed_dialog.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart'; - -void useCheckYtDlpInstalled(WidgetRef ref) { - final context = useContext(); - - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) async { - final youtubeEngine = ref.read( - userPreferencesProvider.select( - (value) => value.youtubeClientEngine, - ), - ); - - final customPath = - KVStoreService.getYoutubeEnginePath(YoutubeClientEngine.ytDlp); - - if (youtubeEngine == YoutubeClientEngine.ytDlp && - !await YtDlpEngine.isInstalled() && - (customPath == null || !await File(customPath).exists()) && - context.mounted) { - await showDialog( - context: context, - builder: (context) => - YouTubeEngineNotInstalledDialog(engine: youtubeEngine), - ); - } - }); - - return null; - }, []); -} diff --git a/lib/hooks/configurators/use_close_behavior.dart b/lib/hooks/configurators/use_close_behavior.dart deleted file mode 100644 index 2bdc65ef..00000000 --- a/lib/hooks/configurators/use_close_behavior.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'dart:io'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/hooks/configurators/use_window_listener.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -import 'package:local_notifier/local_notifier.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:window_manager/window_manager.dart'; - -final closeNotification = !kIsDesktop - ? null - : (LocalNotification( - title: 'Spotube', - body: 'Running in background. Minimized to System Tray', - actions: [ - LocalNotificationAction(text: 'Close The App'), - ], - )..onClickAction = (value) { - exit(0); - }); - -void useCloseBehavior(WidgetRef ref) { - useWindowListener( - onWindowClose: () async { - final preferences = ref.read(userPreferencesProvider); - if (preferences.closeBehavior == CloseBehavior.minimizeToTray) { - await windowManager.hide(); - closeNotification?.show(); - } else { - exit(0); - } - }, - ); -} diff --git a/lib/hooks/configurators/use_deep_linking.dart b/lib/hooks/configurators/use_deep_linking.dart deleted file mode 100644 index aaa4111c..00000000 --- a/lib/hooks/configurators/use_deep_linking.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'dart:async'; - -import 'package:app_links/app_links.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/routes.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:flutter_sharing_intent/flutter_sharing_intent.dart'; -import 'package:flutter_sharing_intent/model/sharing_file.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:spotube/utils/platform.dart'; - -final appLinks = AppLinks(); -final linkStream = appLinks.stringLinkStream.asBroadcastStream(); - -@Deprecated( - "Deeplinking is deprecated. Later a custom API for metadata provider will be created.") -void useDeepLinking(WidgetRef ref, AppRouter router) { - // // single instance no worries - // final spotify = ref.watch(spotifyProvider); - - // useEffect(() { - // void uriListener(List files) async { - // for (final file in files) { - // if (file.type != SharedMediaType.URL) continue; - // final url = Uri.parse(file.value!); - // if (url.pathSegments.length != 2) continue; - - // switch (url.pathSegments.first) { - // case "album": - // final album = await spotify.invoke((api) { - // return api.albums.get(url.pathSegments.last); - // }); - // // router.navigate( - // // AlbumRoute(id: album.id!, album: album), - // // ); - // break; - // case "artist": - // router.navigate(ArtistRoute(artistId: url.pathSegments.last)); - // break; - // case "playlist": - // final playlist = await spotify.invoke((api) { - // return api.playlists.get(url.pathSegments.last); - // }); - // // router - // // .navigate(PlaylistRoute(id: playlist.id!, playlist: playlist)); - // break; - // case "track": - // router.navigate(TrackRoute(trackId: url.pathSegments.last)); - // break; - // default: - // break; - // } - // } - // } - - // StreamSubscription? mediaStream; - - // if (kIsMobile) { - // FlutterSharingIntent.instance.getInitialSharing().then(uriListener); - - // mediaStream = - // FlutterSharingIntent.instance.getMediaStream().listen(uriListener); - // } - - // final subscription = linkStream.listen((uri) async { - // try { - // final startSegment = uri.split(":").take(2).join(":"); - // final endSegment = uri.split(":").last; - - // switch (startSegment) { - // case "spotify:album": - // final album = await spotify.invoke((api) { - // return api.albums.get(endSegment); - // }); - // // await router.navigate( - // // AlbumRoute(id: album.id!, album: album), - // // ); - // break; - // case "spotify:artist": - // await router.navigate(ArtistRoute(artistId: endSegment)); - // break; - // case "spotify:track": - // await router.navigate(TrackRoute(trackId: endSegment)); - // break; - // case "spotify:playlist": - // final playlist = await spotify.invoke((api) { - // return api.playlists.get(endSegment); - // }); - // // await router.navigate( - // // PlaylistRoute(id: playlist.id!, playlist: playlist), - // // ); - // break; - // default: - // break; - // } - // } catch (e, stack) { - // AppLogger.reportError(e, stack); - // } - // }); - - // return () { - // mediaStream?.cancel(); - // subscription.cancel(); - // }; - // }, [spotify]); -} diff --git a/lib/hooks/configurators/use_disable_battery_optimizations.dart b/lib/hooks/configurators/use_disable_battery_optimizations.dart deleted file mode 100644 index 4aa51b74..00000000 --- a/lib/hooks/configurators/use_disable_battery_optimizations.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:disable_battery_optimization/disable_battery_optimization.dart'; - -import 'package:spotube/hooks/utils/use_async_effect.dart'; -import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:spotube/utils/platform.dart'; - -void useDisableBatteryOptimizations() { - useAsyncEffect(() async { - if (!kIsAndroid || KVStoreService.askedForBatteryOptimization) return; - - await DisableBatteryOptimization.showDisableBatteryOptimizationSettings(); - - await DisableBatteryOptimization - .showDisableManufacturerBatteryOptimizationSettings( - "Your device has additional battery optimization", - "Follow the steps and disable the optimizations to allow smooth functioning of this app", - ); - - await KVStoreService.setAskedForBatteryOptimization(true); - }, null, []); -} diff --git a/lib/hooks/configurators/use_endless_playback.dart b/lib/hooks/configurators/use_endless_playback.dart deleted file mode 100644 index 9e8c191e..00000000 --- a/lib/hooks/configurators/use_endless_playback.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; - -void useEndlessPlayback(WidgetRef ref) { - final playback = ref.watch(audioPlayerProvider.notifier); - final audioPlayerState = ref.watch(audioPlayerProvider); - final endlessPlayback = - ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback)); - final metadataPlugin = ref.watch(metadataPluginProvider.future); - - useEffect( - () { - if (!endlessPlayback) return null; - - void listener(int index) async { - try { - final playlist = ref.read(audioPlayerProvider); - if (index != playlist.tracks.length - 1) return; - - final track = playlist.tracks.last; - - final tracks = await (await metadataPlugin)?.track.radio(track.id); - - if (tracks == null || tracks.isEmpty) return; - - await playback.addTracks( - tracks.toList() - ..removeWhere((e) { - final playlist = ref.read(audioPlayerProvider); - final isDuplicate = playlist.tracks.any((t) => t.id == e.id); - return e.id == track.id || isDuplicate; - }), - ); - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - } - - // Sometimes user can change settings for which the currentIndexChanged - // might not be called. So we need to check if the current track is the - // last track and if it is then we need to call the listener manually. - if (audioPlayerState.currentIndex == audioPlayerState.tracks.length - 1 && - audioPlayer.isPlaying) { - listener(audioPlayerState.currentIndex); - } - - final subscription = - audioPlayer.currentIndexChangedStream.listen(listener); - - return subscription.cancel; - }, - [ - metadataPlugin, - playback, - audioPlayerState.tracks, - audioPlayerState.currentIndex, - endlessPlayback, - ], - ); -} diff --git a/lib/hooks/configurators/use_fix_window_stretching.dart b/lib/hooks/configurators/use_fix_window_stretching.dart deleted file mode 100644 index b94098ab..00000000 --- a/lib/hooks/configurators/use_fix_window_stretching.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:window_manager/window_manager.dart'; - -void useFixWindowStretching() { - useEffect(() { - if (!kIsWindows) return; - WidgetsBinding.instance.addPostFrameCallback((Duration timeStamp) async { - await Future.delayed(const Duration(milliseconds: 100), () { - windowManager.getSize().then((Size value) { - windowManager.setSize( - Size(value.width + 1, value.height + 1), - ); - }); - }); - }); - - return null; - }, []); -} diff --git a/lib/hooks/configurators/use_get_storage_perms.dart b/lib/hooks/configurators/use_get_storage_perms.dart deleted file mode 100644 index f860aaa7..00000000 --- a/lib/hooks/configurators/use_get_storage_perms.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:device_info_plus/device_info_plus.dart'; - -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:spotube/hooks/utils/use_async_effect.dart'; -import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; -import 'package:spotube/utils/platform.dart'; - -void useGetStoragePermissions(WidgetRef ref) { - final context = useContext(); - - useAsyncEffect( - () async { - if (kIsAndroid) { - final androidInfo = await DeviceInfoPlugin().androidInfo; - - final hasNoStoragePerm = androidInfo.version.sdkInt < 33 && - !await Permission.storage.isGranted && - !await Permission.storage.isLimited; - - final hasNoAudioPerm = androidInfo.version.sdkInt >= 33 && - !await Permission.audio.isGranted && - !await Permission.audio.isLimited; - - if (hasNoStoragePerm) { - await Permission.storage.request(); - if (context.mounted) ref.invalidate(localTracksProvider); - } - if (hasNoAudioPerm) { - await Permission.audio.request(); - if (context.mounted) ref.invalidate(localTracksProvider); - } - } - - if (kIsIOS) { - final hasStoragePerm = await Permission.storage.isGranted || - await Permission.storage.isLimited; - - if (!hasStoragePerm) { - await Permission.storage.request(); - if (context.mounted) ref.invalidate(localTracksProvider); - } - } - }, - null, - [], - ); -} diff --git a/lib/hooks/configurators/use_has_touch.dart b/lib/hooks/configurators/use_has_touch.dart deleted file mode 100644 index 5ce309b8..00000000 --- a/lib/hooks/configurators/use_has_touch.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/utils/platform.dart'; - -bool useHasTouch() { - final hasTouch = useState(kIsMobile); - - useEffect(() { - void globalRoute(PointerEvent event) { - if (hasTouch.value) return; - hasTouch.value = event.kind == PointerDeviceKind.touch || - event.kind == PointerDeviceKind.stylus || - event.kind == PointerDeviceKind.invertedStylus; - } - - WidgetsBinding.instance.addPostFrameCallback((_) { - GestureBinding.instance.pointerRouter.addGlobalRoute(globalRoute); - }); - - return () { - GestureBinding.instance.pointerRouter.removeGlobalRoute(globalRoute); - }; - }, []); - - return hasTouch.value; -} diff --git a/lib/hooks/configurators/use_window_listener.dart b/lib/hooks/configurators/use_window_listener.dart deleted file mode 100644 index 5977ea8e..00000000 --- a/lib/hooks/configurators/use_window_listener.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:window_manager/window_manager.dart'; - -class CallbackWindowListener implements WindowListener { - final VoidCallback? _onWindowClose; - final VoidCallback? _onWindowFocus; - final VoidCallback? _onWindowBlur; - final VoidCallback? _onWindowMaximize; - final VoidCallback? _onWindowUnmaximize; - final VoidCallback? _onWindowMinimize; - final VoidCallback? _onWindowRestore; - final VoidCallback? _onWindowResize; - final VoidCallback? _onWindowResized; - final VoidCallback? _onWindowMove; - final VoidCallback? _onWindowMoved; - final VoidCallback? _onWindowEnterFullScreen; - final VoidCallback? _onWindowLeaveFullScreen; - final VoidCallback? _onWindowDocked; - final VoidCallback? _onWindowUndocked; - final VoidCallback? _onWindowEvent; - - const CallbackWindowListener({ - VoidCallback? onWindowClose, - VoidCallback? onWindowFocus, - VoidCallback? onWindowBlur, - VoidCallback? onWindowMaximize, - VoidCallback? onWindowUnmaximize, - VoidCallback? onWindowMinimize, - VoidCallback? onWindowRestore, - VoidCallback? onWindowResize, - VoidCallback? onWindowResized, - VoidCallback? onWindowMove, - VoidCallback? onWindowMoved, - VoidCallback? onWindowEnterFullScreen, - VoidCallback? onWindowLeaveFullScreen, - VoidCallback? onWindowDocked, - VoidCallback? onWindowUndocked, - VoidCallback? onWindowEvent, - }) : _onWindowClose = onWindowClose, - _onWindowFocus = onWindowFocus, - _onWindowBlur = onWindowBlur, - _onWindowMaximize = onWindowMaximize, - _onWindowUnmaximize = onWindowUnmaximize, - _onWindowMinimize = onWindowMinimize, - _onWindowRestore = onWindowRestore, - _onWindowResize = onWindowResize, - _onWindowResized = onWindowResized, - _onWindowMove = onWindowMove, - _onWindowMoved = onWindowMoved, - _onWindowEnterFullScreen = onWindowEnterFullScreen, - _onWindowLeaveFullScreen = onWindowLeaveFullScreen, - _onWindowDocked = onWindowDocked, - _onWindowUndocked = onWindowUndocked, - _onWindowEvent = onWindowEvent; - - @override - void onWindowBlur() { - return _onWindowBlur?.call(); - } - - @override - void onWindowClose() { - return _onWindowClose?.call(); - } - - @override - void onWindowDocked() { - return _onWindowDocked?.call(); - } - - @override - void onWindowEnterFullScreen() { - return _onWindowEnterFullScreen?.call(); - } - - @override - void onWindowEvent(String eventName) { - return _onWindowEvent?.call(); - } - - @override - void onWindowFocus() { - return _onWindowFocus?.call(); - } - - @override - void onWindowLeaveFullScreen() { - return _onWindowLeaveFullScreen?.call(); - } - - @override - void onWindowMaximize() { - return _onWindowMaximize?.call(); - } - - @override - void onWindowMinimize() { - return _onWindowMinimize?.call(); - } - - @override - void onWindowMove() { - return _onWindowMove?.call(); - } - - @override - void onWindowMoved() { - return _onWindowMoved?.call(); - } - - @override - void onWindowResize() { - return _onWindowResize?.call(); - } - - @override - void onWindowResized() { - return _onWindowResized?.call(); - } - - @override - void onWindowRestore() { - return _onWindowRestore?.call(); - } - - @override - void onWindowUndocked() { - return _onWindowUndocked?.call(); - } - - @override - void onWindowUnmaximize() { - return _onWindowUnmaximize?.call(); - } -} - -void useWindowListener({ - VoidCallback? onWindowClose, - VoidCallback? onWindowFocus, - VoidCallback? onWindowBlur, - VoidCallback? onWindowMaximize, - VoidCallback? onWindowUnmaximize, - VoidCallback? onWindowMinimize, - VoidCallback? onWindowRestore, - VoidCallback? onWindowResize, - VoidCallback? onWindowResized, - VoidCallback? onWindowMove, - VoidCallback? onWindowMoved, - VoidCallback? onWindowEnterFullScreen, - VoidCallback? onWindowLeaveFullScreen, - VoidCallback? onWindowDocked, - VoidCallback? onWindowUndocked, - VoidCallback? onWindowEvent, -}) { - useEffect(() { - if (!kIsDesktop) return null; - - final listener = CallbackWindowListener( - onWindowClose: onWindowClose, - onWindowFocus: onWindowFocus, - onWindowBlur: onWindowBlur, - onWindowMaximize: onWindowMaximize, - onWindowUnmaximize: onWindowUnmaximize, - onWindowMinimize: onWindowMinimize, - onWindowRestore: onWindowRestore, - onWindowResize: onWindowResize, - onWindowResized: onWindowResized, - onWindowMove: onWindowMove, - onWindowMoved: onWindowMoved, - onWindowEnterFullScreen: onWindowEnterFullScreen, - onWindowLeaveFullScreen: onWindowLeaveFullScreen, - onWindowDocked: onWindowDocked, - onWindowUndocked: onWindowUndocked, - onWindowEvent: onWindowEvent, - ); - windowManager.addListener(listener); - return () { - windowManager.removeListener(listener); - }; - }, [ - onWindowClose, - onWindowFocus, - onWindowBlur, - onWindowMaximize, - onWindowUnmaximize, - onWindowMinimize, - onWindowRestore, - onWindowResize, - onWindowResized, - onWindowMove, - onWindowMoved, - onWindowEnterFullScreen, - onWindowLeaveFullScreen, - onWindowDocked, - onWindowUndocked, - onWindowEvent, - ]); -} diff --git a/lib/hooks/controllers/use_auto_scroll_controller.dart b/lib/hooks/controllers/use_auto_scroll_controller.dart deleted file mode 100644 index befc4351..00000000 --- a/lib/hooks/controllers/use_auto_scroll_controller.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:scroll_to_index/scroll_to_index.dart'; - -/// Creates [AutoScrollController] that will be disposed automatically. -/// -/// See also: -/// - [AutoScrollController] -AutoScrollController useAutoScrollController({ - double initialScrollOffset = 0.0, - bool keepScrollOffset = true, - String? debugLabel, - Axis? axis, - AutoScrollController? copyTagsFrom, - double? suggestedRowHeight, - Rect Function() viewportBoundaryGetter = defaultViewportBoundaryGetter, - List? keys, -}) { - return use( - _AutoScrollControllerHook( - initialScrollOffset: initialScrollOffset, - keepScrollOffset: keepScrollOffset, - debugLabel: debugLabel, - axis: axis, - copyTagsFrom: copyTagsFrom, - suggestedRowHeight: suggestedRowHeight, - viewportBoundaryGetter: viewportBoundaryGetter, - keys: keys, - ), - ); -} - -class _AutoScrollControllerHook extends Hook { - const _AutoScrollControllerHook({ - required this.initialScrollOffset, - required this.keepScrollOffset, - required this.viewportBoundaryGetter, - this.axis, - this.copyTagsFrom, - this.suggestedRowHeight, - this.debugLabel, - super.keys, - }); - - final double initialScrollOffset; - final bool keepScrollOffset; - final String? debugLabel; - final Axis? axis; - final AutoScrollController? copyTagsFrom; - final double? suggestedRowHeight; - final Rect Function() viewportBoundaryGetter; - - @override - HookState> createState() => - _AutoScrollControllerHookState(); -} - -class _AutoScrollControllerHookState - extends HookState { - late final AutoScrollController controller; - - @override - void initHook() { - super.initHook(); - controller = AutoScrollController( - initialScrollOffset: hook.initialScrollOffset, - keepScrollOffset: hook.keepScrollOffset, - debugLabel: hook.debugLabel, - axis: hook.axis, - copyTagsFrom: hook.copyTagsFrom, - suggestedRowHeight: hook.suggestedRowHeight, - viewportBoundaryGetter: hook.viewportBoundaryGetter, - ); - } - - @override - AutoScrollController build(BuildContext context) => controller; - - @override - void dispose() => controller.dispose(); - - @override - String get debugLabel => 'useAutoScrollController'; -} diff --git a/lib/hooks/controllers/use_package_info.dart b/lib/hooks/controllers/use_package_info.dart deleted file mode 100644 index 07b53af6..00000000 --- a/lib/hooks/controllers/use_package_info.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:package_info_plus/package_info_plus.dart'; - -PackageInfo usePackageInfo({ - /// The app name. `CFBundleDisplayName` on iOS, `application/label` on Android. - String appName = 'Unknown', - - /// The package name. `bundleIdentifier` on iOS, `getPackageName` on Android. - String packageName = 'Unknown', - - /// The package version. `CFBundleShortVersionString` on iOS, `versionName` on Android. - String version = 'Unknown', - - /// The build number. `CFBundleVersion` on iOS, `versionCode` on Android. - String buildNumber = 'Unknown', - - /// The build signature. Empty string on iOS, signing key signature (hex) on Android. - String buildSignature = '', - List? keys, -}) { - return use( - _PackageInfoHook( - appName: appName, - buildNumber: buildNumber, - packageName: packageName, - version: version, - buildSignature: buildSignature, - keys: keys, - ), - ); -} - -class _PackageInfoHook extends Hook { - final String appName; - final String packageName; - final String version; - final String buildNumber; - final String buildSignature; - - const _PackageInfoHook({ - required this.appName, - required this.packageName, - required this.version, - required this.buildNumber, - this.buildSignature = '', - super.keys, - }); - - @override - HookState> createState() => - _PackageInfoHookState(); -} - -class _PackageInfoHookState - extends HookState> { - late PackageInfo info = PackageInfo( - appName: hook.appName, - buildNumber: hook.buildNumber, - packageName: hook.packageName, - version: hook.version, - ); - - @override - void initHook() { - PackageInfo.fromPlatform().then((packageInfo) { - setState(() { - info = packageInfo; - }); - }); - super.initHook(); - } - - @override - PackageInfo build(BuildContext context) => info; - - @override - String get debugLabel => 'usePagingController'; -} diff --git a/lib/hooks/controllers/use_shadcn_text_editing_controller.dart b/lib/hooks/controllers/use_shadcn_text_editing_controller.dart deleted file mode 100644 index ae33f4e4..00000000 --- a/lib/hooks/controllers/use_shadcn_text_editing_controller.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -class _TextEditingControllerHookCreator { - const _TextEditingControllerHookCreator(); - - /// Creates a [TextEditingController] that will be disposed automatically. - /// - /// The [text] parameter can be used to set the initial value of the - /// controller. - TextEditingController call({String? text, List? keys}) { - return use(_TextEditingControllerHook(text, keys)); - } - - /// Creates a [TextEditingController] from the initial [value] that will - /// be disposed automatically. - TextEditingController fromValue( - TextEditingValue value, [ - List? keys, - ]) { - return use(_TextEditingControllerHook.fromValue(value, keys)); - } -} - -/// Creates a [TextEditingController], either via an initial text or an initial -/// [TextEditingValue]. -/// -/// To use a [TextEditingController] with an optional initial text, use: -/// ```dart -/// final controller = useTextEditingController(text: 'initial text'); -/// ``` -/// -/// To use a [TextEditingController] with an optional initial value, use: -/// ```dart -/// final controller = useTextEditingController -/// .fromValue(TextEditingValue.empty); -/// ``` -/// -/// Changing the text or initial value after the widget has been built has no -/// effect whatsoever. To update the value in a callback, for instance after a -/// button was pressed, use the [TextEditingController.text] or -/// [TextEditingController.value] setters. To have the [TextEditingController] -/// reflect changing values, you can use [useEffect]. This example will update -/// the [TextEditingController.text] whenever a provided [ValueListenable] -/// changes: -/// ```dart -/// final controller = useTextEditingController(); -/// final update = useValueListenable(myTextControllerUpdates); -/// -/// useEffect(() { -/// controller.text = update; -/// }, [update]); -/// ``` -/// -/// See also: -/// - [TextEditingController], which this hook creates. -const useShadcnTextEditingController = _TextEditingControllerHookCreator(); - -class _TextEditingControllerHook extends Hook { - const _TextEditingControllerHook( - this.initialText, [ - List? keys, - ]) : initialValue = null, - super(keys: keys); - - const _TextEditingControllerHook.fromValue( - TextEditingValue this.initialValue, [ - List? keys, - ]) : initialText = null, - super(keys: keys); - - final String? initialText; - final TextEditingValue? initialValue; - - @override - _TextEditingControllerHookState createState() { - return _TextEditingControllerHookState(); - } -} - -class _TextEditingControllerHookState - extends HookState { - late final _controller = hook.initialValue != null - ? TextEditingController.fromValue( - hook.initialValue ?? TextEditingValue.empty, - ) - : TextEditingController(text: hook.initialText); - - @override - TextEditingController build(BuildContext context) => _controller; - - @override - void dispose() => _controller.dispose(); - - @override - String get debugLabel => 'useTextEditingController'; -} diff --git a/lib/hooks/utils/use_async_effect.dart b/lib/hooks/utils/use_async_effect.dart deleted file mode 100644 index 8af25543..00000000 --- a/lib/hooks/utils/use_async_effect.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_hooks/flutter_hooks.dart'; - -void useAsyncEffect( - FutureOr Function() effect, [ - FutureOr Function()? cleanup, - List? keys, -]) { - useEffect(() { - Future.microtask(effect); - return () { - if (cleanup != null) { - Future.microtask(cleanup); - } - }; - }, keys); -} diff --git a/lib/hooks/utils/use_breakpoint_value.dart b/lib/hooks/utils/use_breakpoint_value.dart deleted file mode 100644 index 74b2f860..00000000 --- a/lib/hooks/utils/use_breakpoint_value.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/extensions/constrains.dart'; - -T useBreakpointValue({ - T? xs, - T? sm, - T? md, - T? lg, - T? xl, - T? xxl, - T? others, -}) { - final isSomeNull = xs == null || - sm == null || - md == null || - lg == null || - xl == null || - xxl == null; - assert( - (isSomeNull && others != null) || (!isSomeNull && others == null), - 'You must provide a value for all breakpoints or a default value for others', - ); - final context = useContext(); - final mediaQuery = MediaQuery.of(context); - - if (isSomeNull) { - if (mediaQuery.isXs) { - return xs ?? others!; - } else if (mediaQuery.isSm) { - return sm ?? others!; - } else if (mediaQuery.isMd) { - return md ?? others!; - } else if (mediaQuery.isXl) { - return xl ?? others!; - } else if (mediaQuery.is2Xl) { - return xxl ?? others!; - } else { - return lg ?? others!; - } - } else { - if (mediaQuery.isXs) { - return xs; - } else if (mediaQuery.isSm) { - return sm; - } else if (mediaQuery.isMd) { - return md; - } else if (mediaQuery.isXl) { - return xl; - } else if (mediaQuery.is2Xl) { - return xxl; - } else { - return lg; - } - } -} diff --git a/lib/hooks/utils/use_brightness_value.dart b/lib/hooks/utils/use_brightness_value.dart deleted file mode 100644 index 64e3f27c..00000000 --- a/lib/hooks/utils/use_brightness_value.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -T useBrightnessValue( - T lightValue, - T darkValue, -) { - final context = useContext(); - - return Theme.of(context).brightness == Brightness.light - ? lightValue - : darkValue; -} diff --git a/lib/hooks/utils/use_custom_status_bar_color.dart b/lib/hooks/utils/use_custom_status_bar_color.dart deleted file mode 100644 index f34ae7a8..00000000 --- a/lib/hooks/utils/use_custom_status_bar_color.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -VoidCallback useCustomStatusBarColor( - Color color, - bool isCurrentRoute, { - bool noSetBGColor = false, - bool? automaticSystemUiAdjustment, -}) { - final context = useContext(); - final backgroundColor = Theme.of(context).colorScheme.background; - // ignore: invalid_use_of_visible_for_testing_member - final previousState = SystemChrome.latestStyle; - - void resetStatusbar() => previousState != null - ? SystemChrome.setSystemUIOverlayStyle(previousState) - : SystemChrome.setSystemUIOverlayStyle( - SystemUiOverlayStyle( - statusBarColor: backgroundColor, // status bar color - statusBarIconBrightness: backgroundColor.computeLuminance() > 0.179 - ? Brightness.dark - : Brightness.light, - ), - ); - - // ignore: invalid_use_of_visible_for_testing_member - final statusBarColor = SystemChrome.latestStyle?.statusBarColor; - - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (automaticSystemUiAdjustment != null) { - // ignore: deprecated_member_use - WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = - automaticSystemUiAdjustment; - } - if (isCurrentRoute && statusBarColor != color) { - final isLight = color.computeLuminance() > 0.179; - SystemChrome.setSystemUIOverlayStyle( - SystemUiOverlayStyle( - statusBarColor: - noSetBGColor ? Colors.transparent : color, // status bar color - statusBarIconBrightness: - isLight ? Brightness.dark : Brightness.light, - ), - ); - } else if (!isCurrentRoute && statusBarColor == color) { - resetStatusbar(); - } - }); - return () { - if (automaticSystemUiAdjustment != null) { - // ignore: deprecated_member_use - WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = false; - } - }; - }, [color, isCurrentRoute, statusBarColor]); - - useEffect(() { - return resetStatusbar; - }, []); - - return resetStatusbar; -} diff --git a/lib/hooks/utils/use_debounce.dart b/lib/hooks/utils/use_debounce.dart deleted file mode 100644 index 5eb859a1..00000000 --- a/lib/hooks/utils/use_debounce.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_hooks/flutter_hooks.dart'; - -T useDebounce( - T value, [ - Duration delay = const Duration(milliseconds: 500), -]) { - final state = useState(value); - - useEffect(() { - final timer = Timer(delay, () => state.value = value); - return timer.cancel; - }, [value, delay]); - - return state.value; -} diff --git a/lib/hooks/utils/use_force_update.dart b/lib/hooks/utils/use_force_update.dart deleted file mode 100644 index 268f0f04..00000000 --- a/lib/hooks/utils/use_force_update.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; - -void Function() useForceUpdate() { - final state = useState(null); - // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member - return () => state.notifyListeners(); -} diff --git a/lib/hooks/utils/use_palette_color.dart b/lib/hooks/utils/use_palette_color.dart deleted file mode 100644 index c70bcf72..00000000 --- a/lib/hooks/utils/use_palette_color.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; -import 'package:spotube/components/image/universal_image.dart'; - -final _paletteColorState = StateProvider( - (ref) { - return PaletteColor(Colors.gray[300], 0); - }, -); - -PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) { - final context = useContext(); - final theme = Theme.of(context); - final paletteColor = ref.watch(_paletteColorState); - - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - final palette = await PaletteGenerator.fromImageProvider( - UniversalImage.imageProvider( - imageUrl, - height: 50, - width: 50, - ), - ); - if (!context.mounted) return; - final color = theme.brightness == Brightness.light - ? palette.lightMutedColor ?? palette.lightVibrantColor - : palette.darkMutedColor ?? palette.darkVibrantColor; - if (color != null) { - ref.read(_paletteColorState.notifier).state = color; - } - }); - return null; - }, [imageUrl]); - - return paletteColor; -} - -PaletteGenerator usePaletteGenerator(String imageUrl) { - final palette = useState(PaletteGenerator.fromColors([])); - final context = useContext(); - - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - final newPalette = await PaletteGenerator.fromImageProvider( - UniversalImage.imageProvider( - imageUrl, - height: 50, - width: 50, - ), - ); - if (!context.mounted) return; - - palette.value = newPalette; - }); - return null; - }, [imageUrl]); - - return palette.value; -} diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb deleted file mode 100644 index f1997517..00000000 --- a/lib/l10n/app_ar.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "ضيف", - "browse": "تصفح", - "search": "بحث", - "library": "مكتبة", - "lyrics": "كلمات", - "settings": "إعدادات", - "genre_categories_filter": "تصفية الفئات أو الأنواع...", - "genre": "النوع", - "personalized": "شخصية", - "featured": "متميز", - "new_releases": "الإصدارات الجديدة", - "songs": "أغاني", - "playing_track": "تشغيل {track}", - "queue_clear_alert": "سيؤدي هذا إلى مسح قائمة الانتظار الحالية. {track_length} ستتم إزالة المقطوعات\nهل تريد الإستمرار؟", - "load_more": "تحميل المزيد", - "playlists": "قوائم التشغيل", - "artists": "فنانون", - "albums": "ألبومات", - "tracks": "مقطوعات", - "downloads": "تنزيلات", - "filter_playlists": "تصفية قوائم التشغيل الخاصة بك...", - "liked_tracks": "المقطوعات التي أعجبتك", - "liked_tracks_description": "جميع المقطوعات التي أعجبتك", - "create_playlist": "إنشاء قائمة التشغيل", - "create_a_playlist": "إنشاء قائمة تشغيل", - "update_playlist": "تحديث قائمة التشغيل", - "create": "إنشاء", - "cancel": "إلغاء", - "update": "تحديث", - "playlist_name": "اسم قائمة التشغيل", - "name_of_playlist": "اسم قائمة التشغيل", - "description": "وصف", - "public": "عام", - "collaborative": "تعاوني", - "search_local_tracks": "بحث عن مقطوعات محلية", - "play": "تشغيل", - "delete": "حذف", - "none": "لا شيء", - "sort_a_z": "الترتيب من A-Z", - "sort_z_a": "الترتيب من Z-A", - "sort_artist": "الترتيب حسب الفنان", - "sort_album": "فرز حسب الألبوم", - "sort_tracks": "ترتيب المقطوعات", - "currently_downloading": "يتم التنزيل ({tracks_length})", - "cancel_all": "إلغاء الكل", - "filter_artist": "تصفية الفنانين...", - "followers": "{followers} متابعون", - "add_artist_to_blacklist": "إضافة فنان إلى القائمة السوداء", - "top_tracks": "أهم المقطوعات الصوتية", - "fans_also_like": "المعجبون يحبون أيضاً", - "loading": "جارٍ التحميل", - "artist": "فنان", - "blacklisted": "في القائمة السوداء", - "following": "يتابع", - "follow": "تابع", - "artist_url_copied": "تم نسخ عنوان URL للفنان إلى الحافظة", - "added_to_queue": "تم إضافة المقطوعات إلى قائمة الإنتظار {tracks}", - "filter_albums": "تصفية الألبومات...", - "synced": "تم المزامنة", - "plain": "سهل", - "shuffle": "خلط", - "search_tracks": "يحث عن مقطوعات", - "released": "تم الإصدار", - "error": "خطأ {error}", - "title": "عنوان", - "time": "وقت", - "more_actions": "المزيد من الإجراءات", - "download_count": "تنزيل ({count})", - "add_count_to_playlist": "إضافة ({count}) إلى قائمة التشغيل", - "add_count_to_queue": "إضافة ({count}) إلى قائمة الإنتظار", - "play_count_next": "تشغيل ({count}) التالي", - "album": "ألبوم", - "copied_to_clipboard": "تم النسخ {data} إلى الحافظة", - "add_to_following_playlists": "إضافة {track} إلى قوائم التشغيل التالية", - "add": "إضافة", - "added_track_to_queue": "تم الإضافة {track} إلى قائمة الإنتظار", - "add_to_queue": "إضافة إلى قائمة التشغيل", - "track_will_play_next": "{track} سيتم تشغيل التالي", - "play_next": "تشغيل التالي", - "removed_track_from_queue": "تم الإزالة {track} من قائمة الإنتظار", - "remove_from_queue": "إزالة من قائمة الإنتظار", - "remove_from_favorites": "إزالة من المفضلة", - "save_as_favorite": "حفظ كمفضل", - "add_to_playlist": "إضافة إلى قائمة التشغيل", - "remove_from_playlist": "إزالة من قائمة التشغيل", - "add_to_blacklist": "إضافة إلى القائمة السوداء", - "remove_from_blacklist": "إزالة من القائمة السوداء", - "share": "مشاكرة", - "mini_player": "مشغل مصغر", - "slide_to_seek": "قم بالتمرير للبحث للأمام أو للخلف", - "shuffle_playlist": "قائمة تشغيل عشوائية", - "unshuffle_playlist": "إلغاء ترتيب قائمة التشغيل", - "previous_track": "المقطوعة السابقة", - "next_track": "مقطوعة جديدة", - "pause_playback": "إيقاف التشغيل مؤقتًا", - "resume_playback": "استئناف التشغيل", - "loop_track": "تشغيل المقطوعة بشكل لا نهائي", - "repeat_playlist": "تكرار قائمة التشغيل", - "queue": "قائمة الإنتظار", - "alternative_track_sources": "مصادر مقطوعات بديلة", - "download_track": "تنزيل المقطوعة", - "tracks_in_queue": "{tracks} المقطوعات في قائمة الإنتظار", - "clear_all": "مسح الكل", - "show_hide_ui_on_hover": "إظهار/إخفاء واجهة المستخدم عند التمرير", - "always_on_top": "دائما في القمة", - "exit_mini_player": "خروج من المشغل المصغر", - "download_location": "تنزيل الموقع", - "account": "حساب", - "login_with_spotify": "تسجيل الدخول بواسطة حساب Spotify", - "connect_with_spotify": "توصيل بـSpotify", - "logout": "تسجيل الخروج", - "logout_of_this_account": "تسجيل الخروج من هذا الحساب", - "language_region": "اللغة والمنطقة", - "language": "لغة", - "system_default": "لغة النظام الإفتراضية", - "market_place_region": "منطقة السوق", - "recommendation_country": "بلد التوصية", - "appearance": "مظهر", - "layout_mode": "وضع التخطيط", - "override_layout_settings": "تجاوز إعدادات وضع التخطيط سريع الاستجابة", - "adaptive": "متكيف", - "compact": "مدمج", - "extended": "ممتد", - "theme": "مظهر", - "dark": "داكن", - "light": "ساطعt", - "system": "حسب النظام", - "accent_color": "لون تمييز", - "sync_album_color": "مزامنة لون الألبوم", - "sync_album_color_description": "يستخدم اللون السائد لصورة الألبوم باعتباره لون التمييز", - "playback": "التشغيل", - "audio_quality": "جودة الصوت", - "high": "مرتفعة", - "low": "منخفضة", - "pre_download_play": "التحميل المسبق والتشغيل", - "pre_download_play_description": "بدلاً من دفق الصوت، قم بتنزيل وحدات البايت وتشغيلها بدلاً من ذلك (موصى به لمستخدمي Bandwidth)", - "skip_non_music": "تخطي المقاطع غير الموسيقية (SponsorBlock)", - "blacklist_description": "المقطوعات والفنانون المدرجون في القائمة السوداء", - "wait_for_download_to_finish": "يرجى الانتظار حتى انتهاء التنزيل الحالي", - "desktop": "سطح المكتب", - "close_behavior": "إغلاق التصرف", - "close": "إغلاق", - "minimize_to_tray": "تصغير إلى الدرج", - "show_tray_icon": "إظهار أيقونات درج النظام", - "about": "حول", - "u_love_spotube": "نحن نعلم أنك تحب Spotube", - "check_for_updates": "تحقق من وجود تحديثات", - "about_spotube": "حول Spotube", - "blacklist": "قائمة سوداء", - "please_sponsor": "يرجى دعم/التبرع", - "spotube_description": "Spotube، عميل Spotify خفيف الوزن ومتعدد المنصات ومجاني للجميع", - "version": "إصدار", - "build_number": "رقم البنية", - "founder": "الموئسس", - "repository": "المستودع", - "bug_issues": "أخطاء+مشاكل", - "made_with": "صُنع باستخدام ❤️ في بنغلاديش🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "الترخيص", - "add_spotify_credentials": "أضف بيانات Spotify الخاصة بك للبدء", - "credentials_will_not_be_shared_disclaimer": "لا تقلق، لن يتم جمع أي من بيانات الخاصة بك أو مشاركتها مع أي شخص", - "know_how_to_login": "لا تعرف كيف تفعل هذا؟", - "follow_step_by_step_guide": "اتبع الدليل خطوة بخطوة", - "spotify_cookie": "Spotify {name} كوكيز", - "cookie_name_cookie": "{name} كوكيز", - "fill_in_all_fields": "يرجى تعبئة جميع الحقول", - "submit": "إرسال", - "exit": "خروج", - "previous": "السابق", - "next": "التالي", - "done": "تم", - "step_1": "الخطوة 1", - "first_go_to": "أولا، اذهب إلى", - "login_if_not_logged_in": "وتسجيل الدخول/الاشتراك إذا لم تقم بتسجيل الدخول", - "step_2": "الخطوة 2", - "step_2_steps": "1. بمجرد تسجيل الدخول، اضغط على F12 أو انقر بزر الماوس الأيمن > فحص لفتح أدوات تطوير المتصفح.\n2. ثم انتقل إلى علامة التبويب \"التطبيقات\" (Chrome وEdge وBrave وما إلى ذلك.) أو علامة التبويب \"التخزين\" (Firefox وPalemoon وما إلى ذلك..)\n3. انتقل إلى قسم \"ملفات تعريف الارتباط\" ثم القسم الفرعي \"https://accounts.spotify.com\"", - "step_3": "الخطوة 3", - "success_emoji": "نجاح 🥳", - "success_message": "لقد قمت الآن بتسجيل الدخول بنجاح باستخدام حساب Spotify الخاص بك. عمل جيد يا صديقي!", - "step_4": "الخطوة 4", - "something_went_wrong": "هناك خطأ ما", - "piped_instance": "مثيل خادم Piped", - "piped_description": "مثيل خادم Piped الذي سيتم استخدامه لمطابقة المقطوعة", - "piped_warning": "البعض منهم قد لا يعمل بشكل جيد. لذلك استخدمه على مسؤوليتك", - "generate_playlist": "إنشاء قائمة التشغيل", - "track_exists": "المقطوعة {track} بالفعل موجودة", - "replace_downloaded_tracks": "استبدل جميع المقطوعات التي تم تنزيلها", - "skip_download_tracks": "تخطي تنزيل كافة المقطوعات التي تم تنزيلها", - "do_you_want_to_replace": "هل تريد استبدال المقطوعة الحالية؟", - "replace": "إستبدال", - "skip": "تخطي", - "select_up_to_count_type": "إختر ما يصل إلى {count} {type}", - "select_genres": "حدد الأنواع", - "add_genres": "أضف الأنواع", - "country": "دولة", - "number_of_tracks_generate": "عدد المسارات المقطوعات المراد توليدها", - "acousticness": "صوتية", - "danceability": "قدرة على الرقص", - "energy": "طاقة", - "instrumentalness": "نفعية", - "liveness": "حيوية", - "loudness": "بريق", - "speechiness": "كلام", - "valence": "تكافؤ", - "popularity": "شعبية", - "key": "مفتاح", - "duration": "مدة (s)", - "tempo": "Tempo (BPM)", - "mode": "Mode", - "time_signature": "توقيع الوقت", - "short": "قصير", - "medium": "متوسط", - "long": "طويل", - "min": "أدنى", - "max": "أقصى", - "target": "هدف", - "moderate": "معتدل", - "deselect_all": "الغاء تحديد الكل", - "select_all": "اختر الكل", - "are_you_sure": "هل أنت متأكد؟", - "generating_playlist": "جارٍ إنشاء قائمة التشغيل المخصصة...", - "selected_count_tracks": "مقطوعات {count} مختارة", - "download_warning": "إذا قمت بتنزيل جميع المقاطع الصوتية بكميات كبيرة، فمن الواضح أنك تقوم بقرصنة الموسيقى وتسبب الضرر للمجتمع الإبداعي للموسيقى. أتمنى أن تكون على علم بهذا. حاول دائمًا احترام ودعم العمل الجاد للفنان", - "download_ip_ban_warning": "بالمناسبة، يمكن أن يتم حظر عنوان IP الخاص بك على YouTube بسبب طلبات التنزيل الزائدة عن المعتاد. يعني حظر IP أنه لا يمكنك استخدام YouTube (حتى إذا قمت بتسجيل الدخول) لمدة تتراوح بين شهرين إلى ثلاثة أشهر على الأقل من جهاز IP هذا. ولا يتحمل Spotube أي مسؤولية إذا حدث هذا على الإطلاق", - "by_clicking_accept_terms": "بالنقر على \"قبول\"، فإنك توافق على الشروط التالية:", - "download_agreement_1": "أعلم أنني أقوم بقرصنة الموسيقى. انا سيئ", - "download_agreement_2": "سأدعم الفنان أينما أستطيع، وأنا أفعل هذا فقط لأنني لا أملك المال لشراء أعمالهم الفنية", - "download_agreement_3": "أدرك تمامًا أنه يمكن حظر عنوان IP الخاص بي على YouTube ولا أحمل Spotube أو مالكيه/مساهميه المسؤولية عن أي حوادث ناجمة عن الإجراء الحالي الخاص بي", - "decline": "رفض", - "accept": "قبول", - "details": "تفاصيل", - "youtube": "YouTube", - "channel": "قناة", - "likes": "إعجابات", - "dislikes": "عدم الإعجابات", - "views": "مشاهدات", - "streamUrl": "عنوان URL البث", - "stop": "إيقاف", - "sort_newest": "الترتيب حسب الأقدم", - "sort_oldest": "الترتيب حسب الأقدم", - "sleep_timer": "مؤقت النوم", - "mins": "{minutes} دقائق", - "hours": "{hours} ساعات", - "hour": "{hours} ساعة", - "custom_hours": "ساعات مخصصة", - "logs": "سجلات", - "developers": "المطورون", - "not_logged_in": "لم تقم بتسجيل الدخول", - "search_mode": "وضع البحث", - "audio_source": "مصدر الصوت", - "ok": "حسسناً", - "failed_to_encrypt": "فشل في التشفير", - "encryption_failed_warning": "يستخدم Spotube التشفير لتخزين بياناتك بشكل آمن. لكنها فشلت في القيام بذلك. لذلك سيعود الأمر إلى التخزين غير الآمن\nإذا كنت تستخدم Linux، فيرجى التأكد من تثبيت أي خدمة سرية (gnome-keyring، kde-wallet، keepassxc، إلخ)", - "querying_info": "جارٍ الاستعلام عن معلومات...", - "piped_api_down": "Piped API معطلة", - "piped_down_error_instructions": "المثيل الموجه {pipedInstance} معطل حاليًا\n\nيمكنك إما تغيير المثيل أو تغيير 'نوع API' إلى YouTube API الرسمي\n\nتأكد من إعادة تشغيل التطبيق بعد التغيير", - "you_are_offline": "أنت غير متصل حالياً", - "connection_restored": "تمت استعادة اتصالك بالإنترنت", - "use_system_title_bar": "استخدم شريط عنوان النظام", - "crunching_results": "تدمير النتائج", - "search_to_get_results": "إبحث للحصول على النتائج", - "use_amoled_mode": "استخدم وضع AMOLED", - "pitch_dark_theme": "موضوع دارت الأسود الفحمي", - "normalize_audio": "تطبيع الصوت", - "change_cover": "تغيير الغلاف", - "add_cover": "إضافة غلاف", - "restore_defaults": "استعادة الإعدادات الافتراضية", - "download_music_codec": "تنزيل ترميز الموسيقى", - "streaming_music_codec": "ترميز الموسيقى بالتدفق", - "login_with_lastfm": "تسجيل الدخول باستخدام Last.fm", - "connect": "اتصال", - "disconnect_lastfm": "قطع الاتصال بـ Last.fm", - "disconnect": "قطع الاتصال", - "username": "اسم المستخدم", - "password": "كلمة المرور", - "login": "تسجيل الدخول", - "login_with_your_lastfm": "تسجيل الدخول باستخدام حساب Last.fm الخاص بك", - "scrobble_to_lastfm": "تسجيل الاستماع على Last.fm", - "go_to_album": "الانتقال إلى الألبوم", - "discord_rich_presence": "وجود ديسكورد الغني", - "browse_all": "تصفح الكل", - "genres": "الأنواع الموسيقية", - "explore_genres": "استكشاف الأنواع", - "step_3_steps": "انسخ قيمة الكوكي \"sp_dc\"", - "step_4_steps": "الصق قيمة \"sp_dc\" المنسوخة", - "friends": "أصدقاء", - "no_lyrics_available": "عذرًا، تعذر العثور على كلمات الأغنية لهذه العنصر", - "sort_duration": "ترتيب حسب المدة", - "start_a_radio": "بدء راديو", - "how_to_start_radio": "كيف تريد بدء الراديو؟", - "replace_queue_question": "هل تريد استبدال قائمة التشغيل الحالية أم إضافة إليها؟", - "endless_playback": "تشغيل بلا نهاية", - "delete_playlist": "حذف قائمة التشغيل", - "delete_playlist_confirmation": "هل أنت متأكد أنك تريد حذف هذه قائمة التشغيل؟", - "local_tracks": "المسارات المحلية", - "song_link": "رابط الأغنية", - "skip_this_nonsense": "تخطي هذه الهراء", - "freedom_of_music": "“حرية الموسيقى”", - "freedom_of_music_palm": "“حرية الموسيقى في متناول يدك”", - "get_started": "لنبدأ", - "youtube_source_description": "موصى به ويعمل بشكل أفضل.", - "piped_source_description": "تشعر بالحرية؟ نفس يوتيوب ولكن أكثر حرية.", - "jiosaavn_source_description": "الأفضل لمنطقة جنوب آسيا.", - "highest_quality": "أعلى جودة: {quality}", - "select_audio_source": "اختر مصدر الصوت", - "endless_playback_description": "إلحاق الأغاني الجديدة تلقائيًا\nإلى نهاية قائمة التشغيل", - "choose_your_region": "اختر منطقتك", - "choose_your_region_description": "سيساعدك هذا في عرض المحتوى المناسب\nلموقعك.", - "choose_your_language": "اختر لغتك", - "help_project_grow": "ساعد في نمو هذا المشروع", - "help_project_grow_description": "Spotube هو مشروع مفتوح المصدر. يمكنك مساعدة هذا المشروع في النمو عن طريق المساهمة في المشروع، أو الإبلاغ عن الأخطاء، أو اقتراح ميزات جديدة.", - "contribute_on_github": "المساهمة على GitHub", - "donate_on_open_collective": "التبرع على Open Collective", - "browse_anonymously": "تصفح بشكل مجهول", - "enable_connect": "تمكين الاتصال", - "enable_connect_description": "التحكم في Spotube من الأجهزة الأخرى", - "devices": "الأجهزة", - "select": "اختر", - "connect_client_alert": "أنت تتم التحكم بواسطة {client}", - "this_device": "هذا الجهاز", - "remote": "بعيد", - "local_library": "المكتبة المحلية", - "add_library_location": "أضف إلى المكتبة", - "remove_library_location": "إزالة من المكتبة", - "local_tab": "محلي", - "stats": "إحصائيات", - "and_n_more": "و {count} أكثر", - "recently_played": "تم تشغيله مؤخرًا", - "browse_more": "تصفح المزيد", - "no_title": "بدون عنوان", - "not_playing": "غير مشغل", - "epic_failure": "فشل كبير!", - "added_num_tracks_to_queue": "تمت إضافة {tracks_length} مسارات إلى قائمة الانتظار", - "spotube_has_an_update": "يوجد تحديث لسبوتيوب", - "download_now": "تحميل الآن", - "nightly_version": "تم إصدار سبوتيوب الليلي {nightlyBuildNum}", - "release_version": "تم إصدار سبوتيوب v{version}", - "read_the_latest": "اقرأ الأحدث", - "release_notes": "ملاحظات الإصدار", - "pick_color_scheme": "اختر نظام الألوان", - "save": "حفظ", - "choose_the_device": "اختر الجهاز:", - "multiple_device_connected": "تم توصيل أجهزة متعددة.\nاختر الجهاز الذي تريد إجراء هذه العملية عليه", - "nothing_found": "لم يتم العثور على شيء", - "the_box_is_empty": "الصندوق فارغ", - "top_artists": "أفضل الفنانين", - "top_albums": "أفضل الألبومات", - "this_week": "هذا الأسبوع", - "this_month": "هذا الشهر", - "last_6_months": "آخر 6 أشهر", - "this_year": "هذا العام", - "last_2_years": "آخر سنتين", - "all_time": "كل الوقت", - "powered_by_provider": "مدعوم من {providerName}", - "email": "البريد الإلكتروني", - "profile_followers": "المتابعين", - "birthday": "عيد الميلاد", - "subscription": "اشتراك", - "not_born": "لم يولد", - "hacker": "هاكر", - "profile": "الملف الشخصي", - "no_name": "بدون اسم", - "edit": "تعديل", - "user_profile": "ملف المستخدم", - "count_plays": "{count} تشغيلات", - "streaming_fees_hypothetical": "رسوم البث (افتراضية)", - "minutes_listened": "الدقائق المستمعة", - "streamed_songs": "الأغاني المذاعة", - "count_streams": "{count} بث", - "owned_by_you": "مملوك لك", - "copied_shareurl_to_clipboard": "تم نسخ {shareUrl} إلى الحافظة", - "spotify_hipotetical_calculation": "*هذا محسوب بناءً على الدفع لكل بث من سبوتيفاي\nبقيمة 0.003 إلى 0.005 دولار. هذا حساب افتراضي\nلإعطاء المستخدم فكرة عن المبلغ الذي\nكان سيدفعه للفنانين إذا كانوا قد استمعوا\nإلى أغنيتهم على سبوتيفاي.", - "count_mins": "{minutes} دقيقة", - "summary_minutes": "الدقائق", - "summary_listened_to_music": "استمعت إلى الموسيقى", - "summary_songs": "أغاني", - "summary_streamed_overall": "بث بشكل عام", - "summary_owed_to_artists": "مدين للفنانين\nهذا الشهر", - "summary_artists": "الفنانين", - "summary_music_reached_you": "وصلت إليك الموسيقى", - "summary_full_albums": "ألبومات كاملة", - "summary_got_your_love": "حصلت على حبك", - "summary_playlists": "قوائم التشغيل", - "summary_were_on_repeat": "كانت على التكرار", - "total_money": "المجموع {money}", - "webview_not_found": "لم يتم العثور على Webview", - "webview_not_found_description": "لم يتم تثبيت بيئة تشغيل Webview على جهازك.\nإذا كانت مثبتة، تأكد من وجودها في environment PATH\n\nبعد التثبيت، أعد تشغيل التطبيق", - "unsupported_platform": "المنصة غير مدعومة", - "invidious_instance": "مثيل خادم Invidious", - "invidious_description": "مثيل خادم Invidious المستخدم لمطابقة المسارات", - "invidious_warning": "قد لا تعمل بعض الخوادم بشكل جيد. استخدمها على مسؤوليتك الخاصة", - "invidious_source_description": "مشابه لـ Piped ولكن بتوافر أعلى", - "cache_music": "تخزين الموسيقى مؤقتًا", - "open": "فتح", - "cache_folder": "مجلد التخزين المؤقت", - "export": "تصدير", - "clear_cache": "مسح التخزين المؤقت", - "clear_cache_confirmation": "هل تريد مسح التخزين المؤقت؟", - "export_cache_files": "تصدير الملفات المخزنة مؤقتًا", - "found_n_files": "تم العثور على {count} ملف", - "export_cache_confirmation": "هل تريد تصدير هذه الملفات إلى", - "exported_n_out_of_m_files": "تم تصدير {filesExported} من أصل {files} ملفات", - "playlist": "قائمة التشغيل", - "no_loop": "بدون تكرار", - "generate": "إنشاء", - "undo": "تراجع", - "download_all": "تنزيل الكل", - "add_all_to_playlist": "إضافة الكل إلى قائمة التشغيل", - "add_all_to_queue": "إضافة الكل إلى القائمة", - "play_all_next": "تشغيل الكل بعد ذلك", - "pause": "إيقاف مؤقت", - "view_all": "عرض الكل", - "no_tracks_added_yet": "يبدو أنك لم تضف أي مسارات بعد", - "no_tracks": "يبدو أنه لا يوجد أي مسارات هنا", - "no_tracks_listened_yet": "يبدو أنك لم تستمع إلى أي شيء بعد", - "not_following_artists": "أنت لا تتابع أي فنانين", - "no_favorite_albums_yet": "يبدو أنك لم تضف أي ألبومات إلى المفضلة بعد", - "no_logs_found": "لم يتم العثور على سجلات", - "youtube_engine": "محرك يوتيوب", - "youtube_engine_not_installed_title": "{engine} غير مثبت", - "youtube_engine_not_installed_message": "{engine} غير مثبت في نظامك.", - "youtube_engine_set_path": "تأكد من أنه متاح في متغير PATH أو\nحدد المسار الكامل للملف القابل للتنفيذ {engine} أدناه", - "youtube_engine_unix_issue_message": "في أنظمة macOS/Linux/Unix مثل الأنظمة، لن يعمل تعيين المسار في .zshrc/.bashrc/.bash_profile وما إلى ذلك.\nيجب تعيين المسار في ملف تكوين الصدفة", - "download": "تنزيل", - "file_not_found": "الملف غير موجود", - "custom": "مخصص", - "add_custom_url": "إضافة URL مخصص", - "edit_port": "تعديل المنفذ", - "port_helper_msg": "القيمة الافتراضية هي -1 والتي تشير إلى رقم عشوائي. إذا كان لديك جدار ناري مُعد، يُوصى بتعيين هذا.", - "connect_request": "السماح لـ {client} بالاتصال؟", - "connection_request_denied": "تم رفض الاتصال. المستخدم رفض الوصول.", - "hipotetical_calculation": "*تمّ الحساب بمعدّل دفعة تتراوح بين 0.003–0.005 دولار أمريكي لكل تشغيل على منصات الموسيقى عبر الإنترنت. هذا حساب افتراضي لتوضيح للمستخدم مقدار ما كان سيدفعه للفنانين لو استمع إلى أغنيتهم على منصات مختلفة.", - "an_error_occurred": "حدث خطأ", - "copy_to_clipboard": "نسخ إلى الحافظة", - "view_logs": "عرض السجلات", - "retry": "إعادة المحاولة", - "no_default_metadata_provider_selected": "لم تقُم بتعيين مزود بيانات افتراضي", - "manage_metadata_providers": "إدارة مزوّدي البيانات", - "open_link_in_browser": "فتح الرابط في المتصفح؟", - "do_you_want_to_open_the_following_link": "هل ترغب في فتح الرابط التالي؟", - "unsafe_url_warning": "قد يكون فتح الروابط من مصادر غير موثوقة غير آمن. تحرّ الحذر!\nيمكنك أيضًا نسخ الرابط إلى الحافظة.", - "copy_link": "نسخ الرابط", - "building_your_timeline": "جاري بناء المخطط الزمني استنادًا إلى استماعاتك...", - "official": "رسمي", - "author_name": "المؤلّف: {author}", - "third_party": "طرف ثالث", - "plugin_requires_authentication": "تتطلّب الإضافة تسجيل الدخول", - "update_available": "تحديث متوفر", - "supports_scrobbling": "يدعم التتبع (scrobbling)", - "plugin_scrobbling_info": "تقوم هذه الإضافة بتتبع مقاطعك الموسيقية لإنشاء سجل الاستماع الخاص بك.", - "default_plugin": "الافتراضي", - "set_default": "تعيين كافتراضي", - "support": "الدعم", - "support_plugin_development": "دعم تطوير الإضافات", - "can_access_name_api": "- يمكن الوصول إلى واجهة برمجة التطبيقات **{name}**", - "do_you_want_to_install_this_plugin": "هل ترغب في تثبيت هذه الإضافة؟", - "third_party_plugin_warning": "هذه الإضافة من مستودع طرف ثالث. تأكد من موثوقية المصدر قبل التثبيت.", - "author": "المؤلف", - "this_plugin_can_do_following": "يمكن لهذه الإضافة القيام بما يلي", - "install": "تثبيت", - "install_a_metadata_provider": "تثبيت مزوّد بيانات", - "no_tracks_playing": "لا توجد مقاطع تعمل حاليًا", - "synced_lyrics_not_available": "الكلمات المتزامنة غير متوفرة لهذه الأغنية. يُرجى استخدام", - "plain_lyrics": "الكلمات العادية", - "tab_instead": "بدلاً من ذلك، استخدم التبويب.", - "disclaimer": "إخلاء المسؤولية", - "third_party_plugin_dmca_notice": "لا تتحمّل فريق Spotube أي مسؤولية (بما في ذلك القانونية) عن أي من الإضافات “لطرف ثالث”.\nاستخدمها على مسؤوليتك الخاصّة. لأيّة أخطاء/مشكلات، يُرجى الإبلاغ عنها في مستودع الإضافة.\n\nإذا كانت أي إضافة “لطرف ثالث” تنتهك شروط الخدمة أو قانون DMCA الخاص بأي خدمة أو كيان قانوني، فيُرجى طلب اتخاذ إجراء من مؤلف الإضافة أو منصة الاستضافة مثل GitHub/Codeberg. الإضافات المدرجة كـ “لطرف ثالث” هي مفعّلة ومُدارة من المجتمع، وليس لدينا صلاحية إدارتها أو التدخل فيها.\n\n", - "input_does_not_match_format": "المدخل لا يتوافق مع التنسيق المطلوب", - "metadata_provider_plugins": "إضافات مزود البيانات", - "paste_plugin_download_url": "الصق رابط التنزيل أو GitHub/Codeberg أو رابط مباشر لملف .smplug", - "download_and_install_plugin_from_url": "تنزيل وتثبيت الإضافة من رابط", - "failed_to_add_plugin_error": "فشل في إضافة الإضافة: {error}", - "upload_plugin_from_file": "رفع الإضافة من ملف", - "installed": "تم التثبيت", - "available_plugins": "الإضافات المتوفّرة", - "configure_your_own_metadata_plugin": "تهيئة مزوّد بيانات للقائمة/الألبوم/الفنان/المصدر خاص بك", - "audio_scrobblers": "أجهزة تتبع الصوت", - "scrobbling": "التتبع", - "download_music_format": "تنسيق تنزيل الموسيقى", - "streaming_music_format": "تنسيق بث الموسيقى", - "download_music_quality": "جودة تنزيل الموسيقى", - "streaming_music_quality": "جودة بث الموسيقى", - "default_metadata_source": "مصدر البيانات الوصفية الافتراضي", - "set_default_metadata_source": "تعيين مصدر البيانات الوصفية الافتراضي", - "default_audio_source": "مصدر الصوت الافتراضي", - "set_default_audio_source": "تعيين مصدر الصوت الافتراضي", - "plugins": "الإضافات", - "configure_plugins": "قم بتكوين مزود البيانات الوصفية ومكونات مصدر الصوت الخاصة بك", - "source": "المصدر: ", - "uncompressed": "غير مضغوط", - "dab_music_source_description": "لمحبي الصوتيات. يوفر تدفقات صوتية عالية الجودة/بدون فقدان. مطابقة دقيقة للمسارات بناءً على ISRC." -} \ No newline at end of file diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb deleted file mode 100644 index 4d001da1..00000000 --- a/lib/l10n/app_bn.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "অতিথি", - "browse": "ব্রাউজ করুন", - "search": "অনুসন্ধান করুন", - "library": "লাইব্রেরী", - "lyrics": "গানের কথা", - "settings": "সেটিংস", - "genre_categories_filter": "গানের ধরণ বা শ্রেণি খুঁজুন", - "genre": "গানের ধরণ", - "personalized": "আপনার জন্য", - "featured": "বৈশিষ্ট্যযুক্ত", - "new_releases": "সাম্প্রতিক মুক্তি প্রাপ্ত", - "songs": "গান", - "playing_track": "{track} চালানো হচ্ছে", - "queue_clear_alert": "এটি বর্তমান প্লেলিষ্ট সাফ করে দিবে। {track_length}টি গান বাদ দেওয়া হবে\nআপনি কি চালিয়ে যেতে চান?", - "load_more": "আরো লোড করুন", - "playlists": "প্লেলিস্ট", - "artists": "শিল্পী", - "albums": "অ্যালবাম", - "tracks": "গানের ট্র্যাক", - "downloads": "ডাউনলোড", - "filter_playlists": "প্লেলিস্ট অনুসন্ধান করুন...", - "liked_tracks": "পছন্দের গান", - "liked_tracks_description": "আপনার পছন্দের গান সমূহ", - "create_playlist": "প্লেলিস্ট তৈরি করুন", - "create_a_playlist": "একটি প্লেলিস্ট তৈরি করুন", - "create": "তৈরি করুন", - "cancel": "বাতিল করুন", - "playlist_name": "প্লেলিস্টের নাম", - "name_of_playlist": "প্লেলিস্টের নাম", - "description": "বিবরণ", - "public": "পাবলিক", - "collaborative": "সহযোগিতামূলক", - "search_local_tracks": "ডাউনলোডকৃত গান অনুসন্ধান করুন...", - "play": "চালান", - "delete": "মুছে ফেলুন", - "none": "কোনটিই না", - "sort_a_z": "A-Z ক্রমে সাজান", - "sort_z_a": "Z-A ক্রমে সাজান", - "sort_artist": "শিল্পীর ক্রমে সাজান", - "sort_album": "অ্যালবামের ক্রমে সাজান", - "sort_tracks": "গানের ক্রম", - "currently_downloading": "ডাউনলোড করা হচ্ছে ({tracks_length})", - "cancel_all": "সব বাতিল করুন", - "filter_artist": "শিল্পীর অনুসন্ধান করুন...", - "followers": "{followers} অনুসরণকারী", - "add_artist_to_blacklist": "শিল্পীকে ব্ল্যাকলিস্টে যোগ করুন", - "top_tracks": "শীর্ষ গানের ট্র্যাক", - "fans_also_like": "অনুসরণকারীদের পছন্দ", - "loading": "লোড হচ্ছে...", - "artist": "শিল্পী", - "blacklisted": "ব্ল্যাকলিস্টে আছে", - "following": "অনুসরণ করছেন", - "follow": "অনুসরণ করুন", - "artist_url_copied": "শিল্পীর URL কপি করা হয়েছে", - "added_to_queue": "{tracks}টি গানের ট্র্যাক কিউতে যোগ করা হয়েছে", - "filter_albums": "অ্যালবাম অনুসন্ধান করুন...", - "synced": "সময় সুসংগত", - "plain": "অসুসংগত", - "shuffle": "অদলবদল", - "search_tracks": "গান অনুসন্ধান করুন...", - "released": "প্রকাশিত হয়েছে", - "error": "ত্রুটি {error}", - "title": "শিরোনাম", - "time": "সময়", - "more_actions": "আরও অপশন", - "download_count": "ডাউনলোড ({count}টি)", - "add_count_to_playlist": "প্লেলিস্টে যোগ করুন ({count}টি)", - "add_count_to_queue": "কিউতে যোগ করুন ({count}টি)", - "play_count_next": "পরবর্তীতে চালান ({count}টি)", - "album": "অ্যালবাম", - "copied_to_clipboard": "{data} ক্লিপবোর্ডে কপি করা হয়েছে", - "add_to_following_playlists": "নিম্নলিখিত প্লেলিস্টে {track} যোগ করুন", - "add": "যোগ করুন", - "added_track_to_queue": "কিউতে {track} যোগ করা হয়েছে", - "add_to_queue": "কিউতে যোগ করুন", - "track_will_play_next": "{track} পরবর্তীতে চালানো হবে", - "play_next": "পরবর্তীতে চালান", - "removed_track_from_queue": "কিউ থেকে {track} সরিয়ে নেওয়া হয়েছে", - "remove_from_queue": "কিউ থেকে সরান", - "remove_from_favorites": "পছন্দের তালিকা থেকে অপসারণ করুন", - "save_as_favorite": "পছন্দের তালিকায় সংরক্ষণ করুন", - "add_to_playlist": "প্লেলিস্টে যোগ করুন", - "remove_from_playlist": "প্লেলিস্ট থেকে সরান", - "add_to_blacklist": "ব্ল্যাকলিস্টে যোগ করুন", - "remove_from_blacklist": "ব্ল্যাকলিস্ট থেকে সরান", - "share": "শেয়ার করুন", - "mini_player": "মিনি প্লেয়ার", - "slide_to_seek": "গান সামনে বা পিছনে নিতে স্লাইড করুন", - "shuffle_playlist": "প্লেলিস্ট এলোমেলো করুন", - "unshuffle_playlist": "প্লেলিস্ট আগের মতো করুন", - "previous_track": "আগের গানের ট্র্যাক", - "next_track": "পরের গানের ট্র্যাক", - "pause_playback": "গান বন্ধ করুন", - "resume_playback": "গান চালু করুন", - "loop_track": "গান শেষে পুনরায় চালান", - "repeat_playlist": "প্লেলিস্ট শেষে পুনরায় চালান", - "queue": "গানের কিউ", - "alternative_track_sources": "বিকল্প গানের উৎস", - "download_track": "গান ডাউনলোড করুন", - "tracks_in_queue": "{tracks}টি গান কিউতে রয়েছে", - "clear_all": "সব মুছে ফেলুন", - "show_hide_ui_on_hover": "হভার করলে UI দেখান/লুকান", - "always_on_top": "সর্বদা উপরে", - "exit_mini_player": "মিনি প্লেয়ার থেকে বের হয়ে যান", - "download_location": "ডাউনলোড স্থান", - "account": "অ্যাকাউন্ট", - "login_with_spotify": "আপনার Spotify account দিয়ে লগইন করুন", - "connect_with_spotify": "Spotify লগইন", - "logout": "লগআউট করুন", - "logout_of_this_account": "অ্যাকাউন্ট থেকে লগআউট করুন", - "language_region": "ভাষা ও অঞ্চল", - "language": "ভাষা", - "system_default": "সিস্টেম ডিফল্ট", - "market_place_region": "মার্কেটপ্লেস অঞ্চল", - "recommendation_country": "দেশভিত্তিক সঙ্গীত পরামর্শের জন্য দেশ", - "appearance": "রুপ", - "layout_mode": "UI বিন্যাস রূপ", - "override_layout_settings": "প্রতিক্রিয়াশীল UI বিন্যাস রূপের সেটিংস পরিবর্তন করুন", - "adaptive": "অভিযোজিত", - "compact": "আঁটসাঁট UI", - "extended": "বিস্তৃত UI", - "theme": "থিম", - "dark": "অন্ধকার", - "light": "উজ্জল", - "system": "সিস্টেম থিম", - "accent_color": "প্রভাবশালী রং", - "sync_album_color": "অ্যালবাম সুসংগত UI এর রং", - "sync_album_color_description": "অ্যালবাম কভারের প্রভাবশালী রঙ UI অ্যাকসেন্ট রঙ হিসাবে ব্যবহার করে", - "playback": "সংগীতের প্লেব্যাক", - "audio_quality": "শব্দের গুণমান", - "high": "উচ্চ", - "low": "নিম্ন", - "pre_download_play": "আগে গান ডাউনলোড করে পরে চালান ", - "pre_download_play_description": "গান স্ট্রিম করার পরিবর্তে, ডাউনলোড করুন এবং প্লে করুন (উচ্চ ব্যান্ডউইথ ব্যবহারকারীদের জন্য প্রস্তাবিত)", - "skip_non_music": "গানের নন-মিউজিক সেগমেন্ট এড়িয়ে যান (SponsorBlock)", - "blacklist_description": "কালো তালিকাভুক্ত গানের ট্র্যাক এবং শিল্পী", - "wait_for_download_to_finish": "ডাউনলোড শেষ হওয়ার জন্য অপেক্ষা করুন", - "desktop": "ডেস্কটপ", - "close_behavior": "বন্ধ করার প্রক্রিয়া", - "close": "বন্ধ করুন", - "minimize_to_tray": "সিস্টেম ট্রেতে রাখুন", - "show_tray_icon": "সিস্টেম ট্রে আইকন দেখান", - "about": "বিস্তারিত", - "u_love_spotube": "আমরা জানি আপনি Spotube কে ভালবাসেন", - "check_for_updates": "আপডেট চেক করুন", - "about_spotube": "Spotube সম্পর্কে বিস্তারিত", - "blacklist": "কালো তালিকা", - "please_sponsor": "স্পনসর/সহায়তা করুন", - "spotube_description": "Spotube, একটি কর্মদক্ষ, ক্রস-প্ল্যাটফর্ম, বিনামূল্যের জন্য Spotify ক্লায়েন্ট", - "version": "সংস্করণ", - "build_number": "বিল্ড নম্বর", - "founder": "প্রতিষ্ঠাতা", - "repository": "সংগ্রহস্থল", - "bug_issues": "বাগ/সমস্যা", - "made_with": "❤️ দিয়ে বাংলাদেশে🇧🇩 তৈরি", - "kingkor_roy_tirtho": "কিংকর রায় তীর্থ", - "copyright": "© 2021-{current_year} কিংকর রায় তীর্থ", - "license": "লাইসেন্স", - "add_spotify_credentials": "আপনার Spotify লগইন তথ্য যোগ করুন", - "credentials_will_not_be_shared_disclaimer": "চিন্তা করবেন না, আপনার কোনো লগইন তথ্য সংগ্রহ করা হবে না বা কারো সাথে শেয়ার করা হবে না", - "know_how_to_login": "আপনি কিভাবে লগইন করবেন তা জানেন না?", - "follow_step_by_step_guide": "ধাপে ধাপে নির্দেশিকা অনুসরণ করুন", - "spotify_cookie": "Spotify {name} কুকি", - "cookie_name_cookie": "{name} কুকি", - "fill_in_all_fields": "সমস্ত ফর্ম ক্ষেত্র পূরণ করুন", - "submit": "জমা দিন", - "exit": "প্রস্থান", - "previous": "পূর্ববর্তী", - "next": "পরবর্তী", - "done": "সম্পন্ন", - "step_1": "ধাপ 1", - "first_go_to": "প্রথমে যান", - "login_if_not_logged_in": "এবং যদি আপনি লগইন/সাইন-আপ না থাকেন তবে লগইন/সাইন-আপ করুন", - "step_2": "ধাপ 2", - "step_2_steps": "১. একবার আপনি লগ ইন করলে, ব্রাউজার ডেভটুল খুলতে F12 বা মাউসের রাইট ক্লিক > \"Inspect to open Browser DevTools\" টিপুন।\n২. তারপর \"Application\" ট্যাবে যান (Chrome, Edge, Brave etc..) অথবা \"Storage\" Tab (Firefox, Palemoon etc..)\n৩. \"Cookies \" বিভাগে যান তারপর \"https://accounts.spotify.com\" উপবিভাগে যান", - "step_3": "ধাপ 3", - "success_emoji": "আমরা সফল🥳", - "success_message": "এখন আপনি সফলভাবে আপনার Spotify অ্যাকাউন্ট দিয়ে লগ ইন করেছেন। সাধুভাত আপনাকে", - "step_4": "ধাপ 4", - "something_went_wrong": "কিছু ভুল হয়েছে", - "piped_instance": "Piped সার্ভার এড্রেস", - "piped_description": "গান ম্যাচ করার জন্য ব্যবহৃত পাইপড সার্ভার", - "piped_warning": "এগুলোর মধ্যে কিছু ভাল কাজ নাও করতে পারে৷ তাই নিজ দায়িত্বে ব্যবহার করুন", - "generate_playlist": "প্লেলিস্ট তৈরি করুন", - "track_exists": "ট্র্যাক {track} ইতিমধ্যে বিদ্যমান", - "replace_downloaded_tracks": "সমস্ত ডাউনলোড করা ট্র্যাক প্রতিস্থাপন করুন", - "skip_download_tracks": "সমস্ত ডাউনলোড করা ট্র্যাক এ স্কিপ করুন", - "do_you_want_to_replace": "আপনি কি বিদ্যমান ট্র্যাকটি প্রতিস্থাপন করতে চান?", - "replace": "প্রতিস্থাপন করুন", - "skip": "স্কিপ করুন", - "select_up_to_count_type": "{count} {type} পর্যন্ত নির্বাচন করুন", - "select_genres": "গানের ধরণ নির্বাচন করুন", - "add_genres": "গানের ধরণ যুক্ত করুন", - "country": "দেশ", - "number_of_tracks_generate": "উত্পাদিত ট্র্যাকের সংখ্যা", - "acousticness": "অধ্যাত্মিকতা", - "danceability": "নৃত্যমূলকতা", - "energy": "শক্তি", - "instrumentalness": "সাধারণতা", - "liveness": "জীবনমুক্ততা", - "loudness": "স্বরের উচ্চতা", - "speechiness": "বক্তব্যমূলকতা", - "valence": "সন্তোষমূলকতা", - "popularity": "জনপ্রিয়তা", - "key": "কী", - "duration": "সময়কাল (সেকেন্ড)", - "tempo": "গতি (বিপিএম)", - "mode": "মোড", - "time_signature": "সময়ের স্বাক্ষর", - "short": "সংক্ষিপ্ত", - "medium": "মাঝারি", - "long": "দীর্ঘ", - "min": "সর্বনিম্ন", - "max": "সর্বাধিক", - "target": "লক্ষ্য", - "moderate": "মাঝারি", - "deselect_all": "সমস্ত অপচুন করুন", - "select_all": "সমস্ত নির্বাচন করুন", - "are_you_sure": "আপনি কি নিশ্চিত?", - "generating_playlist": "আপনার কাস্টম প্লেলিস্ট তৈরি হচ্ছে...", - "selected_count_tracks": "{count} ট্র্যাক নির্বাচিত", - "download_warning": "যদি আপনি সমস্ত ট্র্যাকগুলি একসঙ্গে ডাউনলোড করেন, তবে আপনি নিশ্চিতভাবে সঙ্গীত চুরি করছেন এবং সৃষ্টিশীল সমাজে ক্ষতি দিচ্ছেন। আমি আশা করি আপনি এটা সম্পর্কে জানেন। সর্বদা, শিল্পীদের কঠিন পরিশ্রমকে সম্মান করতে চেষ্টা করুন এবং সমর্থন করুন", - "download_ip_ban_warning": "তথ্যবিশ্বস্ত করে নেওয়া যায় যে, আপনার IP ঠিকানাটি YouTube দ্বারা স্থানান্তরিত করা হতে পারে যখন সাধারন থেকে বেশি ডাউনলোড অনুরোধ হয়। IP ব্লকের মাধ্যমে আপনি কমপক্ষে ২-৩ মাস ধরে (ঐ IP ডিভাইস থেকে) YouTube ব্যবহার করতে পারবেন না। এবং Spotube কোনও দায়িত্ব সম্পর্কে দায়িত্ব বহন করে না যদি এটি ঘটে।", - "by_clicking_accept_terms": "'গ্রহণ' ক্লিক করে আপনি নিম্নলিখিত শর্তাদি স্বীকার করছেন:", - "download_agreement_1": "আমি জানি আমি সঙ্গীত চুরি করছি। আমি খারাপ", - "download_agreement_2": "আমি কেবলমাত্র তাদের কাজ কেনার জন্য অর্থ নেই কিন্তু যেখানে প্রয়োজন সেখানে আমি শিল্পীদের সমর্থন করব।", - "download_agreement_3": "আমি সম্পূর্ণরূপে জানি যে আমার IP YouTube-তে ব্লক হতে পারে এবং আমি Spotube বা তার মালিকানাধীন কোনও দায়িত্ব পেতে পারিনি আমার বর্তমান ক্রিয়াটি দ্বারা সৃষ্ট দুর্ঘটনা করার জন্য", - "decline": "অগ্রায়ন করুন", - "accept": "গ্রহণ করুন", - "details": "বিস্তারিত", - "youtube": "YouTube", - "channel": "চ্যানেল", - "likes": "লাইক", - "dislikes": "অপছন্দ", - "views": "দর্শনার্থী", - "streamUrl": "স্ট্রিম URL", - "stop": "বন্ধ করুন", - "sort_newest": "নতুনতম অনুসারে সাজান", - "sort_oldest": "পুরানোতম অনুসারে সাজান", - "sleep_timer": "স্লীপ টাইমার", - "mins": "{minutes} মিনিট", - "hours": "{hours} ঘন্টা", - "hour": "{hours} ঘন্টা", - "custom_hours": "কাস্টম ঘন্টা", - "logs": "লগ", - "developers": "ডেভেলপার", - "not_logged_in": "আপনি লগইন করা নেই", - "search_mode": "অনুসন্ধান মোড", - "audio_source": "অডিও উৎস", - "ok": "ঠিক আছে", - "failed_to_encrypt": "এনক্রিপ্ট করা ব্যর্থ হয়েছে", - "encryption_failed_warning": "Spotube আপনার তথ্যগুলি নিরাপদভাবে স্টোর করতে এনক্রিপশন ব্যবহার করে। কিন্তু এটি ব্যর্থ হয়েছে। তাই এটি অনিরাপদ স্টোরে ফলফল হবে\nযদি আপনি Linux ব্যবহার করেন, তবে দয়া করে নিশ্চিত হউন যে আপনার কোনও সিক্রেট-সার্ভিস gnome-keyring, kde-wallet, keepassxc ইত্যাদি ইনস্টল করা আছে", - "querying_info": "তথ্য অনুসন্ধান করা হচ্ছে", - "piped_api_down": "পাইপড API ডাউন আছে", - "piped_down_error_instructions": "বর্তমানে পাইপড ইনস্ট্যান্স {pipedInstance} ডাউন আছে\n\nইনস্ট্যান্স পরিবর্তন করুন অথবা 'API টাইপ' পরিবর্তন করুন অফিসিয়াল ইউটিউব API হতে\n\nপরিবর্তনের পরে অ্যাপটি পুনরায় চালানোর নিশ্চিত করুন", - "you_are_offline": "আপনি বর্তমানে অফলাইন", - "connection_restored": "আপনার ইন্টারনেট সংযোগ পুনরুদ্ধার হয়েছে", - "use_system_title_bar": "সিস্টেম শিরোনাম বার ব্যবহার করুন", - "update_playlist": "প্লেলিস্ট আপডেট করুন", - "update": "আপডেট", - "crunching_results": "ফলাফল বিশ্লেষণ করা হচ্ছে...", - "search_to_get_results": "ফলাফল পেতে খোঁজ করুন", - "use_amoled_mode": "AMOLED মোড ব্যবহার করুন", - "pitch_dark_theme": "পিচ ব্ল্যাক ডার্ট থিম", - "normalize_audio": "অডিও স্তরমান করুন", - "change_cover": "কভার পরিবর্তন করুন", - "add_cover": "কভার যোগ করুন", - "restore_defaults": "ডিফল্ট সেটিংস পুনরুদ্ধার করুন", - "download_music_codec": "সঙ্গীত কোডেক ডাউনলোড করুন", - "streaming_music_codec": "স্ট্রিমিং সঙ্গীত কোডেক", - "login_with_lastfm": "Last.fm দিয়ে লগইন করুন", - "connect": "সংযোগ করুন", - "disconnect_lastfm": "Last.fm সংযোগ বিচ্ছিন্ন করুন", - "disconnect": "সংযোগ বিচ্ছিন্ন করুন", - "username": "ব্যবহারকারীর নাম", - "password": "পাসওয়ার্ড", - "login": "লগইন", - "login_with_your_lastfm": "আপনার Last.fm অ্যাকাউন্ট দিয়ে লগইন করুন", - "scrobble_to_lastfm": "Last.fm এ স্ক্রবল করুন", - "go_to_album": "الانتقال إلى الألبوم", - "discord_rich_presence": "وجود ديسكورد الغني", - "browse_all": "تصفح الكل", - "genres": "الأنواع الموسيقية", - "explore_genres": "استكشاف الأنواع", - "step_3_steps": "কুকি \"sp_dc\" এর মানটি কপি করুন", - "step_4_steps": "কপি করা \"sp_dc\" মানটি পেস্ট করুন", - "friends": "বন্ধু", - "no_lyrics_available": "দুঃখিত, এই ট্র্যাকের জন্য কথা খুঁজে পাওয়া গেলনা", - "sort_duration": "দৈর্ঘ্য অনুযায়ী বাছাই করুন", - "start_a_radio": "রেডিও শুরু করুন", - "how_to_start_radio": "রেডিও কিভাবে শুরু করতে চান?", - "replace_queue_question": "আপনি বর্তমান কিউটি প্রতিস্থাপন করতে চান কিনা বা এর সাথে যুক্ত করতে চান?", - "endless_playback": "অবিরাম প্রচার", - "delete_playlist": "প্লেলিস্ট মুছুন", - "delete_playlist_confirmation": "আপনি কি নিশ্চিত যে আপনি এই প্লেলিস্টটি মুছতে চান?", - "local_tracks": "স্থানীয় ট্র্যাক", - "song_link": "গানের লিংক", - "skip_this_nonsense": "এই বাকবাস পালান", - "freedom_of_music": "“সংগীতের স্বাধীনতা”", - "freedom_of_music_palm": "“তোমার হাতের কাছে সংগীতের স্বাধীনতা”", - "get_started": "শুরু করা যাক", - "youtube_source_description": "প্রস্তাবিত এবং সেরা কাজ করে।", - "piped_source_description": "মন খারাপ? ইউটিউবের মতো আবার ফ্রি।", - "jiosaavn_source_description": "দক্ষিণ এশিয়ান অঞ্চলের জন্য সেরা।", - "highest_quality": "সর্বোচ্চ গুণগতি: {quality}", - "select_audio_source": "অডিও উৎস নির্বাচন করুন", - "endless_playback_description": "নতুন গান নিজে নিজে প্লেলিস্টের শেষে\nসংযুক্ত করুন", - "choose_your_region": "আপনার অঞ্চল নির্বাচন করুন", - "choose_your_region_description": "এটি স্পটুবে আপনাকে আপনার অবস্থানের জন্য ঠিক কন্টেন্ট দেখানোর সাহায্য করবে।", - "choose_your_language": "আপনার ভাষা নির্বাচন করুন", - "help_project_grow": "এই প্রকল্পের বৃদ্ধি করুন", - "help_project_grow_description": "স্পটুব একটি ওপেন সোর্স প্রকল্প। আপনি প্রকল্পে অবদান রাখেন, বাগ রিপোর্ট করেন, বা নতুন বৈশিষ্ট্যগুলি সুপারিশ করেন।", - "contribute_on_github": "গিটহাবে অবদান রাখুন", - "donate_on_open_collective": "ওপেন কলেক্টিভে অনুদান করুন", - "browse_anonymously": "অজানে ব্রাউজ করুন", - "enable_connect": "সংযোগ সক্রিয় করুন", - "enable_connect_description": "অন্যান্য ডিভাইস থেকে Spotube নিয়ন্ত্রণ করুন", - "devices": "ডিভাইস", - "select": "নির্বাচন করুন", - "connect_client_alert": "আপনি {client} দ্বারা নিয়ন্ত্রিত হচ্ছেন", - "this_device": "এই ডিভাইস", - "remote": "রিমোট", - "local_library": "স্থানীয় লাইব্রেরি", - "add_library_location": "লাইব্রেরিতে যোগ করুন", - "remove_library_location": "লাইব্রেরি থেকে সরান", - "local_tab": "স্থানীয়", - "stats": "পরিসংখ্যান", - "and_n_more": "এবং {count} আরও", - "recently_played": "সম্প্রতি বাজানো", - "browse_more": "আরও ব্রাউজ করুন", - "no_title": "কোনো শিরোনাম নেই", - "not_playing": "চালানো হচ্ছে না", - "epic_failure": "বিরাট ব্যর্থতা!", - "added_num_tracks_to_queue": "{tracks_length} ট্র্যাক সারিতে যোগ করা হয়েছে", - "spotube_has_an_update": "স্পটিউবে একটি আপডেট আছে", - "download_now": "এখনই ডাউনলোড করুন", - "nightly_version": "স্পটিউব নাইটলি {nightlyBuildNum} প্রকাশিত হয়েছে", - "release_version": "স্পটিউব v{version} প্রকাশিত হয়েছে", - "read_the_latest": "সর্বশেষ পড়ুন", - "release_notes": "রিলিজ নোট", - "pick_color_scheme": "রঙের থিম নির্বাচন করুন", - "save": "সংরক্ষণ করুন", - "choose_the_device": "ডিভাইস নির্বাচন করুন:", - "multiple_device_connected": "একাধিক ডিভাইস সংযুক্ত রয়েছে।\nযে ডিভাইসে আপনি এই ক্রিয়াটি চালাতে চান সেটি নির্বাচন করুন", - "nothing_found": "কিছুই পাওয়া যায়নি", - "the_box_is_empty": "বাক্সটি খালি", - "top_artists": "শীর্ষ শিল্পী", - "top_albums": "শীর্ষ অ্যালবাম", - "this_week": "এই সপ্তাহ", - "this_month": "এই মাস", - "last_6_months": "গত ৬ মাস", - "this_year": "এই বছর", - "last_2_years": "গত ২ বছর", - "all_time": "সব সময়", - "powered_by_provider": "{providerName} দ্বারা চালিত", - "email": "ইমেইল", - "profile_followers": "অনুসারী", - "birthday": "জন্মদিন", - "subscription": "সাবস্ক্রিপশন", - "not_born": "জন্মগ্রহণ করেনি", - "hacker": "হ্যাকার", - "profile": "প্রোফাইল", - "no_name": "কোন নাম নেই", - "edit": "সম্পাদনা করুন", - "user_profile": "ব্যবহারকারীর প্রোফাইল", - "count_plays": "{count} বার প্লে হয়েছে", - "streaming_fees_hypothetical": "স্ট্রিমিং ফি (ধারণাগত)", - "minutes_listened": "শুনেছেন মিনিট", - "streamed_songs": "স্ট্রিম করা গান", - "count_streams": "{count} বার স্ট্রিম", - "owned_by_you": "আপনার মালিকানাধীন", - "copied_shareurl_to_clipboard": "{shareUrl} ক্লিপবোর্ডে কপি করা হয়েছে", - "spotify_hipotetical_calculation": "*এটি স্পোটিফাইয়ের প্রতি স্ট্রিম\n$0.003 থেকে $0.005 পেআউটের ভিত্তিতে গণনা করা হয়েছে। এটি একটি ধারণাগত\nগণনা ব্যবহারকারীদেরকে জানাতে দেয় যে কত টাকা\nতারা শিল্পীদের দিতো যদি তারা স্পোটিফাইতে\nতাদের গান শুনতেন।", - "count_mins": "{minutes} মিনিট", - "summary_minutes": "মিনিট", - "summary_listened_to_music": "সঙ্গীত শুনেছেন", - "summary_songs": "গান", - "summary_streamed_overall": "মোট স্ট্রিম", - "summary_owed_to_artists": "এই মাসে\nশিল্পীদেরকে ঋণী", - "summary_artists": "শিল্পীর", - "summary_music_reached_you": "আপনার কাছে পৌঁছেছে সঙ্গীত", - "summary_full_albums": "সম্পূর্ণ অ্যালবাম", - "summary_got_your_love": "আপনার ভালোবাসা পেয়েছে", - "summary_playlists": "প্লেলিস্ট", - "summary_were_on_repeat": "পুনরাবৃত্তিতে ছিল", - "total_money": "মোট {money}", - "webview_not_found": "ওয়েবভিউ পাওয়া যায়নি", - "webview_not_found_description": "আপনার ডিভাইসে কোনো ওয়েবভিউ রানটাইম ইনস্টল করা নেই।\nযদি ইনস্টল থাকে, তা নিশ্চিত করুন যে এটি environment PATH এ রয়েছে\n\nইনস্টল করার পর, অ্যাপটি পুনরায় চালু করুন", - "unsupported_platform": "সমর্থিত প্ল্যাটফর্ম নয়", - "invidious_instance": "ইনভিডিয়াস সার্ভার ইন্সটেন্স", - "invidious_description": "ট্রাক মিলানোর জন্য ব্যবহৃত ইনভিডিয়াস সার্ভার", - "invidious_warning": "কিছু সার্ভার ভাল কাজ নাও করতে পারে। নিজের ঝুঁকিতে ব্যবহার করুন", - "invidious_source_description": "পাইপের মতো কিন্তু আরও বেশি উপলব্ধতা সহ", - "cache_music": "ক্যাশে সংগীত", - "open": "খুলুন", - "cache_folder": "ক্যাশে ফোল্ডার", - "export": "রপ্তানি", - "clear_cache": "ক্যাশে পরিষ্কার", - "clear_cache_confirmation": "আপনি কি ক্যাশে পরিষ্কার করতে চান?", - "export_cache_files": "ক্যাশে ফাইল রপ্তানি", - "found_n_files": "{count} টি ফাইল পাওয়া গেছে", - "export_cache_confirmation": "আপনি কি এই ফাইলগুলি রপ্তানি করতে চান", - "exported_n_out_of_m_files": "{filesExported} টি ফাইল রপ্তানি করা হয়েছে {files} এর মধ্যে", - "playlist": "প্লেলিস্ট", - "no_loop": "কোনো লুপ নেই", - "generate": "উৎপন্ন করুন", - "undo": "পূর্বাবস্থায় ফিরুন", - "download_all": "সব ডাউনলোড করুন", - "add_all_to_playlist": "সব প্লেলিস্টে যোগ করুন", - "add_all_to_queue": "সব কিউতে যোগ করুন", - "play_all_next": "সব পরবর্তী খেলুন", - "pause": "বিরতি", - "view_all": "সব দেখুন", - "no_tracks_added_yet": "এখনও কোনো ট্র্যাক যোগ করা হয়নি মনে হচ্ছে", - "no_tracks": "এখানে কোনো ট্র্যাক নেই মনে হচ্ছে", - "no_tracks_listened_yet": "এখনও কিছু শোনা হয়নি মনে হচ্ছে", - "not_following_artists": "আপনি কোনো শিল্পীকে অনুসরণ করছেন না", - "no_favorite_albums_yet": "এখনও কোনো অ্যালবাম প্রিয় তালিকায় যোগ করা হয়নি মনে হচ্ছে", - "no_logs_found": "কোনো লগ পাওয়া যায়নি", - "youtube_engine": "ইউটিউব ইঞ্জিন", - "youtube_engine_not_installed_title": "{engine} ইনস্টল করা নেই", - "youtube_engine_not_installed_message": "{engine} আপনার সিস্টেমে ইনস্টল করা নেই।", - "youtube_engine_set_path": "এটি PATH ভেরিয়েবলে উপলব্ধ কিনা নিশ্চিত করুন অথবা\nনীচে {engine} এক্সিকিউটেবল এর পূর্ণপথ সেট করুন", - "youtube_engine_unix_issue_message": "macOS/Linux/Unix-এর মতো অপারেটিং সিস্টেমে, .zshrc/.bashrc/.bash_profile ইত্যাদিতে পাথ সেট করা কাজ করবে না।\nআপনাকে শেল কনফিগারেশন ফাইলে পাথ সেট করতে হবে", - "download": "ডাউনলোড", - "file_not_found": "ফাইল পাওয়া যায়নি", - "custom": "কাস্টম", - "add_custom_url": "কাস্টম URL যোগ করুন", - "edit_port": "পোর্ট সম্পাদনা করুন", - "port_helper_msg": "ডিফল্ট হল -1 যা এলোমেলো সংখ্যা নির্দেশ করে। যদি আপনার ফায়ারওয়াল কনফিগার করা থাকে, তবে এটি সেট করা সুপারিশ করা হয়।", - "connect_request": "{client} কে সংযোগ করতে অনুমতি দেবেন?", - "connection_request_denied": "সংযোগ অস্বীকৃত। ব্যবহারকারী প্রবেশাধিকার অস্বীকার করেছে।", - "hipotetical_calculation": "*এটি নিরূপণ করা হয়েছে গড় অনলাইন মিউজিক স্ট্রিমিং প্ল্যাটফর্মের প্রতি স্ট্রিম 0.003–0.005 USD পেআউটের ভিত্তিতে। এটি একটি কাল্পনিক হিসাব যা ব্যবহারকারীকে ধারণা দিতে পারে তারা অন্যান্য স্ট্রিমিং প্ল্যাটফর্মে একই গান শোনার জন্য শিল্পীদের কত টাকা দিয়েছেন হোক।", - "an_error_occurred": "একটি ত্রুটি ঘটেছে", - "copy_to_clipboard": "ক্লিপবোর্ডে কপি করুন", - "view_logs": "লগ দেখুন", - "retry": "পুনরায় চেষ্টা করুন", - "no_default_metadata_provider_selected": "আপনি কোনো ডিফল্ট মেটাডেটা প্রদানকারী সেট করেননি", - "manage_metadata_providers": "মেটাডেটা প্রদানকারীগণ পরিচালনা করুন", - "open_link_in_browser": "লিংকটি ব্রাউজারে খুলবেন?", - "do_you_want_to_open_the_following_link": "নিচের লিংকটি খুলতে চান?", - "unsafe_url_warning": "অবিশ্বাসযোগ্য উৎস থেকে লিংক খোলা নিরাপদ নাও হতে পারে। সতর্ক থাকুন!\nআপনি এটি ক্লিপবোর্ডে কপি করতে পারেন।", - "copy_link": "লিংক কপি করুন", - "building_your_timeline": "আপনার শোনার ধারা অনুযায়ী টাইমলাইন তৈরি করা হচ্ছে...", - "official": "সরকারি", - "author_name": "লেখক: {author}", - "third_party": "তৃতীয় পক্ষ", - "plugin_requires_authentication": "প্লাগইনটি প্রমাণীকরণ প্রয়োজন", - "update_available": "হালনাগাদ উপলব্ধ", - "supports_scrobbling": "স্ক্রোব্বলিং সমর্থিত", - "plugin_scrobbling_info": "এই প্লাগইনটি আপনার সঙ্গীত স্ক্রোব্বল করে আপনার শোনা ইতিহাস তৈরি করে।", - "default_plugin": "ডিফল্ট", - "set_default": "ডিফল্ট হিসাবে নির্ধারণ করুন", - "support": "সমর্থন", - "support_plugin_development": "প্লাগইন উন্নয়নকে সমর্থন করুন", - "can_access_name_api": "- **{name}** API-তে অ্যাক্সেস করতে পারে", - "do_you_want_to_install_this_plugin": "আপনি কি এই প্লাগইন ইনস্টল করতে চান?", - "third_party_plugin_warning": "এই প্লাগইন একটি তৃতীয় পক্ষের রেপোজিটরির। ইনস্টল করার আগে উৎস বিশ্বস্ত কিনা নিশ্চিত করুন।", - "author": "লেখক", - "this_plugin_can_do_following": "এই প্লাগইন নিচের কাজ করতে পারে", - "install": "ইনস্টল করুন", - "install_a_metadata_provider": "একটি মেটাডেটা প্রদানকারী ইনস্টল করুন", - "no_tracks_playing": "বর্তমানে কোনো ট্র্যাক শোনা হচ্ছে না", - "synced_lyrics_not_available": "এই গানের জন্য সিঙ্ক্রোনাইজড লিরিক্স পাওয়া যায় না। অনুগ্রহ করে ব্যবহার করুন", - "plain_lyrics": "সহজ লিরিক্স", - "tab_instead": "তার পরিবর্তে ট্যাব ব্যবহার করুন।", - "disclaimer": "অস্বীকৃতি", - "third_party_plugin_dmca_notice": "Spotube দল কোনো “তৃতীয় পক্ষ” প্লাগইনের জন্য কোনো (আইনগত সহ) দায়িত্ব নেয় না। নিজের বিপদে ব্যবহার করুন। কোনো বাগ/সমস্যা হলে প্লাগইন রেপোজিটরিতে জানাতে অনুরোধ করা হচ্ছে।\n\nযদি কোনো “তৃতীয় পক্ষ” প্লাগইন কোনো পরিষেবা/আইনগত সংস্থার ToS/DMCA ভূঙ্গ করে, অনুগ্রহ করে “তৃতীয় পক্ষ” প্লাগইনের লেখক বা হোস্টিং প্ল্যাটফর্মে (যেমন GitHub/Codeberg) পদক্ষেপ নিতে বলুন। “তৃতীয় পক্ষ” লেবেলযুক্ত যুক্তিগুলি সকলই পাবলিক/কমিউনিটি দ্বারা রক্ষণাবেক্ষণ করা হয়; আমরা সেগুলি কিউরেট করি না, তাই আমরা কোনো পদক্ষেপ নিতে পারি না।\n\n", - "input_does_not_match_format": "ইনপুট প্রয়োজনীয় ফরম্যাটের সাথে মেলে না", - "metadata_provider_plugins": "মেটাডেটা প্রদানকারী প্লাগইনসমূহ", - "paste_plugin_download_url": "ডাউনলোড URL বা GitHub/Codeberg রিপো URL বা .smplug ফাইলের সরাসরি লিঙ্ক পেস্ট করুন", - "download_and_install_plugin_from_url": "URL থেকে প্লাগইন ডাউনলোড এবং ইনস্টল করুন", - "failed_to_add_plugin_error": "প্লাগইন যোগ করতে ব্যর্থ: {error}", - "upload_plugin_from_file": "ফাইল থেকে প্লাগইন আপলোড করুন", - "installed": "ইনস্টল করা হয়েছে", - "available_plugins": "উপলব্ধ প্লাগইনগুলো", - "configure_your_own_metadata_plugin": "নিজস্ব প্লেলিস্ট/অ্যালবাম/শিল্পী/ফিড মেটাডেটা প্রদানকারী কনফিগার করুন", - "audio_scrobblers": "অডিও স্ক্রোব্বলার্স", - "scrobbling": "স্ক্রোব্বলিং", - "download_music_format": "গান ডাউনলোডের বিন্যাস", - "streaming_music_format": "গান স্ট্রিমিং এর বিন্যাস", - "download_music_quality": "গান ডাউনলোডের মান", - "streaming_music_quality": "গান স্ট্রিমিং এর মান", - "default_metadata_source": "ডিফল্ট মেটাডেটা উৎস", - "set_default_metadata_source": "ডিফল্ট মেটাডেটা উৎস সেট করুন", - "default_audio_source": "ডিফল্ট অডিও উৎস", - "set_default_audio_source": "ডিফল্ট অডিও উৎস সেট করুন", - "plugins": "প্লাগইন", - "configure_plugins": "আপনার নিজের মেটাডেটা প্রদানকারী এবং অডিও উৎস প্লাগইন কনফিগার করুন", - "source": "উৎস: ", - "uncompressed": "অ-সংকুচিত", - "dab_music_source_description": "অডিওফাইলদের জন্য। উচ্চ-মানের/লসলেস অডিও স্ট্রিম প্রদান করে। সঠিক ISRC ভিত্তিক ট্র্যাক ম্যাচিং।" -} \ No newline at end of file diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb deleted file mode 100644 index 06ed7ec6..00000000 --- a/lib/l10n/app_ca.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "Convidat", - "browse": "Explorar", - "search": "Cercar", - "library": "Biblioteca", - "lyrics": "Lletres", - "settings": "Configuració", - "genre_categories_filter": "Filtrar categories o gèneres...", - "genre": "Gènere", - "personalized": "Personalizat", - "featured": "Destacat", - "new_releases": "Nous Llançaments", - "songs": "Cançons", - "playing_track": "Reproduint {track}", - "queue_clear_alert": "Això eliminarà la llista actual. S'eliminaran {track_length} cançons.\n¿Vol continuar?", - "load_more": "Carregar més", - "playlists": "Llistes de reproducció", - "artists": "Artistes", - "albums": "Àlbums", - "tracks": "Cançons", - "downloads": "Descàrregues", - "filter_playlists": "Filtrar les seves llistes de reproducció...", - "liked_tracks": "Cançons Preferides", - "liked_tracks_description": "Totes les seves cançons preferides", - "create_playlist": "Crear Llista de reproducció", - "create_a_playlist": "Crear una llista de reproducció", - "create": "Crear", - "cancel": "Cancel·lar", - "playlist_name": "Nom de la llista", - "name_of_playlist": "Nom de la lista", - "description": "Descripció", - "public": "Pública", - "collaborative": "Col·laborativa", - "search_local_tracks": "Cercar cançons locals...", - "play": "Reproduir", - "delete": "Eliminar", - "none": "Cap", - "sort_a_z": "Ordenar de la A a la Z", - "sort_z_a": "Ordenar de la Z a la A", - "sort_artist": "Ordenar per Artista", - "sort_album": "Ordenar per Àlbum", - "sort_tracks": "Ordenar Cançons", - "currently_downloading": "Descàrrega en curs ({tracks_length})", - "cancel_all": "Cancel·lar todo", - "filter_artist": "Filtrar artistes...", - "followers": "{followers} Seguidors", - "add_artist_to_blacklist": "Afegir artista a la llista negra", - "top_tracks": "Millors Cançons", - "fans_also_like": "Als fans també els hi agrada", - "loading": "Carregant...", - "artist": "Artista", - "blacklisted": "A la llista negra", - "following": "Seguint", - "follow": "Seguir", - "artist_url_copied": "URL de l'artista copiada al porta-retalls ", - "added_to_queue": "{tracks} cançons afegides a la llista", - "filter_albums": "Filtrar àlbums...", - "synced": "Sincronitzat", - "plain": "Normal", - "shuffle": "Aleatori", - "search_tracks": "Buscar cançons...", - "released": "Publicat", - "error": "Error {error}", - "title": "Títul", - "time": "Duració", - "more_actions": "Més accios", - "download_count": "Descarregar ({count})", - "add_count_to_playlist": "Afegir ({count}) a la llista de reproducció", - "add_count_to_queue": "Agregar ({count}) a la llista", - "play_count_next": "Reproduir ({count}) a continuació", - "album": "Àlbum", - "copied_to_clipboard": "{data} copiado al porta-retalls", - "add_to_following_playlists": "Afegir {track} a les llistes de reproducció següents", - "add": "Afegir", - "added_track_to_queue": "{track} afegida a la llista", - "add_to_queue": "Afegir a la llista", - "track_will_play_next": "{track} es reproduirà a continuació", - "play_next": "Reproduir a continuació", - "removed_track_from_queue": "{track} eliminada de la llista", - "remove_from_queue": "Eliminar de la llista", - "remove_from_favorites": "Eliminar de preferits", - "save_as_favorite": "Guardar a preferits", - "add_to_playlist": "Afegir a la llista de reproducció", - "remove_from_playlist": "Eliminar de la llista de reproducció", - "add_to_blacklist": "Afegir a la llista negra", - "remove_from_blacklist": "Eliminar de la llista negra", - "share": "Compartir", - "mini_player": "Reproductor Petit", - "slide_to_seek": "Lliscar per cercar endavant o endarrere", - "shuffle_playlist": "Mesclar la llista de reproducció", - "unshuffle_playlist": "No mesclar la llista de reproducció", - "previous_track": "Cançó anterior", - "next_track": "Canço següent", - "pause_playback": "Pausar reproducció", - "resume_playback": "Continuar reproducció", - "loop_track": "Repetir canço", - "repeat_playlist": "Repetir la llista de reproducció", - "queue": "Llista", - "alternative_track_sources": "Fonts alternatives de cançons", - "download_track": "Descarregar cançó", - "tracks_in_queue": "{tracks} cançons a la llista", - "clear_all": "Netejar tot", - "show_hide_ui_on_hover": "Mostrar/Ocultar interfície al passar el cursor", - "always_on_top": "Sempre visible", - "exit_mini_player": "Sortir del reproductor petit", - "download_location": "Ubicació de descàrregues", - "account": "Compte", - "login_with_spotify": "Iniciar sesión amb el seu compte de Spotify", - "connect_with_spotify": "Connectar amb Spotify", - "logout": "Tancar sessió", - "logout_of_this_account": "Tancar sessió d'aquest compte", - "language_region": "Idioma i Regió", - "language": "Idioma", - "system_default": "Predeterminat del sistema", - "market_place_region": "Regió de la botiga", - "recommendation_country": "País de recomanació", - "appearance": "Apariència", - "layout_mode": "Mode de disseny", - "override_layout_settings": "Anul·leu la configuració del mode de disseny responsiu", - "adaptive": "Adaptable", - "compact": "Compacte", - "extended": "Extès", - "theme": "Tema", - "dark": "Fosc", - "light": "Clar", - "system": "Sistema", - "accent_color": "Color d'accent", - "sync_album_color": "Sincronitzar color de l'àlbum", - "sync_album_color_description": "Utilitza el color dominant de l'álbum com a color d'accent", - "playback": "Reproducció", - "audio_quality": "Qualitat d'àudio", - "high": "Alta", - "low": "Baixa", - "pre_download_play": "Descàrrega prèvia i reproduir", - "pre_download_play_description": "En lloc de transmetre l'àudio, descarrega bytes i ho reprodueix (recomendat per usuaris amb un bon ample de banda)", - "skip_non_music": "Ometre segments que no son música (SponsorBlock)", - "blacklist_description": "Cançons i artistes de la llista negra", - "wait_for_download_to_finish": "Si us plau, esperi que acabi la descàrrega actual", - "desktop": "Escriptori", - "close_behavior": "Comportament al tancar", - "close": "Tancar", - "minimize_to_tray": "Minimizar a la safata del sistema", - "show_tray_icon": "Mostrar icona a la safata del sistema", - "about": "Sobre", - "u_love_spotube": "Sabem que li encanta Spotube", - "check_for_updates": "Buscar actualitzacions", - "about_spotube": "Sobre Spotube", - "blacklist": "Llista negra", - "please_sponsor": "Si us plau, patrocina/dona", - "spotube_description": "Spotube, un client lleuger, multiplataforma i gratuït de Spotify", - "version": "Versió", - "build_number": "Número de compilació", - "founder": "Fundador", - "repository": "Repositori", - "bug_issues": "Errors i problemes", - "made_with": "Fet amb ❤️ a Bangladesh🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Llicència", - "add_spotify_credentials": "Afegir les seves credencials de Spotify per començar", - "credentials_will_not_be_shared_disclaimer": "No es preocupi, les seves credencials no seran recollides ni compartides amb ningú", - "know_how_to_login": "No sap com fer-ho?", - "follow_step_by_step_guide": "Segueixi la guia pas a pas", - "spotify_cookie": "Cookie de Spotify {name}", - "cookie_name_cookie": "Cookie {name}", - "fill_in_all_fields": "Si us plau, completi tots els camps", - "submit": "Enviar", - "exit": "Sortir", - "previous": "Anterior", - "next": "Següent", - "done": "Fet", - "step_1": "Pas 1", - "first_go_to": "Primer, vagi a", - "login_if_not_logged_in": "i iniciï sessió/registri el seu compte si no ho ha fet encara", - "step_2": "Pas 2", - "step_2_steps": "1. Una vegada que hagi iniciat sessió, premi F12 o faci clic dret amb el ratolí > Inspeccionar per obrir les eines de desenvolulpador del navegador.\n2. Després vagi a la pestanya \"Application\" (Chrome, Edge, Brave, etc.) o \"Storage\" (Firefox, Palemoon, etc.)\n3. Vagi a la secció \"Cookies\" i després a la subsecció \"https://accounts.spotify.com\"", - "step_3": "Pas 3", - "success_emoji": "Èxit! 🥳", - "success_message": "Ara has iniciat sessió amb èxit al teu compte de Spotify. Bona feina!", - "step_4": "Pas 4", - "something_went_wrong": "Quelcom ha sortit malament", - "piped_instance": "Instància del servidor Piped", - "piped_description": "La instància del servidor Piped a utilitzar per la coincidència de cançons", - "piped_warning": "Algunes poden no funcionar bé, utilitzi-les sota el seu propi risc", - "generate_playlist": "Generar Llista de reproducció", - "track_exists": "La cançó {track} ja existeix", - "replace_downloaded_tracks": "Substituir totes les cançons descarregades", - "skip_download_tracks": "Ometre la descàrrega de totes les cançons descarregades", - "do_you_want_to_replace": "Vol substituir la cançó existent?", - "replace": "Substituir", - "skip": "Ometre", - "select_up_to_count_type": "Seleccionar fins{count} {type}", - "select_genres": "Seleccionar Gèneres", - "add_genres": "Afegir Gèneres", - "country": "País", - "number_of_tracks_generate": "Número de cançons a generar", - "acousticness": "Acústica", - "danceability": "Ballabilitat", - "energy": "Energia", - "instrumentalness": "Instrumental", - "liveness": "En viu", - "loudness": "Sonoritat", - "speechiness": "Parla", - "valence": "Valencia", - "popularity": "Popularidad", - "key": "To", - "duration": "Duració (s)", - "tempo": "Tempo (BPM)", - "mode": "Mode", - "time_signature": "Signatura de temps", - "short": "Curt", - "medium": "Mig", - "long": "Llarg", - "min": "Mín.", - "max": "Màx.", - "target": "Objetiu", - "moderate": "Moderat", - "deselect_all": "Desseleccionar tot", - "select_all": "Seleccionar tot", - "are_you_sure": "Està segur?", - "generating_playlist": "Generant la seva llista de reproducció personalitzada...", - "selected_count_tracks": "Cançons {count} seleccionades", - "download_warning": "Si descarrega totes les cançons de cop, està piratejant música clarament i causant dany a la societat creativa de la música. Espero que sigui conscient d'això i sempre intenti respectar i recolzar la forta feina dels artístes", - "download_ip_ban_warning": "Per cert, la seva IP pot ser bloquejada a YouTube degut a solicituds de descàrrega excessives. El bloqueig d'IP vol dir que no podrà utilitzar YouTube (fins i tot si ha iniciat sessió) durant un mínim de 2-3 meses desde esa dirección IP. I Spotube no es fa responsable si això succeeix en alguna ocasió", - "by_clicking_accept_terms": "Al fer clic a 'Acceptar', acepta els següents termes:", - "download_agreement_1": "Se que estic piratejant música. Sóc dolent", - "download_agreement_2": "Recolzaré l'artista quan pugui i només ho faig perquè no tinc diners per comprar el seu art", - "download_agreement_3": "Sóc completament conscient que la meva IP pot ser bloqueada per YouTube i no responsabilizo a Spotube ni als seus propietaris/contribuents per qualsevol incident causat per la meva acció actual", - "decline": "Rebutjar", - "accept": "Acceptar", - "details": "Detalls", - "youtube": "YouTube", - "channel": "Canal", - "likes": "M'agrada", - "dislikes": "No m'agrada", - "views": "Vistes", - "streamUrl": "URL del streaming", - "stop": "Parar", - "sort_newest": "Ordenar per més noves", - "sort_oldest": "Ordenar per més antigues", - "sleep_timer": "Temporitzador d'apagat", - "mins": "{minutes} minuts", - "hours": "{hours} hores", - "hour": "{hours} hora", - "custom_hours": "Hores personalitzades", - "logs": "Registres", - "developers": "Desenvolupadors", - "not_logged_in": "No ha iniciat sesió", - "search_mode": "Mode de cerca", - "audio_source": "Font d'àudio", - "ok": "OK", - "failed_to_encrypt": "Error al xifrar", - "encryption_failed_warning": "Spotube utilitza el xifrado per emmagatzemar les seves dades de forma segura. Però ha fallat. Per tant, tornarà a un emmagatzament no segur\nSi estè utilizant Linux, asseguri's de tenir instal·lats els serveis secrets com gnome-keyring, kde-wallet i keepassxc", - "piped_api_down": "La API de Piped no està operativa", - "piped_down_error_instructions": "La instància de Piped {pipedInstance} no està operativa en aquest moment\n\nCanvieu la instància o canvieu el 'Tipus d'API' a l'API oficial de YouTube\n\nAssegureu-vos de reiniciar l'aplicació després del canvi", - "you_are_offline": "Actualment no teniu connexió a internet", - "connection_restored": "S'ha restablert la connexió a internet", - "use_system_title_bar": "Utilitza la barra de títol del sistema", - "querying_info": "Consultant informació...", - "update_playlist": "Actualitzar la llista de reproducció", - "update": "Actualitzar", - "crunching_results": "Processant resultats...", - "search_to_get_results": "Cerca per obtenir resultats", - "use_amoled_mode": "Utilitza el mode AMOLED", - "pitch_dark_theme": "Tema de dart negre intens", - "normalize_audio": "Normalitza l'àudio", - "change_cover": "Canvia la coberta", - "add_cover": "Afegeix una coberta", - "restore_defaults": "Restaura els valors per defecte", - "download_music_codec": "Descarrega el codec de música", - "streaming_music_codec": "Codec de música en streaming", - "login_with_lastfm": "Inicia la sessió amb Last.fm", - "connect": "Connecta", - "disconnect_lastfm": "Desconnecta de Last.fm", - "disconnect": "Desconnecta", - "username": "Nom d'usuari", - "password": "Contrasenya", - "login": "Inicia la sessió", - "login_with_your_lastfm": "Inicia la sessió amb el teu compte de Last.fm", - "scrobble_to_lastfm": "Scrobble a Last.fm", - "go_to_album": "Anar a l'àlbum", - "discord_rich_presence": "Presència rica de Discord", - "browse_all": "Navega per tot", - "genres": "Gèneres", - "explore_genres": "Explora els gèneres", - "step_3_steps": "Copia el valor de la cookie \"sp_dc\"", - "step_4_steps": "Pega el valor copiado de \"sp_dc\"", - "friends": "Amics", - "no_lyrics_available": "Ho sentim, no es poden trobar les lletres d'aquesta pista", - "sort_duration": "Ordenar per Durada", - "start_a_radio": "Inicia una ràdio", - "how_to_start_radio": "Com vols començar la ràdio?", - "replace_queue_question": "Voleu substituir la cua actual o afegir-hi?", - "endless_playback": "Reproducció infinita", - "delete_playlist": "Suprimeix la llista de reproducció", - "delete_playlist_confirmation": "Esteu segur que voleu suprimir aquesta llista de reproducció?", - "local_tracks": "Pistes locals", - "song_link": "Enllaç de la cançó", - "skip_this_nonsense": "Omet aquesta tonteria", - "freedom_of_music": "“Llibertat de la música”", - "freedom_of_music_palm": "“Llibertat de la música a la palma de la mà”", - "get_started": "Comencem", - "youtube_source_description": "Recomanat i funciona millor.", - "piped_source_description": "Et sents lliure? El mateix que YouTube però més lliure.", - "jiosaavn_source_description": "El millor per a la regió del sud d'Àsia.", - "highest_quality": "Qualitat més alta: {quality}", - "select_audio_source": "Seleccioneu la font d'àudio", - "endless_playback_description": "Afegiu automàticament noves cançons\nal final de la cua", - "choose_your_region": "Trieu la vostra regió", - "choose_your_region_description": "Això ajudarà a Spotube a mostrar-vos el contingut adequat\nper a la vostra ubicació.", - "choose_your_language": "Trieu el vostre idioma", - "help_project_grow": "Ajuda a fer créixer aquest projecte", - "help_project_grow_description": "Spotube és un projecte de codi obert. Podeu ajudar a fer créixer aquest projecte contribuint al projecte, informant d'errors o suggerint noves funcionalitats.", - "contribute_on_github": "Contribueix a GitHub", - "donate_on_open_collective": "Fes una donació a Open Collective", - "browse_anonymously": "Navega de manera anònima", - "enable_connect": "Habilita la connexió", - "enable_connect_description": "Controla Spotube des d'altres dispositius", - "devices": "Dispositius", - "select": "Selecciona", - "connect_client_alert": "Estàs sent controlat per {client}", - "this_device": "Aquest dispositiu", - "remote": "Remot", - "local_library": "Biblioteca local", - "add_library_location": "Afegeix a la biblioteca", - "remove_library_location": "Elimina de la biblioteca", - "local_tab": "Local", - "stats": "Estadístiques", - "and_n_more": "i {count} més", - "recently_played": "Reproduït recentment", - "browse_more": "Navega més", - "no_title": "Sense títol", - "not_playing": "No s'està reproduint", - "epic_failure": "Fracàs èpic!", - "added_num_tracks_to_queue": "Afegit {tracks_length} pistes a la cua", - "spotube_has_an_update": "Spotube té una actualització", - "download_now": "Descarregar ara", - "nightly_version": "Spotube Nightly {nightlyBuildNum} ha estat publicat", - "release_version": "Spotube v{version} ha estat publicat", - "read_the_latest": "Llegeix el més recent", - "release_notes": "notes de la versió", - "pick_color_scheme": "Tria l'esquema de colors", - "save": "Desar", - "choose_the_device": "Tria el dispositiu:", - "multiple_device_connected": "Hi ha diversos dispositius connectats.\nTria el dispositiu on vols realitzar aquesta acció", - "nothing_found": "No s'ha trobat res", - "the_box_is_empty": "La caixa està buida", - "top_artists": "Millors artistes", - "top_albums": "Millors àlbums", - "this_week": "Aquesta setmana", - "this_month": "Aquest mes", - "last_6_months": "Últims 6 mesos", - "this_year": "Aquest any", - "last_2_years": "Últims 2 anys", - "all_time": "Tots els temps", - "powered_by_provider": "Funciona amb {providerName}", - "email": "Correu electrònic", - "profile_followers": "Seguidors", - "birthday": "Aniversari", - "subscription": "Subscripció", - "not_born": "No ha nascut", - "hacker": "Hacker", - "profile": "Perfil", - "no_name": "Sense nom", - "edit": "Editar", - "user_profile": "Perfil d'usuari", - "count_plays": "{count} reproduccions", - "streaming_fees_hypothetical": "Comissions de streaming (hipotètic)", - "minutes_listened": "minuts escoltats", - "streamed_songs": "cançons reproduïdes", - "count_streams": "{count} reproduccions", - "owned_by_you": "De la teva propietat", - "copied_shareurl_to_clipboard": "S'ha copiat {shareUrl} al porta-retalls", - "spotify_hipotetical_calculation": "*Això es calcula basant-se en els\npagaments per reproducció de Spotify de $0.003 a $0.005.\nAquest és un càlcul hipotètic per\ndonar als usuaris una idea de quant\nhaurien pagat als artistes si haguessin escoltat\nla seva cançó a Spotify.", - "count_mins": "{minutes} minuts", - "summary_minutes": "minuts", - "summary_listened_to_music": "has escoltat música", - "summary_songs": "cançons", - "summary_streamed_overall": "reproduït en general", - "summary_owed_to_artists": "degut als artistes\nAquest mes", - "summary_artists": "artistes", - "summary_music_reached_you": "La música t'ha arribat", - "summary_full_albums": "Àlbums complets", - "summary_got_your_love": "ha aconseguit el teu amor", - "summary_playlists": "llistes de reproducció", - "summary_were_on_repeat": "estaven en repetició", - "total_money": "total {money}", - "webview_not_found": "No s'ha trobat el Webview", - "webview_not_found_description": "No hi ha cap temps d'execució de Webview instal·lat al dispositiu.\nSi està instal·lat, assegureu-vos que estigui en el environment PATH\n\nDesprés d'instal·lar-lo, reinicieu l'aplicació", - "unsupported_platform": "Plataforma no compatible", - "invidious_instance": "Instància del servidor Invidious", - "invidious_description": "La instància del servidor Invidious per fer coincidir pistes", - "invidious_warning": "Algunes instàncies podrien no funcionar bé. Feu-les servir sota la vostra responsabilitat", - "invidious_source_description": "Similar a Piped però amb més disponibilitat", - "cache_music": "Música en caché", - "open": "Obrir", - "cache_folder": "Carpeta de caché", - "export": "Exportar", - "clear_cache": "Netejar caché", - "clear_cache_confirmation": "Voleu netejar la memòria cau?", - "export_cache_files": "Exportar arxius en caché", - "found_n_files": "S'han trobat {count} arxius", - "export_cache_confirmation": "Voleu exportar aquests arxius a", - "exported_n_out_of_m_files": "S'han exportat {filesExported} de {files} arxius", - "playlist": "Llista de reproducció", - "no_loop": "Sense repetició", - "generate": "Generar", - "undo": "Desfer", - "download_all": "Descarregar tot", - "add_all_to_playlist": "Afegir tot a la llista de reproducció", - "add_all_to_queue": "Afegir tot a la cua", - "play_all_next": "Reproduir tot a continuació", - "pause": "Pausa", - "view_all": "Veure tot", - "no_tracks_added_yet": "Sembla que encara no has afegit cap pista", - "no_tracks": "Sembla que no hi ha pistes aquí", - "no_tracks_listened_yet": "Sembla que no has escoltat res encara", - "not_following_artists": "No estàs seguint cap artista", - "no_favorite_albums_yet": "Sembla que encara no has afegit cap àlbum als teus favorits", - "no_logs_found": "No s'han trobat registres", - "youtube_engine": "Motor de YouTube", - "youtube_engine_not_installed_title": "{engine} no està instal·lat", - "youtube_engine_not_installed_message": "{engine} no està instal·lat al teu sistema.", - "youtube_engine_set_path": "Assegura't que estigui disponible a la variable PATH o\nestableix el camí absolut a l'executable de {engine} a continuació", - "youtube_engine_unix_issue_message": "En macOS/Linux/Unix com a sistemes operatius, establir el camí a .zshrc/.bashrc/.bash_profile etc. no funcionarà.\nHas de configurar el camí al fitxer de configuració de la shell", - "download": "Descarregar", - "file_not_found": "Fitxer no trobat", - "custom": "Personalitzat", - "add_custom_url": "Afegir URL personalitzada", - "edit_port": "Editar port", - "port_helper_msg": "El valor per defecte és -1, que indica un número aleatori. Si teniu un tallafoc configurat, es recomana establir-ho.", - "connect_request": "Permetre que {client} es connecti?", - "connection_request_denied": "Connexió denegada. L'usuari ha denegat l'accés.", - "hipotetical_calculation": "*Això està calculat en funció d’un pagament mitjà per reproducció de 0,003–0,005 USD en plataformes de reproducció musical en línia. És un càlcul hipotètic per ajudar l’usuari a entendre quant hauria pagat als artistes si hagués escoltat la seva cançó en diferents plataformes.", - "an_error_occurred": "S’ha produït un error", - "copy_to_clipboard": "Copiar al porta-retalls", - "view_logs": "Veure registres", - "retry": "Tornar-ho a provar", - "no_default_metadata_provider_selected": "No has configurat cap proveïdor de metadades predeterminat", - "manage_metadata_providers": "Gestionar proveïdors de metadades", - "open_link_in_browser": "Obrir l’enllaç en el navegador?", - "do_you_want_to_open_the_following_link": "Vols obrir l’enllaç següent?", - "unsafe_url_warning": "Pot ser perillós obrir enllaços de fonts no fiables. Sigues precavís!\nTambé pots copiar l’enllaç al porta-retalls.", - "copy_link": "Copiar enllaç", - "building_your_timeline": "Construint la teva cronologia en funció de les teves escoltes...", - "official": "Oficial", - "author_name": "Autor: {author}", - "third_party": "Tercers", - "plugin_requires_authentication": "El complement requereix autenticació", - "update_available": "Actualització disponible", - "supports_scrobbling": "Admet scrobbling", - "plugin_scrobbling_info": "Aquest complement fa scrobbling de la teva música per generar l’historial d’escoltes.", - "default_plugin": "Predeterminat", - "set_default": "Establir com a predeterminat", - "support": "Suport", - "support_plugin_development": "Suportar el desenvolupament del complement", - "can_access_name_api": "- Pot accedir a l’API **{name}**", - "do_you_want_to_install_this_plugin": "Vols instal·lar aquest complement?", - "third_party_plugin_warning": "Aquest complement prové d’un repositori de tercers. Assegura’t de confiar en la font abans d’instal·lar-lo.", - "author": "Autor", - "this_plugin_can_do_following": "Aquest complement pot fer el següent", - "install": "Instal·lar", - "install_a_metadata_provider": "Instal·lar un proveïdor de metadades", - "no_tracks_playing": "No s’està reproduint cap pista actualment", - "synced_lyrics_not_available": "Les lletres sincronitzades no estan disponibles per a aquesta cançó. Si us plau, usa", - "plain_lyrics": "Lletres sense format", - "tab_instead": "en lloc d’això, utilitza la tecla Tab.", - "disclaimer": "Avís legal", - "third_party_plugin_dmca_notice": "L’equip de Spotube no accepta cap responsabilitat (inclosa legal) pels complements de “tercers”.\nFes-los servir sota la teva responsabilitat. Si detectes errors/problemes, informa’ls al repositori del complement.\n\nSi algun complement de “tercers” incompleix els ToS/DMCA d’un servei o entitat legal, contacta amb l’autor del complement o amb la plataforma d’allotjament (per exemple GitHub/Codeberg) per prendre mesures. Els complements etiquetats com a “tercers” són públics i gestionats per la comunitat; no els curatem, per la qual cosa no podem intervenir-hi.\n\n", - "input_does_not_match_format": "L’entrada no coincideix amb el format requerit", - "metadata_provider_plugins": "Complements de proveïdor de metadades", - "paste_plugin_download_url": "Enllaça l’URL de descàrrega o el repositori de GitHub/Codeberg o l’enllaç directe al fitxer .smplug", - "download_and_install_plugin_from_url": "Descarrega i instal·la el complement des d’un URL", - "failed_to_add_plugin_error": "Error en afegir el complement: {error}", - "upload_plugin_from_file": "Penja el complement des d’un fitxer", - "installed": "Instal·lat", - "available_plugins": "Complements disponibles", - "configure_your_own_metadata_plugin": "Configura el teu propi proveïdor de metadades per llistes/reproduccions àlbum/artista/flux", - "audio_scrobblers": "Scrobblers d’àudio", - "scrobbling": "Scrobbling", - "download_music_format": "Format de descàrrega de música", - "streaming_music_format": "Format de reproducció de música en temps real", - "download_music_quality": "Qualitat de descàrrega de música", - "streaming_music_quality": "Qualitat de reproducció de música en temps real", - "default_metadata_source": "Font de metadades per defecte", - "set_default_metadata_source": "Estableix la font de metadades per defecte", - "default_audio_source": "Font d'àudio per defecte", - "set_default_audio_source": "Estableix la font d'àudio per defecte", - "plugins": "Connectors", - "configure_plugins": "Configura els teus propis connectors de proveïdor de metadades i de font d'àudio", - "source": "Font: ", - "uncompressed": "Sense comprimir", - "dab_music_source_description": "Per als audiòfils. Ofereix fluxos d'àudio d'alta qualitat/sense pèrdua. Coincidència precisa de pistes basada en ISRC." -} \ No newline at end of file diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb deleted file mode 100644 index 59938004..00000000 --- a/lib/l10n/app_cs.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "Host", - "browse": "Procházet", - "search": "Hledat", - "library": "Knihovna", - "lyrics": "Texty", - "settings": "Nastavení", - "genre_categories_filter": "Filtrovat kategorie nebo žánry...", - "genre": "Žánr", - "personalized": "Personalizované", - "featured": "Doporučené", - "new_releases": "Nově vydané", - "songs": "Skladby", - "playing_track": "Hraje {track}", - "queue_clear_alert": "Toto vymaže aktuální frontu. {track_length} skladeb bude odstraněno\nChcete pokračovat?", - "load_more": "Načíst více", - "playlists": "Playlisty", - "artists": "Umělci", - "albums": "Alba", - "tracks": "Skladby", - "downloads": "Stahování", - "filter_playlists": "Filtrovat playlisty...", - "liked_tracks": "Oblíbené skladby", - "liked_tracks_description": "Všechny vaše oblíbené skladby", - "create_playlist": "Vytvořit playlist", - "create_a_playlist": "Vytvořit playlist", - "update_playlist": "Aktualizovat playlist", - "create": "Vytvořit", - "cancel": "Zrušit", - "update": "Aktualizovat", - "playlist_name": "Název playlistu", - "name_of_playlist": "Název playlistu", - "description": "Popis", - "public": "Veřejné", - "collaborative": "Společný", - "search_local_tracks": "Hledat místní skladby...", - "play": "Přehrát", - "delete": "Smazat", - "none": "Žádné", - "sort_a_z": "Seřadit od A-Z", - "sort_z_a": "Seřadit od Z-A", - "sort_artist": "Seřadit podle umělce", - "sort_album": "Seřadit podle alba", - "sort_duration": "Seřadit podle délky", - "sort_tracks": "Seřadit skladby", - "currently_downloading": "Právě se stahuje ({tracks_length})", - "cancel_all": "Zrušit vše", - "filter_artist": "Filtrovat umělce...", - "followers": "{followers} Sledující", - "add_artist_to_blacklist": "Přidat umělce na černou listinu", - "top_tracks": "Top skladby", - "fans_also_like": "Fanoušci mají také rádi", - "loading": "Načítání...", - "artist": "Umělec", - "blacklisted": "Na černé listině", - "following": "Sleduje", - "follow": "Sledovat", - "artist_url_copied": "URL umělce zkopírována do schránky", - "added_to_queue": "Přidáno {tracks} skladeb do fronty", - "filter_albums": "Filtrovat alba...", - "synced": "Synchronizováno", - "plain": "Jednoduché", - "shuffle": "Zamíchat", - "search_tracks": "Hledat skladby...", - "released": "Vydáno", - "error": "Chyba {error}", - "title": "Název", - "time": "Čas", - "more_actions": "Více akcí", - "download_count": "Stáhnout ({count})", - "add_count_to_playlist": "Přidat ({count}) do playlistu", - "add_count_to_queue": "Přidat ({count}) do fronty", - "play_count_next": "Přehrát ({count}) dalších", - "album": "Album", - "copied_to_clipboard": "Zkopírováno {data} do schránky", - "add_to_following_playlists": "Přidat {track} do následujících playlistů", - "add": "Přidat", - "added_track_to_queue": "Přidána skladba {track} do fronty", - "add_to_queue": "Přidat do fronty", - "track_will_play_next": "{track} se přehraje jako další", - "play_next": "Přehrát další", - "removed_track_from_queue": "Odstraněna skladba {track} z fronty", - "remove_from_queue": "Odstranit z fronty", - "remove_from_favorites": "Odstranit z oblíbených", - "save_as_favorite": "Uložit jako oblíbené", - "add_to_playlist": "Přidat do playlistu", - "remove_from_playlist": "Odstranit z playlistu", - "add_to_blacklist": "Přidat na černou listinu", - "remove_from_blacklist": "Odstranit z černé listiny", - "share": "Sdílet", - "mini_player": "Mini přehrávač", - "slide_to_seek": "Táhněte pro posunutí vpřed nebo vzad", - "shuffle_playlist": "Zamíchat playlist", - "unshuffle_playlist": "Zrušit zamíchání playlistu", - "previous_track": "Předchozí skladba", - "next_track": "Další skladba", - "pause_playback": "Pozastavit přehrávání", - "resume_playback": "Pokračovat v přehrávání", - "loop_track": "Opakovat skladbu", - "repeat_playlist": "Opakovat playlist", - "queue": "Fronta", - "alternative_track_sources": "Alternativní zdroje skladeb", - "download_track": "Stáhnout skladbu", - "tracks_in_queue": "{tracks} skladeb ve frontě", - "clear_all": "Vymazat vše", - "show_hide_ui_on_hover": "Zobrazit/Skrýt UI při najetí", - "always_on_top": "Vždy nahoře", - "exit_mini_player": "Zavřít mini přehrávač", - "download_location": "Umístění stahování", - "account": "Účet", - "login_with_spotify": "Přihlásit se pomocí Spotify účtu", - "connect_with_spotify": "Připojit k Spotify", - "logout": "Odhlásit se", - "logout_of_this_account": "Odhlásit se z tohoto účtu", - "language_region": "Jazyk a region", - "language": "Jazyk", - "system_default": "Systém", - "market_place_region": "Region", - "recommendation_country": "Země pro doporučení", - "appearance": "Vzhled", - "layout_mode": "Režim rozložení", - "override_layout_settings": "Přepsat režim rozložení", - "adaptive": "Adaptivní", - "compact": "Kompaktní", - "extended": "Rozšířený", - "theme": "Téma", - "dark": "Tmavé", - "light": "Světlé", - "system": "Systém", - "accent_color": "Barva akcentu", - "sync_album_color": "Synchronizovat barvu alba", - "sync_album_color_description": "Používá dominantní barvu obalu alba jako barvu akcentu", - "playback": "Přehrávání", - "audio_quality": "Kvalita zvuku", - "high": "Vysoká", - "low": "Nízká", - "pre_download_play": "Předstáhnout a přehrát", - "pre_download_play_description": "Místo streamování audia stáhnout skladbu a přehrát (doporučeno pro uživatele s rychlejším internetem)", - "skip_non_music": "Přeskočit nehudební segmenty (SponsorBlock)", - "blacklist_description": "Zakázané skladby a umělci", - "wait_for_download_to_finish": "Počkejte, až se dokončí stahování", - "desktop": "Desktop", - "close_behavior": "Chování při zavření", - "close": "Zavřít", - "minimize_to_tray": "Minimalizovat do lišty", - "show_tray_icon": "Zobrazit ikonu v systémové liště", - "about": "O aplikaci", - "u_love_spotube": "Víme, že milujete Spotube", - "check_for_updates": "Zkontrolovat aktualizace", - "about_spotube": "O Spotube", - "blacklist": "Černá listina", - "please_sponsor": "Sponzorovat/darovat", - "spotube_description": "Spotube, rychlý, multiplatformní, bezplatný Spotify klient", - "version": "Verze", - "build_number": "Číslo sestavení", - "founder": "Zakladatel", - "repository": "Repozitář", - "bug_issues": "Chyby+Problémy", - "made_with": "Vytvořeno s ❤️ v Bangladéši🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Licence", - "add_spotify_credentials": "Přidejte své přihlašovací údaje Spotify a začněte", - "credentials_will_not_be_shared_disclaimer": "Nebojte, žádné z vašich údajů nebudou shromažďovány ani s nikým sdíleny", - "know_how_to_login": "Nevíte, jak na to?", - "follow_step_by_step_guide": "Postupujte podle návodu", - "spotify_cookie": "Cookie Spotify {name}", - "cookie_name_cookie": "Cookie {name}", - "fill_in_all_fields": "Vyplňte prosím všechna pole", - "submit": "Odeslat", - "exit": "Ukončit", - "previous": "Předchozí", - "next": "Další", - "done": "Hotovo", - "step_1": "Krok 1", - "first_go_to": "Nejprve jděte na", - "login_if_not_logged_in": "a přihlašte se nebo se zaregistrujte, pokud nejste přihlášeni", - "step_2": "Krok 2", - "step_2_steps": "1. Jakmile jste přihlášeni, stiskněte F12 nebo pravé tlačítko myši > Prozkoumat, abyste otevřeli nástroje pro vývojáře prohlížeče.\n2. Poté přejděte na kartu \"Aplikace\" (Chrome, Edge, Brave atd.) nebo kartu \"Úložiště\" (Firefox, Palemoon atd.)\n3. Přejděte do sekce \"Cookies\" a pak do podsekce \"https://accounts.spotify.com\"", - "step_3": "Krok 3", - "step_3_steps": "Zkopírujte hodnotu cookie \"sp_dc\"", - "success_emoji": "Úspěch🥳", - "success_message": "Nyní jste úspěšně přihlášeni pomocí svého Spotify účtu. Dobrá práce, kamaráde!", - "step_4": "Krok 4", - "step_4_steps": "Vložte zkopírovanou hodnotu \"sp_dc\"", - "something_went_wrong": "Něco se pokazilo", - "piped_instance": "Instance serveru Piped", - "piped_description": "Instance serveru Piped, kterou použít pro hledání skladeb", - "piped_warning": "Některé z nich nemusí dobře fungovat. Používejte na vlastní riziko", - "generate_playlist": "Vygenerovat playlist", - "track_exists": "Skladba {track} již existuje", - "replace_downloaded_tracks": "Nahradit všechny stažené skladby", - "skip_download_tracks": "Přeskočit stahování všech stažených skladeb", - "do_you_want_to_replace": "Chcete nahradit existující skladbu??", - "replace": "Nahradit", - "skip": "Přeskočit", - "select_up_to_count_type": "Vyberte až {count} {type}", - "select_genres": "Vyberte žánry", - "add_genres": "Přidat žánry", - "country": "Země", - "number_of_tracks_generate": "Počet skladeb k vygenerování", - "acousticness": "Akustičnost", - "danceability": "Tanečnost", - "energy": "Energie", - "instrumentalness": "Instrumentálnost", - "liveness": "Živost", - "loudness": "Hlasitost", - "speechiness": "Mluvnost", - "valence": "Valence", - "popularity": "Popularita", - "key": "Klíč", - "duration": "Délka (s)", - "tempo": "Tempo (BPM)", - "mode": "Režim", - "time_signature": "Udání taktu", - "short": "Krátký", - "medium": "Střední", - "long": "Dlouhý", - "min": "Min", - "max": "Max", - "target": "Cíl", - "moderate": "Mírný", - "deselect_all": "Zrušit výběr", - "select_all": "Vybrat vše", - "are_you_sure": "Jste si jisti?", - "generating_playlist": "Generování vašeho vlastního playlistu...", - "selected_count_tracks": "Vybráno {count} skladeb", - "download_warning": "Pokud stáhnete všechny skladby najednou, pirátíte tím hudbu a škodíte kreativní společnosti hudby. Doufám, že jste si toho vědomi. Vždy se snažte respektovat a podporovat tvrdou práci umělců", - "download_ip_ban_warning": "Mimochodem, vaše IP může být na YouTube zablokována kvůli nadměrným požadavkům na stahování. Blokování IP znamená, že nemůžete používat YouTube (i když jste přihlášeni) alespoň 2-3 měsíce ze zařízení s touto IP. A Spotube nenese žádnou odpovědnost, pokud se to někdy stane", - "by_clicking_accept_terms": "Kliknutím na 'přijmout' souhlasíte s následujícími podmínkami:", - "download_agreement_1": "Vím, že pirátím hudbu. Jsem špatný", - "download_agreement_2": "Budu podporovat umělce, kdekoliv to bude možné, a dělám to jen proto, že nemám peníze na koupi jejich umění", - "download_agreement_3": "Jsem si naprosto vědom toho, že moje IP může být na YouTube zablokována a nenesu žádnou odpovědnost za nehody způsobené mým současným jednáním", - "decline": "Odmítnout", - "accept": "Přijmout", - "details": "Podrobnosti", - "youtube": "YouTube", - "channel": "Kanál", - "likes": "Líbí se", - "dislikes": "Nelíbí se", - "views": "Zobrazení", - "streamUrl": "URL streamu", - "stop": "Zastavit", - "sort_newest": "Seřadit od nejnovějších", - "sort_oldest": "Seřadit od nejstarších", - "sleep_timer": "Časovač spánku", - "mins": "{minutes} Minut", - "hours": "{hours} Hodin", - "hour": "{hours} Hodina", - "custom_hours": "Vlastní hodiny", - "logs": "Protokoly", - "developers": "Vývojáři", - "not_logged_in": "Nejste přihlášeni", - "search_mode": "Režim hledání", - "audio_source": "Zdroj zvuku", - "ok": "Ok", - "failed_to_encrypt": "Šifrování selhalo", - "encryption_failed_warning": "Spotube používá šifrování k bezpečnému ukládání vašich dat. Ale selhalo. Takže se vrátí k nezabezpečenému úložišti\nPokud používáte linux, ujistěte se, že máte nainstalovanou jakoukoli službu k ukládání bezpečnostních pověření (gnome-keyring, kde-wallet, keepassxc atd.)", - "querying_info": "Získávání informací...", - "piped_api_down": "Piped API je mimo provoz", - "piped_down_error_instructions": "Instance Piped {pipedInstance} je momentálně mimo provoz\n\nBuď změňte instanci nebo změňte 'Typ API' na oficiální YouTube API\n\nPo změně se ujistěte, že aplikaci restartujete", - "you_are_offline": "Momentálně jste offline", - "connection_restored": "Vaše internetové připojení bylo obnoveno", - "use_system_title_bar": "Použít systémové záhlaví okna", - "crunching_results": "Zpracovávání výsledků...", - "search_to_get_results": "Hledejte pro získání výsledků", - "use_amoled_mode": "Úplně černé téma", - "pitch_dark_theme": "AMOLED režim", - "normalize_audio": "Normalizovat audio", - "change_cover": "Změnit obal", - "add_cover": "Přidat obal", - "restore_defaults": "Obnovit výchozí", - "download_music_codec": "Kodek pro stahování", - "streaming_music_codec": "Kodek pro streamování", - "login_with_lastfm": "Přihlásit se pomocí Last.fm", - "connect": "Připojit", - "disconnect_lastfm": "Odpojit Last.fm", - "disconnect": "Odpojit", - "username": "Uživatelské jméno", - "password": "Heslo", - "login": "Přihlásit se", - "login_with_your_lastfm": "Přihlásit se pomocí vašeho Last.fm účtu", - "scrobble_to_lastfm": "Scrobble na Last.fm", - "go_to_album": "Přejít na album", - "discord_rich_presence": "Discord Rich Presence", - "browse_all": "Procházet vše", - "genres": "Žánry", - "explore_genres": "Prozkoumat žánry", - "friends": "Přátelé", - "no_lyrics_available": "Omlouváme se, není možné najít texty pro tuto skladbu", - "start_a_radio": "Vytvořit rádio", - "how_to_start_radio": "Jak chcete vytvořit rádio?", - "replace_queue_question": "Chcete nahradit aktuální frontu nebo k ní přidat?", - "endless_playback": "Nekonečné přehrávání", - "delete_playlist": "Smazat playlist", - "delete_playlist_confirmation": "Jste si jisti, že chcete smazat tento playlist?", - "local_tracks": "Místní skladby", - "song_link": "Odkaz na skladbu", - "skip_this_nonsense": "Přeskočit tenhle nesmysl", - "freedom_of_music": "“Svobodná hudba”", - "freedom_of_music_palm": "“Svobodná hudba ve vaší dlani”", - "get_started": "Začít", - "youtube_source_description": "Doporučeno a funguje nejlépe.", - "piped_source_description": "Nechcete být sledováni? Stejné jako YouTube, ale respektuje soukromí.", - "jiosaavn_source_description": "Nejlepší pro jihoasijský region.", - "highest_quality": "Nejvyšší kvalita: {quality}", - "select_audio_source": "Vyberte zdroj zvuku", - "endless_playback_description": "Automaticky přidávat nové skladby\nna konec fronty", - "choose_your_region": "Vyberte svůj region", - "choose_your_region_description": "To pomůže Spotube ukázat vám správný obsah\npro vaši lokalitu.", - "choose_your_language": "Vyberte svůj jazyk", - "help_project_grow": "Pomozte tomuto projektu růst", - "help_project_grow_description": "Spotube je open-source projekt. Můžete pomoci tomuto projektu růst tím, že přispějete do projektu, nahlásíte chyby nebo navrhnete nové funkce.", - "contribute_on_github": "Přispějte na GitHub", - "donate_on_open_collective": "Darujte na Open Collective", - "browse_anonymously": "Procházet anonymně", - "enable_connect": "Povolit ovládání", - "enable_connect_description": "Ovládejte Spotube z jiného zařízení", - "devices": "Zařízení", - "select": "Vybrat", - "connect_client_alert": "Zařízení je ovládáno z {client}", - "this_device": "Toto zařízení", - "remote": "Ovladač", - "local_library": "Místní knihovna", - "add_library_location": "Přidat do knihovny", - "remove_library_location": "Odebrat z knihovny", - "local_tab": "Místní", - "stats": "Statistiky", - "and_n_more": "a dalších {count}", - "recently_played": "Nedávno přehráno", - "browse_more": "Procházet více", - "no_title": "Bez názvu", - "not_playing": "Nepřehrává se", - "epic_failure": "Epické selhání!", - "added_num_tracks_to_queue": "Přidáno {tracks_length} skladeb do fronty", - "spotube_has_an_update": "Spotube má aktualizaci", - "download_now": "Stáhnout nyní", - "nightly_version": "Byla vydána noční verze Spotube {nightlyBuildNum}", - "release_version": "Byla vydána verze Spotube v{version}", - "read_the_latest": "Přečtěte si nejnovější ", - "release_notes": "poznámky k vydání", - "pick_color_scheme": "Vyberte barevné schéma", - "save": "Uložit", - "choose_the_device": "Vyberte zařízení:", - "multiple_device_connected": "Je připojeno více zařízení.\nVyberte zařízení, na kterém chcete provést tuto akci", - "nothing_found": "Nic nenalezeno", - "the_box_is_empty": "Krabice je prázdná", - "top_artists": "Nejlepší umělci", - "top_albums": "Nejlepší alba", - "this_week": "Tento týden", - "this_month": "Tento měsíc", - "last_6_months": "Posledních 6 měsíců", - "this_year": "Tento rok", - "last_2_years": "Poslední 2 roky", - "all_time": "Všechny časy", - "powered_by_provider": "Pohání {providerName}", - "email": "Email", - "profile_followers": "Sledující", - "birthday": "Narozeniny", - "subscription": "Předplatné", - "not_born": "Nenarozen", - "hacker": "Hacker", - "profile": "Profil", - "no_name": "Bez jména", - "edit": "Upravit", - "user_profile": "Uživatelský profil", - "count_plays": "{count} přehrání", - "streaming_fees_hypothetical": "Poplatky za streamování (hypotetické)", - "minutes_listened": "Poslouchané minuty", - "streamed_songs": "Streamované skladby", - "count_streams": "{count} streamů", - "owned_by_you": "Vlastněno vámi", - "copied_shareurl_to_clipboard": "Zkopírováno {shareUrl} do schránky", - "spotify_hipotetical_calculation": "*Toto je vypočítáno na základě výplaty\nza stream Spotify od $0.003 do $0.005.\nToto je hypotetický výpočet,\nabyste měli představu o tom, kolik\nbyste zaplatili umělcům,\npokud byste poslouchali jejich píseň na Spotify.", - "count_mins": "{minutes} minut", - "summary_minutes": "minuty", - "summary_listened_to_music": "Poslouchal(a) hudbu", - "summary_songs": "písně", - "summary_streamed_overall": "Streamováno celkově", - "summary_owed_to_artists": "Dluženo umělcům\nTento měsíc", - "summary_artists": "umělců", - "summary_music_reached_you": "Hudba vás oslovila", - "summary_full_albums": "plná alba", - "summary_got_your_love": "Získal vaši lásku", - "summary_playlists": "playlisty", - "summary_were_on_repeat": "Byly na opakování", - "total_money": "Celkem {money}", - "webview_not_found": "Webview nebyl nalezen", - "webview_not_found_description": "Na vašem zařízení není nainstalováno žádné runtime prostředí Webview.\nPokud je nainstalováno, ujistěte se, že je v environment PATH\n\nPo instalaci restartujte aplikaci", - "unsupported_platform": "Nepodporovaná platforma", - "invidious_instance": "Instance serveru Invidious", - "invidious_description": "Instance serveru Invidious pro párování stop", - "invidious_warning": "Některé instance nemusí fungovat správně. Používejte na vlastní riziko", - "invidious_source_description": "Podobné Piped, ale s vyšší dostupností", - "cache_music": "Hudba v mezipaměti", - "open": "Otevřít", - "cache_folder": "Složka mezipaměti", - "export": "Exportovat", - "clear_cache": "Vymazat mezipaměť", - "clear_cache_confirmation": "Opravdu chcete vymazat mezipaměť?", - "export_cache_files": "Exportovat soubory z mezipaměti", - "found_n_files": "Nalezeno {count} souborů", - "export_cache_confirmation": "Chcete exportovat tyto soubory do", - "exported_n_out_of_m_files": "Exportováno {filesExported} z {files} souborů", - "playlist": "Seznam skladeb", - "no_loop": "Žádné opakování", - "generate": "Generovat", - "undo": "Zpět", - "download_all": "Stáhnout vše", - "add_all_to_playlist": "Přidat vše do seznamu skladeb", - "add_all_to_queue": "Přidat vše do fronty", - "play_all_next": "Přehrát vše následně", - "pause": "Pauza", - "view_all": "Zobrazit vše", - "no_tracks_added_yet": "Zdá se, že jste ještě nepřidali žádné skladby", - "no_tracks": "Zdá se, že zde nejsou žádné skladby", - "no_tracks_listened_yet": "Zdá se, že jste ještě nic neposlouchali", - "not_following_artists": "Nezajímáte se o žádné umělce", - "no_favorite_albums_yet": "Zdá se, že jste ještě nepřidali žádné alba mezi oblíbené", - "no_logs_found": "Žádné záznamy nenalezeny", - "youtube_engine": "YouTube Engine", - "youtube_engine_not_installed_title": "{engine} není nainstalován", - "youtube_engine_not_installed_message": "{engine} není nainstalován ve vašem systému.", - "youtube_engine_set_path": "Ujistěte se, že je k dispozici v proměnné PATH nebo\nnastavte absolutní cestu k {engine} spustitelnému souboru níže", - "youtube_engine_unix_issue_message": "V macOS/Linux/Unixových systémech nebude fungovat nastavení cesty v .zshrc/.bashrc/.bash_profile atd.\nMusíte nastavit cestu v konfiguračním souboru shellu", - "download": "Stáhnout", - "file_not_found": "Soubor nenalezen", - "custom": "Vlastní", - "add_custom_url": "Přidat vlastní URL", - "edit_port": "Upravit port", - "port_helper_msg": "Výchozí hodnota je -1, což znamená náhodné číslo. Pokud máte nakonfigurován firewall, doporučuje se to nastavit.", - "connect_request": "Povolit {client} připojení?", - "connection_request_denied": "Připojení bylo zamítnuto. Uživatel odmítl přístup.", - "hipotetical_calculation": "*Toto je vypočítáno na základě průměrného výplatu za přehrání 0,003–0,005 USD na online hudebních streamovacích platformách. Jedná se o hypotetický výpočet, který má uživateli ukázat, kolik by umělci dostali, pokud by jeho píseň poslouchal na jiné platformě.", - "an_error_occurred": "Došlo k chybě", - "copy_to_clipboard": "Kopírovat do schránky", - "view_logs": "Zobrazit protokoly", - "retry": "Zkusit znovu", - "no_default_metadata_provider_selected": "Nemáte nastaven výchozí poskytovatel metadat", - "manage_metadata_providers": "Spravovat poskytovatele metadat", - "open_link_in_browser": "Otevřít odkaz v prohlížeči?", - "do_you_want_to_open_the_following_link": "Chcete otevřít následující odkaz?", - "unsafe_url_warning": "Odkazy z nedůvěryhodných zdrojů mohou být nebezpečné. Buďte opatrní!\nOdkaz si také můžete zkopírovat do schránky.", - "copy_link": "Zkopírovat odkaz", - "building_your_timeline": "Vytváří se váš časový přehled podle poslechů...", - "official": "Oficiální", - "author_name": "Autor: {author}", - "third_party": "Třetí strana", - "plugin_requires_authentication": "Plugin vyžaduje ověření", - "update_available": "Aktualizace dostupná", - "supports_scrobbling": "Podpora scrobblování", - "plugin_scrobbling_info": "Tento plugin scrobbles vaši hudbu pro vytvoření historie poslechů.", - "default_plugin": "Výchozí", - "set_default": "Nastavit jako výchozí", - "support": "Podpora", - "support_plugin_development": "Podpořit vývoj pluginu", - "can_access_name_api": "- Může přistupovat k API **{name}**", - "do_you_want_to_install_this_plugin": "Chcete tento plugin nainstalovat?", - "third_party_plugin_warning": "Tento plugin pochází z repozitáře třetí strany. Ujistěte se, že důvěřujete zdroji, než ho nainstalujete.", - "author": "Autor", - "this_plugin_can_do_following": "Tento plugin může provádět následující úkony", - "install": "Instalovat", - "install_a_metadata_provider": "Nainstalovat poskytovatele metadat", - "no_tracks_playing": "Momentálně není přehrávána žádná skladba", - "synced_lyrics_not_available": "Synchronizované texty nejsou k dispozici k této písni. Prosím použijte", - "plain_lyrics": "Prostý text", - "tab_instead": "místo toho použijte tabulátor.", - "disclaimer": "Prohlášení", - "third_party_plugin_dmca_notice": "Tým Spotube nenese žádnou odpovědnost (včetně právní) za pluginy „třetích stran“.\nPoužívejte je na vlastní riziko. Pro chyby/problémy je nahlaste do repozitáře pluginu.\n\nPokud jakýkoli plugin „třetí strany“ porušuje podmínky služby nebo DMCA kteréhokoli poskytovatele či právního subjektu, požádejte autora pluginu nebo hostingovou platformu (např. GitHub/Codeberg), aby podnikla kroky. Pluginy označené jako „třetí strana“ jsou otevřené a spravovány komunitou; nespravujeme je, tudíž nemůžeme jednat.\n\n", - "input_does_not_match_format": "Vstup neodpovídá požadovanému formátu", - "metadata_provider_plugins": "Pluginy poskytovatelů metadat", - "paste_plugin_download_url": "Vložte URL ke stažení nebo GitHub/Codeberg repozitář či přímý odkaz na soubor .smplug", - "download_and_install_plugin_from_url": "Stáhnout a nainstalovat plugin z URL", - "failed_to_add_plugin_error": "Nepodařilo se přidat plugin: {error}", - "upload_plugin_from_file": "Nahrát plugin ze souboru", - "installed": "Nainstalováno", - "available_plugins": "Dostupné pluginy", - "configure_your_own_metadata_plugin": "Nakonfigurujte si vlastního poskytovatele metadat pro playlist/album/umělec/fid", - "audio_scrobblers": "Audio scrobblers", - "scrobbling": "Scrobbling", - "download_music_format": "Formát stahování hudby", - "streaming_music_format": "Formát streamování hudby", - "download_music_quality": "Kvalita stahování hudby", - "streaming_music_quality": "Kvalita streamování hudby", - "default_metadata_source": "Výchozí zdroj metadat", - "set_default_metadata_source": "Nastavit výchozí zdroj metadat", - "default_audio_source": "Výchozí zdroj zvuku", - "set_default_audio_source": "Nastavit výchozí zdroj zvuku", - "plugins": "Pluginy", - "configure_plugins": "Konfigurujte své vlastní pluginy poskytovatele metadat a zdroje zvuku", - "source": "Zdroj: ", - "uncompressed": "Nekomprimováno", - "dab_music_source_description": "Pro audiofily. Poskytuje vysoce kvalitní/bezztrátové zvukové toky. Přesná shoda skladeb na základě ISRC." -} \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb deleted file mode 100644 index 458e7c07..00000000 --- a/lib/l10n/app_de.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "Gast", - "browse": "Durchsuchen", - "search": "Suchen", - "library": "Bibliothek", - "lyrics": "Songtexte", - "settings": "Einstellungen", - "genre_categories_filter": "Filtere Kategorien oder Genres...", - "genre": "Genre", - "personalized": "Personalisiert", - "featured": "Empfohlen", - "new_releases": "Neue Veröffentlichungen", - "songs": "Songs", - "playing_track": "Wiedergabe: {track}", - "queue_clear_alert": "Dadurch wird die aktuelle Warteschlange gelöscht. {track_length} Titel werden entfernt.\nMöchten Sie fortfahren?", - "load_more": "Mehr laden", - "playlists": "Playlists", - "artists": "Künstler", - "albums": "Alben", - "tracks": "Titel", - "downloads": "Downloads", - "filter_playlists": "Filtere deine Playlists...", - "liked_tracks": "Gefällt mir-Titel", - "liked_tracks_description": "Alle deine geliketen Titel", - "create_playlist": "Playlist erstellen", - "create_a_playlist": "Erstelle eine Playlist", - "create": "Erstellen", - "cancel": "Abbrechen", - "playlist_name": "Playlist-Name", - "name_of_playlist": "Name der Playlist", - "description": "Beschreibung", - "public": "Öffentlich", - "collaborative": "Kollaborativ", - "search_local_tracks": "Lokale Titel durchsuchen...", - "play": "Wiedergabe", - "delete": "Löschen", - "none": "Keine", - "sort_a_z": "Sortieren nach A-Z", - "sort_z_a": "Sortieren nach Z-A", - "sort_artist": "Sortieren nach Künstler", - "sort_album": "Sortieren nach Album", - "sort_tracks": "Titel sortieren", - "currently_downloading": "Derzeitige Downloads ({tracks_length})", - "cancel_all": "Alle abbrechen", - "filter_artist": "Künstler filtern...", - "followers": "{followers} Follower", - "add_artist_to_blacklist": "Künstler zur Schwarzen Liste hinzufügen", - "top_tracks": "Top-Titel", - "fans_also_like": "Fans mögen auch", - "loading": "Laden...", - "artist": "Künstler", - "blacklisted": "Auf der Schwarzen Liste", - "following": "Folgen", - "follow": "Folgen", - "artist_url_copied": "Künstler-URL in Zwischenablage kopiert", - "added_to_queue": "{tracks} Titel zur Warteschlange hinzugefügt", - "filter_albums": "Alben filtern...", - "synced": "Synchronisiert", - "plain": "Einfach", - "shuffle": "Zufällige Wiedergabe", - "search_tracks": "Titel durchsuchen...", - "released": "Veröffentlicht", - "error": "Fehler {error}", - "title": "Titel", - "time": "Dauer", - "more_actions": "Weitere Aktionen", - "download_count": "Download ({count})", - "add_count_to_playlist": "Zu Playlist hinzufügen ({count})", - "add_count_to_queue": "Zur Warteschlange hinzufügen ({count})", - "play_count_next": "Als nächstes abspielen ({count})", - "album": "Album", - "copied_to_clipboard": "{data} in Zwischenablage kopiert", - "add_to_following_playlists": "{track} zu folgenden Playlists hinzufügen", - "add": "Hinzufügen", - "added_track_to_queue": "{track} zur Warteschlange hinzugefügt", - "add_to_queue": "Zur Warteschlange hinzufügen", - "track_will_play_next": "{track} wird als nächstes abgespielt", - "play_next": "Als nächstes abspielen", - "removed_track_from_queue": "{track} aus der Warteschlange entfernt", - "remove_from_queue": "Aus der Warteschlange entfernen", - "remove_from_favorites": "Aus Favoriten entfernen", - "save_as_favorite": "Als Favorit speichern", - "add_to_playlist": "Zur Playlist hinzufügen", - "remove_from_playlist": "Aus der Playlist entfernen", - "add_to_blacklist": "Zur Schwarzen Liste hinzufügen", - "remove_from_blacklist": "Aus der Schwarzen Liste entfernen", - "share": "Teilen", - "mini_player": "Mini-Player", - "slide_to_seek": "Zum Vor- oder Zurückspulen ziehen", - "shuffle_playlist": "Playlist mischen", - "unshuffle_playlist": "Playlist nicht mehr mischen", - "previous_track": "Vorheriger Track", - "next_track": "Nächster Track", - "pause_playback": "Wiedergabe pausieren", - "resume_playback": "Wiedergabe fortsetzen", - "loop_track": "Track wiederholen", - "repeat_playlist": "Playlist wiederholen", - "queue": "Warteschlange", - "alternative_track_sources": "Alternative Track-Quellen", - "download_track": "Track herunterladen", - "tracks_in_queue": "{tracks} Tracks in der Warteschlange", - "clear_all": "Alle löschen", - "show_hide_ui_on_hover": "UI beim Überfahren anzeigen/ausblenden", - "always_on_top": "Immer im Vordergrund", - "exit_mini_player": "Mini-Player verlassen", - "download_location": "Download-Speicherort", - "account": "Konto", - "login_with_spotify": "Mit deinem Spotify-Konto anmelden", - "connect_with_spotify": "Mit Spotify verbinden", - "logout": "Abmelden", - "logout_of_this_account": "Von diesem Konto abmelden", - "language_region": "Sprache & Region", - "language": "Sprache", - "system_default": "Systemstandard", - "market_place_region": "Marktplatzregion", - "recommendation_country": "Empfehlungsland", - "appearance": "Erscheinungsbild", - "layout_mode": "Layout-Modus", - "override_layout_settings": "Responsiven Layout-Modus-Einstellungen überschreiben", - "adaptive": "Adaptiv", - "compact": "Kompakt", - "extended": "Erweitert", - "theme": "Design", - "dark": "Dunkel", - "light": "Hell", - "system": "System", - "accent_color": "Akzentfarbe", - "sync_album_color": "Albumfarbe synchronisieren", - "sync_album_color_description": "Verwendet die dominante Farbe des Album Covers als Akzentfarbe", - "playback": "Wiedergabe", - "audio_quality": "Audioqualität", - "high": "Hoch", - "low": "Niedrig", - "pre_download_play": "Vorab herunterladen und abspielen", - "pre_download_play_description": "Anstatt Audio zu streamen, Bytes herunterladen und abspielen (Empfohlen für Benutzer mit hoher Bandbreite)", - "skip_non_music": "Überspringe Nicht-Musik-Segmente (SponsorBlock)", - "blacklist_description": "Gesperrte Titel und Künstler", - "wait_for_download_to_finish": "Bitte warten Sie, bis der aktuelle Download abgeschlossen ist", - "desktop": "Desktop", - "close_behavior": "Verhalten beim Schließen", - "close": "Schließen", - "minimize_to_tray": "In Taskleiste minimieren", - "show_tray_icon": "Systemsymbol anzeigen", - "about": "Über", - "u_love_spotube": "Wir wissen, dass Sie Spotube lieben", - "check_for_updates": "Nach Updates suchen", - "about_spotube": "Über Spotube", - "blacklist": "Gesperrte Titel", - "please_sponsor": "Bitte unterstützen/Spenden Sie", - "spotube_description": "Spotube, ein leichtgewichtiger, plattformübergreifender und kostenloser Spotify-Client", - "version": "Version", - "build_number": "Build-Nummer", - "founder": "Gründer", - "repository": "Repository", - "bug_issues": "Fehler und Probleme", - "made_with": "Entwickelt mit ❤️ in Bangladesch 🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Lizenz", - "add_spotify_credentials": "Fügen Sie Ihre Spotify-Anmeldeinformationen hinzu, um zu starten", - "credentials_will_not_be_shared_disclaimer": "Keine Sorge, Ihre Anmeldeinformationen werden nicht erfasst oder mit anderen geteilt", - "know_how_to_login": "Wissen Sie nicht, wie es geht?", - "follow_step_by_step_guide": "Befolgen Sie die schrittweise Anleitung", - "spotify_cookie": "Spotify {name} Cookie", - "cookie_name_cookie": "{name} Cookie", - "fill_in_all_fields": "Bitte füllen Sie alle Felder aus", - "submit": "Senden", - "exit": "Beenden", - "previous": "Zurück", - "next": "Weiter", - "done": "Fertig", - "step_1": "Schritt 1", - "first_go_to": "Gehe zuerst zu", - "login_if_not_logged_in": "und melde dich an/registriere dich, falls du nicht angemeldet bist", - "step_2": "Schritt 2", - "step_2_steps": "1. Wenn du angemeldet bist, drücke F12 oder klicke mit der rechten Maustaste > Inspektion, um die Browser-Entwicklertools zu öffnen.\n2. Gehe dann zum \"Anwendungs\"-Tab (Chrome, Edge, Brave usw.) oder zum \"Storage\"-Tab (Firefox, Palemoon usw.)\n3. Gehe zum Abschnitt \"Cookies\" und dann zum Unterabschnitt \"https://accounts.spotify.com\"", - "step_3": "Schritt 3", - "success_emoji": "Erfolg🥳", - "success_message": "Jetzt bist du erfolgreich mit deinem Spotify-Konto angemeldet. Gut gemacht, Kumpel!", - "step_4": "Schritt 4", - "something_went_wrong": "Etwas ist schiefgelaufen", - "piped_instance": "Piped-Serverinstanz", - "piped_description": "Die Piped-Serverinstanz, die zur Titelzuordnung verwendet werden soll", - "piped_warning": "Einige von ihnen funktionieren möglicherweise nicht gut. Verwende sie also auf eigenes Risiko", - "generate_playlist": "Playlist generieren", - "track_exists": "Track {track} existiert bereits", - "replace_downloaded_tracks": "Alle heruntergeladenen Titel ersetzen", - "skip_download_tracks": "Das Herunterladen aller heruntergeladenen Titel überspringen", - "do_you_want_to_replace": "Möchtest du den vorhandenen Track ersetzen?", - "replace": "Ersetzen", - "skip": "Überspringen", - "select_up_to_count_type": "Wähle bis zu {count} {type} aus", - "select_genres": "Genres auswählen", - "add_genres": "Genres hinzufügen", - "country": "Land", - "number_of_tracks_generate": "Anzahl der zu generierenden Titel", - "acousticness": "Akustik", - "danceability": "Tanzbarkeit", - "energy": "Energie", - "instrumentalness": "Instrumentalität", - "liveness": "Lebendigkeit", - "loudness": "Lautstärke", - "speechiness": "Sprechanteil", - "valence": "Stimmung", - "popularity": "Beliebtheit", - "key": "Tonart", - "duration": "Dauer (s)", - "tempo": "Tempo (BPM)", - "mode": "Modus", - "time_signature": "Taktart", - "short": "Kurz", - "medium": "Mittel", - "long": "Lang", - "min": "Min", - "max": "Max", - "target": "Ziel", - "moderate": "Mäßig", - "deselect_all": "Alle abwählen", - "select_all": "Alle auswählen", - "are_you_sure": "Bist du sicher?", - "generating_playlist": "Erstelle deine individuelle Wiedergabeliste...", - "selected_count_tracks": "{count} Titel ausgewählt", - "download_warning": "Wenn du alle Titel in großen Mengen herunterlädst, betreibst du eindeutig Raubkopien von Musik und schadest der kreativen Gesellschaft der Musik. Ich hoffe, dir ist dies bewusst. Versuche immer, die harte Arbeit der Künstler zu respektieren und zu unterstützen.", - "download_ip_ban_warning": "Übrigens, deine IP-Adresse kann aufgrund übermäßiger Downloadanfragen von YouTube gesperrt werden. Eine IP-Sperre bedeutet, dass du YouTube (auch wenn du angemeldet bist) für mindestens 2-3 Monate von diesem IP-Gerät aus nicht nutzen kannst. Spotube übernimmt keine Verantwortung, falls dies jemals geschieht.", - "by_clicking_accept_terms": "Durch Klicken auf 'Akzeptieren' stimmst du den folgenden Bedingungen zu:", - "download_agreement_1": "Ich weiß, dass ich Raubkopien von Musik betreibe. Ich bin böse.", - "download_agreement_2": "Ich werde die Künstler, wo immer ich kann, unterstützen, und ich tue dies nur, weil ich kein Geld habe, um ihre Kunst zu kaufen.", - "download_agreement_3": "Mir ist vollkommen bewusst, dass meine IP-Adresse auf YouTube gesperrt werden kann, und ich halte Spotube oder seine Eigentümer/Mitarbeiter nicht für etwaige Unfälle verantwortlich, die durch meine derzeitige Handlung verursacht werden.", - "decline": "Ablehnen", - "accept": "Akzeptieren", - "details": "Details", - "youtube": "YouTube", - "channel": "Kanal", - "likes": "Likes", - "dislikes": "Dislikes", - "views": "Aufrufe", - "streamUrl": "Stream-URL", - "stop": "Stopp", - "sort_newest": "Nach neuesten Hinzufügungen sortieren", - "sort_oldest": "Nach ältesten Hinzufügungen sortieren", - "sleep_timer": "Schlaftimer", - "mins": "{minutes} Minuten", - "hours": "{hours} Stunden", - "hour": "{hours} Stunde", - "custom_hours": "Benutzerdefinierte Stunden", - "logs": "Protokolle", - "developers": "Entwickler", - "not_logged_in": "Sie sind nicht angemeldet", - "search_mode": "Suchmodus", - "audio_source": "Audioquelle", - "ok": "OK", - "failed_to_encrypt": "Verschlüsselung fehlgeschlagen", - "encryption_failed_warning": "Spotube verwendet Verschlüsselung, um Ihre Daten sicher zu speichern. Dies ist jedoch fehlgeschlagen. Daher wird es auf unsichere Speicherung zurückgreifen\nWenn Sie Linux verwenden, stellen Sie bitte sicher, dass Sie Secret-Services wie gnome-keyring, kde-wallet und keepassxc installiert haben", - "querying_info": "Abfrageinformationen...", - "piped_api_down": "Die Piped API ist ausgefallen", - "piped_down_error_instructions": "Die Piped-Instanz {pipedInstance} ist derzeit nicht verfügbar\n\nEntweder ändern Sie die Instanz oder wechseln Sie den 'API-Typ' zur offiziellen YouTube API\n\nStellen Sie sicher, dass Sie die App nach der Änderung neu starten", - "you_are_offline": "Sie sind derzeit offline", - "connection_restored": "Ihre Internetverbindung wurde wiederhergestellt", - "use_system_title_bar": "System-Titelleiste verwenden", - "update_playlist": "Wiedergabeliste aktualisieren", - "update": "Aktualisieren", - "crunching_results": "Ergebnisse werden verarbeitet...", - "search_to_get_results": "Suche, um Ergebnisse zu erhalten", - "use_amoled_mode": "AMOLED-Modus verwenden", - "pitch_dark_theme": "Pitch Black Dart Theme", - "normalize_audio": "Audio normalisieren", - "change_cover": "Cover ändern", - "add_cover": "Cover hinzufügen", - "restore_defaults": "Standardeinstellungen wiederherstellen", - "download_music_codec": "Musik-Codec herunterladen", - "streaming_music_codec": "Streaming-Musik-Codec", - "login_with_lastfm": "Mit Last.fm anmelden", - "connect": "Verbinden", - "disconnect_lastfm": "Last.fm trennen", - "disconnect": "Trennen", - "username": "Benutzername", - "password": "Passwort", - "login": "Anmelden", - "login_with_your_lastfm": "Mit Ihrem Last.fm-Konto anmelden", - "scrobble_to_lastfm": "Auf Last.fm scrobbeln", - "go_to_album": "Zum Album gehen", - "discord_rich_presence": "Discord Rich Presence", - "browse_all": "Alles durchsuchen", - "genres": "Genres", - "explore_genres": "Genres erkunden", - "step_3_steps": "Kopiere den Wert des Cookies \"sp_dc\"", - "step_4_steps": "Füge den kopierten Wert von \"sp_dc\" ein", - "friends": "Freunde", - "no_lyrics_available": "Entschuldigung, Texte für diesen Track konnten nicht gefunden werden", - "sort_duration": "Nach Dauer sortieren", - "start_a_radio": "Radio starten", - "how_to_start_radio": "Wie möchten Sie das Radio starten?", - "replace_queue_question": "Möchten Sie die aktuelle Wiedergabeliste ersetzen oder hinzufügen?", - "endless_playback": "Endlose Wiedergabe", - "delete_playlist": "Wiedergabeliste löschen", - "delete_playlist_confirmation": "Sind Sie sicher, dass Sie diese Wiedergabeliste löschen möchten?", - "local_tracks": "Lokale Titel", - "song_link": "Lied-Link", - "skip_this_nonsense": "Diesen Unsinn überspringen", - "freedom_of_music": "“Freiheit der Musik”", - "freedom_of_music_palm": "“Freiheit der Musik in Ihrer Handfläche”", - "get_started": "Lass uns anfangen", - "youtube_source_description": "Empfohlen und funktioniert am besten.", - "piped_source_description": "Fühlen Sie sich frei? Wie YouTube, aber viel freier.", - "jiosaavn_source_description": "Am besten für die südasiatische Region.", - "highest_quality": "Höchste Qualität: {quality}", - "select_audio_source": "Audioquelle auswählen", - "endless_playback_description": "Neue Lieder automatisch\nam Ende der Wiedergabeliste hinzufügen", - "choose_your_region": "Wählen Sie Ihre Region", - "choose_your_region_description": "Dies wird Spotube helfen, Ihnen den richtigen Inhalt\nfür Ihren Standort anzuzeigen.", - "choose_your_language": "Wählen Sie Ihre Sprache", - "help_project_grow": "Helfen Sie diesem Projekt zu wachsen", - "help_project_grow_description": "Spotube ist ein Open-Source-Projekt. Sie können diesem Projekt helfen, indem Sie zum Projekt beitragen, Fehler melden oder neue Funktionen vorschlagen.", - "contribute_on_github": "Auf GitHub beitragen", - "donate_on_open_collective": "Auf Open Collective spenden", - "browse_anonymously": "Anonym durchsuchen", - "enable_connect": "Verbindung aktivieren", - "enable_connect_description": "Spotube von anderen Geräten steuern", - "devices": "Geräte", - "select": "Auswählen", - "connect_client_alert": "Du wirst von {client} gesteuert", - "this_device": "Dieses Gerät", - "remote": "Fernbedienung", - "local_library": "Lokale Bibliothek", - "add_library_location": "Zur Bibliothek hinzufügen", - "remove_library_location": "Aus der Bibliothek entfernen", - "local_tab": "Lokal", - "stats": "Statistiken", - "and_n_more": "und {count} mehr", - "recently_played": "Zuletzt gespielt", - "browse_more": "Mehr durchsuchen", - "no_title": "Kein Titel", - "not_playing": "Wird nicht abgespielt", - "epic_failure": "Episches Versagen!", - "added_num_tracks_to_queue": "{tracks_length} Titel zur Warteschlange hinzugefügt", - "spotube_has_an_update": "Spotube hat ein Update", - "download_now": "Jetzt herunterladen", - "nightly_version": "Spotube Nightly {nightlyBuildNum} wurde veröffentlicht", - "release_version": "Spotube v{version} wurde veröffentlicht", - "read_the_latest": "Lese die neuesten ", - "release_notes": "Versionshinweise", - "pick_color_scheme": "Farbschema wählen", - "save": "Speichern", - "choose_the_device": "Wähle das Gerät:", - "multiple_device_connected": "Es sind mehrere Geräte verbunden.\nWähle das Gerät, auf dem diese Aktion ausgeführt werden soll", - "nothing_found": "Nichts gefunden", - "the_box_is_empty": "Die Box ist leer", - "top_artists": "Top-Künstler", - "top_albums": "Top-Alben", - "this_week": "Diese Woche", - "this_month": "Diesen Monat", - "last_6_months": "Letzte 6 Monate", - "this_year": "Dieses Jahr", - "last_2_years": "Letzte 2 Jahre", - "all_time": "Alle Zeiten", - "powered_by_provider": "Bereitgestellt von {providerName}", - "email": "Email", - "profile_followers": "Follower", - "birthday": "Geburtstag", - "subscription": "Abonnement", - "not_born": "Nicht geboren", - "hacker": "Hacker", - "profile": "Profil", - "no_name": "Kein Name", - "edit": "Bearbeiten", - "user_profile": "Benutzerprofil", - "count_plays": "{count} Wiedergaben", - "streaming_fees_hypothetical": "Streaming-Gebühren (hypothetisch)", - "minutes_listened": "Gehörte Minuten", - "streamed_songs": "Gestreamte Lieder", - "count_streams": "{count} Streams", - "owned_by_you": "In Ihrem Besitz", - "copied_shareurl_to_clipboard": "{shareUrl} in die Zwischenablage kopiert", - "spotify_hipotetical_calculation": "*Dies ist basierend auf Spotifys\npro Stream Auszahlung von $0,003 bis $0,005\nberechnet. Dies ist eine hypothetische Berechnung,\num dem Benutzer Einblick zu geben,\nwieviel sie den Künstlern gezahlt hätten,\nwenn sie ihren Song auf Spotify gehört hätten.", - "count_mins": "{minutes} Minuten", - "summary_minutes": "Minuten", - "summary_listened_to_music": "Hat Musik gehört", - "summary_songs": "Lieder", - "summary_streamed_overall": "Insgesamt gestreamt", - "summary_owed_to_artists": "Den Künstlern geschuldet\nDiesen Monat", - "summary_artists": "Künstler", - "summary_music_reached_you": "Musik hat Sie erreicht", - "summary_full_albums": "volle Alben", - "summary_got_your_love": "Hat Ihre Liebe gewonnen", - "summary_playlists": "Wiedergabelisten", - "summary_were_on_repeat": "Wurden wiederholt", - "total_money": "Gesamt {money}", - "webview_not_found": "Webview nicht gefunden", - "webview_not_found_description": "Es ist keine Webview-Laufzeitumgebung auf Ihrem Gerät installiert.\nFalls installiert, stellen Sie sicher, dass es im environment PATH ist\n\nNach der Installation starten Sie die App neu", - "unsupported_platform": "Nicht unterstützte Plattform", - "invidious_instance": "Invidious-Serverinstanz", - "invidious_description": "Die Invidious-Serverinstanz zur Titelerkennung", - "invidious_warning": "Einige Instanzen funktionieren möglicherweise nicht gut. Benutzung auf eigene Gefahr", - "invidious_source_description": "Ähnlich wie Piped, aber mit höherer Verfügbarkeit", - "cache_music": "Musik zwischenspeichern", - "open": "Öffnen", - "cache_folder": "Cache-Ordner", - "export": "Exportieren", - "clear_cache": "Cache leeren", - "clear_cache_confirmation": "Möchten Sie den Cache leeren?", - "export_cache_files": "Cachedateien exportieren", - "found_n_files": "{count} Dateien gefunden", - "export_cache_confirmation": "Möchten Sie diese Dateien exportieren nach", - "exported_n_out_of_m_files": "{filesExported} von {files} Dateien exportiert", - "playlist": "Playlist", - "no_loop": "Kein Loop", - "generate": "Generieren", - "undo": "Rückgängig", - "download_all": "Alle herunterladen", - "add_all_to_playlist": "Alle zur Playlist hinzufügen", - "add_all_to_queue": "Alle zur Warteschlange hinzufügen", - "play_all_next": "Alle als Nächstes abspielen", - "pause": "Pause", - "view_all": "Alle ansehen", - "no_tracks_added_yet": "Sie haben noch keine Titel hinzugefügt.", - "no_tracks": "Es sieht so aus, als ob hier keine Titel sind.", - "no_tracks_listened_yet": "Es scheint, dass Sie noch nichts gehört haben.", - "not_following_artists": "Sie folgen noch keinem Künstler.", - "no_favorite_albums_yet": "Es sieht so aus, als ob Sie noch keine Alben zu Ihren Favoriten hinzugefügt haben.", - "no_logs_found": "Keine Protokolle gefunden", - "youtube_engine": "YouTube-Engine", - "youtube_engine_not_installed_title": "{engine} ist nicht installiert", - "youtube_engine_not_installed_message": "{engine} ist nicht auf Ihrem System installiert.", - "youtube_engine_set_path": "Stellen Sie sicher, dass es im PATH verfügbar ist oder\nsetzen Sie den absoluten Pfad zur {engine} ausführbaren Datei unten.", - "youtube_engine_unix_issue_message": "In macOS/Linux/unixähnlichen Betriebssystemen funktioniert das Setzen des Pfads in .zshrc/.bashrc/.bash_profile usw. nicht.\nSie müssen den Pfad in der Shell-Konfigurationsdatei festlegen.", - "download": "Herunterladen", - "file_not_found": "Datei nicht gefunden", - "custom": "Benutzerdefiniert", - "add_custom_url": "Benutzerdefinierte URL hinzufügen", - "edit_port": "Port bearbeiten", - "port_helper_msg": "Der Standardwert ist -1, was eine zufällige Zahl bedeutet. Wenn Sie eine Firewall konfiguriert haben, wird empfohlen, dies einzustellen.", - "connect_request": "{client} die Verbindung erlauben?", - "connection_request_denied": "Verbindung abgelehnt. Benutzer hat den Zugriff verweigert.", - "hipotetical_calculation": "*Diese Berechnung basiert auf der durchschnittlichen Auszahlung pro Stream (0,003 USD bis 0,005 USD) auf Online-Musik-Streaming-Plattformen. Sie ist hypothetisch und soll dem Nutzer veranschaulichen, wie viel er den Künstlern bezahlt hätte, wenn er ihren Song auf verschiedenen Streaming-Plattformen gehört hätte.", - "an_error_occurred": "Ein Fehler ist aufgetreten", - "copy_to_clipboard": "In die Zwischenablage kopieren", - "view_logs": "Protokolle anzeigen", - "retry": "Erneut versuchen", - "no_default_metadata_provider_selected": "Sie haben keinen Standard-Metadatenanbieter festgelegt", - "manage_metadata_providers": "Metadatenanbieter verwalten", - "open_link_in_browser": "Link im Browser öffnen?", - "do_you_want_to_open_the_following_link": "Möchten Sie folgenden Link öffnen?", - "unsafe_url_warning": "Das Öffnen von Links aus nicht vertrauenswürdigen Quellen kann unsicher sein. Seien Sie vorsichtig!\nSie können den Link auch in Ihre Zwischenablage kopieren.", - "copy_link": "Link kopieren", - "building_your_timeline": "Ihr Zeitverlauf wird basierend auf Ihren Hördaten erstellt…", - "official": "Offiziell", - "author_name": "Autor: {author}", - "third_party": "Drittanbieter", - "plugin_requires_authentication": "Plugin erfordert Authentifizierung", - "update_available": "Update verfügbar", - "supports_scrobbling": "Unterstützt Scrobbling", - "plugin_scrobbling_info": "Dieses Plugin scrobbelt Ihre Musik, um Ihre Hörhistorie zu erstellen.", - "default_plugin": "Standard", - "set_default": "Als Standard festlegen", - "support": "Unterstützung", - "support_plugin_development": "Plugin-Entwicklung unterstützen", - "can_access_name_api": "- Kann auf **{name}**-API zugreifen", - "do_you_want_to_install_this_plugin": "Möchten Sie dieses Plugin installieren?", - "third_party_plugin_warning": "Dieses Plugin stammt aus einem Drittanbieter-Repository. Bitte stellen Sie sicher, dass Sie der Quelle vertrauen, bevor Sie es installieren.", - "author": "Autor", - "this_plugin_can_do_following": "Dieses Plugin kann Folgendes:", - "install": "Installieren", - "install_a_metadata_provider": "Einen Metadatenanbieter installieren", - "no_tracks_playing": "Derzeit wird kein Titel abgespielt", - "synced_lyrics_not_available": "Synchronisierte Liedtexte sind für dieses Lied nicht verfügbar. Bitte verwenden Sie stattdessen", - "plain_lyrics": "Einfache Liedtexte", - "tab_instead": "stattdessen die Tab-Taste verwenden.", - "disclaimer": "Haftungsausschluss", - "third_party_plugin_dmca_notice": "Das Spotube-Team übernimmt keine Verantwortung (auch nicht rechtlicher Art) für Plugins \"Drittanbieter\". Nutzen Sie diese auf eigenes Risiko. Für Fehler/Probleme melden Sie sich bitte beim Plugin-Repository.\n\nWenn ein Plugin \"Drittanbieter\" gegen die ToS/DMCA eines Dienstes bzw. gesetzlicher Vorschriften verstößt, wenden Sie sich bitte an den Plugin-Autor oder die Hosting-Plattform (z. B. GitHub/Codeberg), um Maßnahmen zu ergreifen. Die genannten Plugins (mit \"Drittanbieter\"-Kennzeichnung) werden öffentlich und gemeinschaftlich gepflegt. Wir kuratieren sie nicht und können keine Maßnahmen ergreifen.\n\n", - "input_does_not_match_format": "Eingabe entspricht nicht dem geforderten Format", - "metadata_provider_plugins": "Plugins für Metadatenanbieter", - "paste_plugin_download_url": "Download-URL, GitHub/Codeberg-Repo-URL oder direkten Link zur .smplug-Datei einfügen", - "download_and_install_plugin_from_url": "Plugin per URL herunterladen und installieren", - "failed_to_add_plugin_error": "Plugin konnte nicht hinzugefügt werden: {error}", - "upload_plugin_from_file": "Plugin per Datei hochladen", - "installed": "Installiert", - "available_plugins": "Verfügbare Plugins", - "configure_your_own_metadata_plugin": "Eigenen Anbieter für Playlist-/Album-/Künstler-/Feed-Metadaten konfigurieren", - "audio_scrobblers": "Audio-Scrobbler", - "scrobbling": "Scrobbling", - "download_music_format": "Musik-Downloadformat", - "streaming_music_format": "Musik-Streamingformat", - "download_music_quality": "Musik-Downloadqualität", - "streaming_music_quality": "Musik-Streamingqualität", - "default_metadata_source": "Standard-Metadatenquelle", - "set_default_metadata_source": "Standard-Metadatenquelle festlegen", - "default_audio_source": "Standard-Audioquelle", - "set_default_audio_source": "Standard-Audioquelle festlegen", - "plugins": "Plugins", - "configure_plugins": "Richte deine eigenen Metadatenanbieter- und Audioquellen-Plugins ein", - "source": "Quelle: ", - "uncompressed": "Unkomprimiert", - "dab_music_source_description": "Für Audiophile. Bietet hochwertige/verlustfreie Audiostreams. Präzises ISRC-basiertes Track-Matching." -} \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb deleted file mode 100644 index 111d76a8..00000000 --- a/lib/l10n/app_en.arb +++ /dev/null @@ -1,473 +0,0 @@ -{ - "guest": "Guest", - "browse": "Browse", - "search": "Search", - "library": "Library", - "lyrics": "Lyrics", - "settings": "Settings", - "genre_categories_filter": "Filter categories or genres...", - "genre": "Genre", - "personalized": "Personalized", - "featured": "Featured", - "new_releases": "New Releases", - "songs": "Songs", - "playing_track": "Playing {track}", - "queue_clear_alert": "This will clear the current queue. {track_length} tracks will be removed\nDo you want to continue?", - "load_more": "Load more", - "playlists": "Playlists", - "artists": "Artists", - "albums": "Albums", - "tracks": "Tracks", - "downloads": "Downloads", - "filter_playlists": "Filter your playlists...", - "liked_tracks": "Liked Tracks", - "liked_tracks_description": "All your liked tracks", - "playlist": "Playlist", - "create_a_playlist": "Create a playlist", - "update_playlist": "Update playlist", - "create": "Create", - "cancel": "Cancel", - "update": "Update", - "playlist_name": "Playlist Name", - "name_of_playlist": "Name of the playlist", - "description": "Description", - "public": "Public", - "collaborative": "Collaborative", - "search_local_tracks": "Search local tracks...", - "play": "Play", - "delete": "Delete", - "none": "None", - "sort_a_z": "Sort by A-Z", - "sort_z_a": "Sort by Z-A", - "sort_artist": "Sort by Artist", - "sort_album": "Sort by Album", - "sort_duration": "Sort by Duration", - "sort_tracks": "Sort Tracks", - "currently_downloading": "Currently Downloading ({tracks_length})", - "cancel_all": "Cancel All", - "filter_artist": "Filter artists...", - "followers": "{followers} Followers", - "add_artist_to_blacklist": "Add artist to blacklist", - "top_tracks": "Top Tracks", - "fans_also_like": "Fans also like", - "loading": "Loading...", - "artist": "Artist", - "blacklisted": "Blacklisted", - "following": "Following", - "follow": "Follow", - "artist_url_copied": "Artist URL copied to clipboard", - "added_to_queue": "Added {tracks} tracks to queue", - "filter_albums": "Filter albums...", - "synced": "Synced", - "plain": "Plain", - "shuffle": "Shuffle", - "search_tracks": "Search tracks...", - "released": "Released", - "error": "Error {error}", - "title": "Title", - "time": "Time", - "more_actions": "More actions", - "download_count": "Download ({count})", - "add_count_to_playlist": "Add ({count}) to Playlist", - "add_count_to_queue": "Add ({count}) to Queue", - "play_count_next": "Play ({count}) next", - "album": "Album", - "copied_to_clipboard": "Copied {data} to clipboard", - "add_to_following_playlists": "Add {track} to following Playlists", - "add": "Add", - "added_track_to_queue": "Added {track} to queue", - "add_to_queue": "Add to queue", - "track_will_play_next": "{track} will play next", - "play_next": "Play next", - "removed_track_from_queue": "Removed {track} from queue", - "remove_from_queue": "Remove from queue", - "remove_from_favorites": "Remove from favorites", - "save_as_favorite": "Save as favorite", - "add_to_playlist": "Add to playlist", - "remove_from_playlist": "Remove from playlist", - "add_to_blacklist": "Add to blacklist", - "remove_from_blacklist": "Remove from blacklist", - "share": "Share", - "mini_player": "Mini Player", - "slide_to_seek": "Slide to seek forward or backward", - "shuffle_playlist": "Shuffle playlist", - "unshuffle_playlist": "Unshuffle playlist", - "previous_track": "Previous track", - "next_track": "Next track", - "pause_playback": "Pause Playback", - "resume_playback": "Resume Playback", - "loop_track": "Loop track", - "no_loop": "No loop", - "repeat_playlist": "Repeat playlist", - "queue": "Queue", - "alternative_track_sources": "Alternative track sources", - "download_track": "Download track", - "tracks_in_queue": "{tracks} tracks in queue", - "clear_all": "Clear all", - "show_hide_ui_on_hover": "Show/Hide UI on hover", - "always_on_top": "Always on top", - "exit_mini_player": "Exit Mini player", - "download_location": "Download location", - "local_library": "Local library", - "add_library_location": "Add to library", - "remove_library_location": "Remove from library", - "account": "Account", - "logout": "Logout", - "logout_of_this_account": "Logout of this account", - "language_region": "Language & Region", - "language": "Language", - "system_default": "System Default", - "market_place_region": "Marketplace Region", - "recommendation_country": "Recommendation Country", - "appearance": "Appearance", - "layout_mode": "Layout Mode", - "override_layout_settings": "Override responsive layout mode settings", - "adaptive": "Adaptive", - "compact": "Compact", - "extended": "Extended", - "theme": "Theme", - "dark": "Dark", - "light": "Light", - "system": "System", - "accent_color": "Accent Color", - "sync_album_color": "Sync album color", - "sync_album_color_description": "Uses the dominant color of the album art as the accent color", - "playback": "Playback", - "audio_quality": "Audio Quality", - "high": "High", - "low": "Low", - "pre_download_play": "Pre-download and play", - "pre_download_play_description": "Instead of streaming audio, download bytes and play instead (Recommended for higher bandwidth users)", - "skip_non_music": "Skip non-music segments (SponsorBlock)", - "blacklist_description": "Blacklisted tracks and artists", - "wait_for_download_to_finish": "Please wait for the current download to finish", - "desktop": "Desktop", - "close_behavior": "Close Behavior", - "close": "Close", - "minimize_to_tray": "Minimize to tray", - "show_tray_icon": "Show System tray icon", - "about": "About", - "u_love_spotube": "We know you love Spotube", - "check_for_updates": "Check for updates", - "about_spotube": "About Spotube", - "blacklist": "Blacklist", - "please_sponsor": "Please Sponsor/Donate", - "spotube_description": "Open source extensible music streaming platform and app, based on BYOMM (Bring your own music metadata) concept", - "version": "Version", - "build_number": "Build Number", - "founder": "Founder", - "repository": "Repository", - "bug_issues": "Bug+Issues", - "made_with": "Made with ❤️ in Bangladesh🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "License", - "credentials_will_not_be_shared_disclaimer": "Don't worry, any of your credentials won't be collected or shared with anyone", - "know_how_to_login": "Don't know how to do this?", - "follow_step_by_step_guide": "Follow along the Step by Step guide", - "cookie_name_cookie": "{name} Cookie", - "fill_in_all_fields": "Please fill in all the fields", - "submit": "Submit", - "exit": "Exit", - "previous": "Previous", - "next": "Next", - "done": "Done", - "step_1": "Step 1", - "first_go_to": "First, Go to", - "something_went_wrong": "Something went wrong", - "piped_instance": "Piped Server Instance", - "piped_description": "The Piped server instance to use for track matching", - "piped_warning": "Some of them might not work well. So use at your own risk", - "invidious_instance": "Invidious Server Instance", - "invidious_description": "The Invidious server instance to use for track matching", - "invidious_warning": "Some of them might not work well. So use at your own risk", - "generate": "Generate", - "track_exists": "Track {track} already exists", - "replace_downloaded_tracks": "Replace all downloaded tracks", - "skip_download_tracks": "Skip downloading all downloaded tracks", - "do_you_want_to_replace": "Do you want to replace the existing track??", - "replace": "Replace", - "skip": "Skip", - "select_up_to_count_type": "Select up to {count} {type}", - "select_genres": "Select Genres", - "add_genres": "Add Genres", - "country": "Country", - "number_of_tracks_generate": "Number of tracks to generate", - "acousticness": "Acousticness", - "danceability": "Danceability", - "energy": "Energy", - "instrumentalness": "Instrumentalness", - "liveness": "Liveness", - "loudness": "Loudness", - "speechiness": "Speechiness", - "valence": "Valence", - "popularity": "Popularity", - "key": "Key", - "duration": "Duration (s)", - "tempo": "Tempo (BPM)", - "mode": "Mode", - "time_signature": "Time Signature", - "short": "Short", - "medium": "Medium", - "long": "Long", - "min": "Min", - "max": "Max", - "target": "Target", - "moderate": "Moderate", - "deselect_all": "Deselect All", - "select_all": "Select All", - "are_you_sure": "Are you sure?", - "generating_playlist": "Generating your custom playlist...", - "selected_count_tracks": "Selected {count} tracks", - "download_warning": "If you download all Tracks at bulk you're clearly pirating Music & causing damage to the creative society of Music. I hope you are aware of this. Always, try respecting & supporting Artist's hard work", - "download_ip_ban_warning": "BTW, your IP can get blocked on YouTube due excessive download requests than usual. IP block means you can't use YouTube (even if you're logged in) for at least 2-3 months from that IP device. And Spotube doesn't hold any responsibility if this ever happens", - "by_clicking_accept_terms": "By clicking 'accept' you agree to following terms:", - "download_agreement_1": "I know I'm pirating Music. I'm bad", - "download_agreement_2": "I'll support the Artist wherever I can and I'm only doing this because I don't have money to buy their art", - "download_agreement_3": "I'm completely aware that my IP can get blocked on YouTube & I don't hold Spotube or his owners/contributors responsible for any accidents caused by my current action", - "decline": "Decline", - "accept": "Accept", - "details": "Details", - "youtube": "YouTube", - "channel": "Channel", - "likes": "Likes", - "dislikes": "Dislikes", - "views": "Views", - "streamUrl": "Stream URL", - "stop": "Stop", - "sort_newest": "Sort by newest added", - "sort_oldest": "Sort by oldest added", - "sleep_timer": "Sleep Timer", - "mins": "{minutes} Minutes", - "hours": "{hours} Hours", - "hour": "{hours} Hour", - "custom_hours": "Custom Hours", - "logs": "Logs", - "developers": "Developers", - "not_logged_in": "You're not logged in", - "search_mode": "Search Mode", - "audio_source": "Audio Source", - "ok": "Ok", - "failed_to_encrypt": "Failed to encrypt", - "encryption_failed_warning": "Spotube uses encryption to securely store your data. But failed to do so. So it'll fallback to insecure storage\nIf you're using linux, please make sure you've any secret-service (gnome-keyring, kde-wallet, keepassxc etc) installed", - "querying_info": "Querying info...", - "piped_api_down": "Piped API is down", - "piped_down_error_instructions": "The Piped instance {pipedInstance} is currently down\n\nEither change the instance or change the 'API type' to official YouTube API\n\nMake sure to restart the app after change", - "you_are_offline": "You are currently offline", - "connection_restored": "Your internet connection was restored", - "use_system_title_bar": "Use system title bar", - "crunching_results": "Crunching results...", - "search_to_get_results": "Search to get results", - "use_amoled_mode": "Pitch black dark theme", - "pitch_dark_theme": "AMOLED Mode", - "normalize_audio": "Normalize audio", - "change_cover": "Change cover", - "add_cover": "Add cover", - "restore_defaults": "Restore defaults", - "download_music_format": "Download music format", - "streaming_music_format": "Streaming music format", - "download_music_quality": "Download music quality", - "streaming_music_quality": "Streaming music quality", - "login_with_lastfm": "Login with Last.fm", - "connect": "Connect", - "disconnect_lastfm": "Disconnect Last.fm", - "disconnect": "Disconnect", - "username": "Username", - "password": "Password", - "login": "Login", - "login_with_your_lastfm": "Login with your Last.fm account", - "scrobble_to_lastfm": "Scrobble to Last.fm", - "go_to_album": "Go to Album", - "discord_rich_presence": "Discord Rich Presence", - "browse_all": "Browse All", - "genres": "Genres", - "explore_genres": "Explore Genres", - "friends": "Friends", - "no_lyrics_available": "Sorry, unable find lyrics for this track", - "start_a_radio": "Start a Radio", - "how_to_start_radio": "How do you want to start the radio?", - "replace_queue_question": "Do you want to replace the current queue or append to it?", - "endless_playback": "Endless Playback", - "delete_playlist": "Delete Playlist", - "delete_playlist_confirmation": "Are you sure you want to delete this playlist?", - "local_tracks": "Local Tracks", - "local_tab": "Local", - "song_link": "Song Link", - "skip_this_nonsense": "Skip this nonsense", - "freedom_of_music": "“Freedom of Music”", - "freedom_of_music_palm": "“Freedom of Music in the palm of your hand”", - "get_started": "Let's get started", - "youtube_source_description": "Recommended and works best.", - "piped_source_description": "Feeling free? Same as YouTube but a lot free.", - "jiosaavn_source_description": "Best for South Asian region.", - "invidious_source_description": "Similar to Piped but with higher availability.", - "highest_quality": "Highest Quality: {quality}", - "select_audio_source": "Select Audio Source", - "endless_playback_description": "Automatically append new songs\nto the end of the queue", - "choose_your_region": "Choose your region", - "choose_your_region_description": "This will help Spotube show you the right content\nfor your location.", - "choose_your_language": "Choose your language", - "help_project_grow": "Help this project grow", - "help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.", - "contribute_on_github": "Contribute on GitHub", - "donate_on_open_collective": "Donate on Open Collective", - "browse_anonymously": "Browse Anonymously", - "enable_connect": "Enable Connect", - "enable_connect_description": "Control Spotube from other devices", - "devices": "Devices", - "select": "Select", - "connect_client_alert": "You're being controlled by {client}", - "this_device": "This Device", - "remote": "Remote", - "stats": "Stats", - "and_n_more": "and {count} more", - "recently_played": "Recently Played", - "browse_more": "Browse More", - "no_title": "No Title", - "not_playing": "Not playing", - "epic_failure": "Epic failure!", - "added_num_tracks_to_queue": "Added {tracks_length} tracks to queue", - "spotube_has_an_update": "Spotube has an update", - "download_now": "Download Now", - "nightly_version": "Spotube Nightly {nightlyBuildNum} has been released", - "release_version": "Spotube v{version} has been released", - "read_the_latest": "Read the latest ", - "release_notes": "release notes", - "pick_color_scheme": "Pick color scheme", - "save": "Save", - "choose_the_device": "Choose the device:", - "multiple_device_connected": "There are multiple device connected.\nChoose the device you want this action to take place", - "nothing_found": "Nothing found", - "the_box_is_empty": "The box is empty", - "top_artists": "Top Artists", - "top_albums": "Top Albums", - "this_week": "This week", - "this_month": "This month", - "last_6_months": "Last 6 months", - "this_year": "This year", - "last_2_years": "Last 2 years", - "all_time": "All time", - "powered_by_provider": "Powered by {providerName}", - "email": "Email", - "profile_followers": "Followers", - "birthday": "Birthday", - "subscription": "Subscription", - "not_born": "Not born", - "hacker": "Hacker", - "profile": "Profile", - "no_name": "No Name", - "edit": "Edit", - "user_profile": "User Profile", - "count_plays": "{count} plays", - "streaming_fees_hypothetical": "Streaming fees (hypothetical)", - "minutes_listened": "Minutes listened", - "streamed_songs": "Streamed songs", - "count_streams": "{count} streams", - "owned_by_you": "Owned by you", - "copied_shareurl_to_clipboard": "Copied {shareUrl} to clipboard", - "hipotetical_calculation": "*This is calculated based on average online music streaming platform's per stream\npayout of $0.003 to $0.005. This is a hypothetical\ncalculation to give user insight about how much they\nwould have paid to the artists if they were to listen\ntheir song in different music streaming platform.", - "count_mins": "{minutes} mins", - "summary_minutes": "minutes", - "summary_listened_to_music": "Listened to music", - "summary_songs": "songs", - "summary_streamed_overall": "Streamed overall", - "summary_owed_to_artists": "Owed to artists\nthis month", - "summary_artists": "artist's", - "summary_music_reached_you": "Music reached you", - "summary_full_albums": "full albums", - "summary_got_your_love": "Got your love", - "summary_playlists": "playlists", - "summary_were_on_repeat": "Were on repeat", - "total_money": "Total {money}", - "webview_not_found": "Webview not found", - "webview_not_found_description": "No webview runtime is installed in your device.\nIf it's installed make sure it's in the Environment PATH\n\nAfter installing, restart the app", - "unsupported_platform": "Unsupported platform", - "cache_music": "Cache music", - "open": "Open", - "cache_folder": "Cache folder", - "export": "Export", - "clear_cache": "Clear cache", - "clear_cache_confirmation": "Do you want to clear the cache?", - "export_cache_files": "Export Cached Files", - "found_n_files": "Found {count} files", - "export_cache_confirmation": "Do you want to export these files to", - "exported_n_out_of_m_files": "Exported {filesExported} out of {files} files", - "undo": "Undo", - "download_all": "Download all", - "add_all_to_playlist": "Add all to playlist", - "add_all_to_queue": "Add all to queue", - "play_all_next": "Play all next", - "pause": "Pause", - "view_all": "View all", - "no_tracks_added_yet": "Looks like you haven't added any tracks yet", - "no_tracks": "Looks like there are no tracks here", - "no_tracks_listened_yet": "Looks like you haven't listened to anything yet", - "not_following_artists": "You're not following any artists", - "no_favorite_albums_yet": "Looks like you haven't added any albums to your favorites yet", - "no_logs_found": "No logs found", - "youtube_engine": "YouTube Engine", - "youtube_engine_not_installed_title": "{engine} is not installed", - "youtube_engine_not_installed_message": "{engine} is not installed in your system.", - "youtube_engine_set_path": "Make sure it's available in the PATH variable or\nset the absolute path to the {engine} executable below", - "youtube_engine_unix_issue_message": "In macOS/Linux/unix like OS's, setting path on .zshrc/.bashrc/.bash_profile etc. won't work.\nYou need to set the path in the shell configuration file", - "download": "Download", - "file_not_found": "File not found", - "custom": "Custom", - "add_custom_url": "Add custom URL", - "edit_port": "Edit port", - "port_helper_msg": "Default is -1 which indicates random number. If you've firewall configured, setting this is recommended.", - "connect_request": "Allow {client} to connect?", - "connection_request_denied": "Connection denied. User denied access.", - "an_error_occurred": "An error occurred", - "copy_to_clipboard": "Copy to clipboard", - "view_logs": "View logs", - "retry": "Retry", - "no_default_metadata_provider_selected": "You've no default metadata provider set", - "manage_metadata_providers": "Manage metadata providers", - "open_link_in_browser": "Open Link in Browser?", - "do_you_want_to_open_the_following_link": "Do you want to open the following link", - "unsafe_url_warning": "It can be unsafe to open links from untrusted sources. Be cautious!\nYou can also copy the link to your clipboard.", - "copy_link": "Copy Link", - "building_your_timeline": "Building your timeline based on your listenings...", - "official": "Official", - "author_name": "Author: {author}", - "third_party": "Third-party", - "plugin_requires_authentication": "Plugin requires authentication", - "update_available": "Update available", - "supports_scrobbling": "Supports scrobbling", - "plugin_scrobbling_info": "This plugin scrobbles your music to generate your listening history.", - "default_metadata_source": "Default metadata source", - "set_default_metadata_source": "Set default metadata source", - "default_audio_source": "Default audio source", - "set_default_audio_source": "Set default audio source", - "set_default": "Set default", - "support": "Support", - "support_plugin_development": "Support plugin development", - "can_access_name_api": "- Can access **{name}** API", - "do_you_want_to_install_this_plugin": "Do you want to install this plugin?", - "third_party_plugin_warning": "This plugin is from a third-party repository. Please ensure you trust the source before installing.", - "author": "Author", - "this_plugin_can_do_following": "This plugin can do following", - "install": "Install", - "install_a_metadata_provider": "Install a Metadata Provider", - "no_tracks_playing": "No Track being played currently", - "synced_lyrics_not_available": "Synced lyrics are not available for this song. Please use the", - "plain_lyrics": "Plain Lyrics", - "tab_instead": "tab instead.", - "disclaimer": "Disclaimer", - "third_party_plugin_dmca_notice": "The Spotube team does not hold any responsibility (including legal) for any \"Third-party\" plugins.\nPlease use them at your own risk. For any bugs/issues, please report them to the plugin repository.\n\nIf any \"Third-party\" plugin is breaking ToS/DMCA of any service/legal entity, please ask the \"Third-party\" plugin author or the hosting platform .e.g GitHub/Codeberg to take action. Above listed (\"Third-party\" labelled) are all public/community maintained plugins. We're not curating them, so we cannot take any action on them.\n\n", - "input_does_not_match_format": "Input doesn't match the required format", - "plugins": "Plugins", - "paste_plugin_download_url": "Paste download url or GitHub/Codeberg repo url or direct link to .smplug file", - "download_and_install_plugin_from_url": "Download and install plugin from url", - "failed_to_add_plugin_error": "Failed to add plugin: {error}", - "upload_plugin_from_file": "Upload plugin from file", - "installed": "Installed", - "available_plugins": "Available plugins", - "configure_plugins": "Configure your own metadata provider and audio source plugins", - "audio_scrobblers": "Audio Scrobblers", - "scrobbling": "Scrobbling", - "source": "Source: ", - "uncompressed": "Uncompressed", - "dab_music_source_description": "For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching." -} diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb deleted file mode 100644 index 32822763..00000000 --- a/lib/l10n/app_es.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "Invitado", - "browse": "Explorar", - "search": "Buscar", - "library": "Biblioteca", - "lyrics": "Letras", - "settings": "Configuración", - "genre_categories_filter": "Filtrar categorías o géneros...", - "genre": "Género", - "personalized": "Personalizado", - "featured": "Destacado", - "new_releases": "Nuevos Lanzamientos", - "songs": "Canciones", - "playing_track": "Reproduciendo {track}", - "queue_clear_alert": "Esto eliminará la lista actual. Se eliminarán {track_length} canciones.\n¿Deseas continuar?", - "load_more": "Cargar más", - "playlists": "Listas de reproducción", - "artists": "Artistas", - "albums": "Álbumes", - "tracks": "Canciones", - "downloads": "Descargas", - "filter_playlists": "Filtrar tus listas de reproducción...", - "liked_tracks": "Canciones Favoritas", - "liked_tracks_description": "Todas tus canciones favoritas", - "create_playlist": "Crear Lista de reproducción", - "create_a_playlist": "Crear una lista de reproducción", - "create": "Crear", - "cancel": "Cancelar", - "playlist_name": "Nombre de la lista", - "name_of_playlist": "Nombre de la lista", - "description": "Descripción", - "public": "Pública", - "collaborative": "Colaborativa", - "search_local_tracks": "Buscar canciones locales...", - "play": "Reproducir", - "delete": "Eliminar", - "none": "Ninguno", - "sort_a_z": "Ordenar de la A a la Z", - "sort_z_a": "Ordenar de la Z a la A", - "sort_artist": "Ordenar por Artista", - "sort_album": "Ordenar por Álbum", - "sort_tracks": "Ordenar Canciones", - "currently_downloading": "Descargando en curso ({tracks_length})", - "cancel_all": "Cancelar todo", - "filter_artist": "Filtrar artistas...", - "followers": "{followers} Seguidores", - "add_artist_to_blacklist": "Agregar artista a la lista negra", - "top_tracks": "Mejores Canciones", - "fans_also_like": "A los fans también les gusta", - "loading": "Cargando...", - "artist": "Artista", - "blacklisted": "En la lista negra", - "following": "Siguiendo", - "follow": "Seguir", - "artist_url_copied": "URL del artista copiada al portapapeles", - "added_to_queue": "Agregadas {tracks} canciones a la lista", - "filter_albums": "Filtrar álbumes...", - "synced": "Sincronizado", - "plain": "Normal", - "shuffle": "Aleatorio", - "search_tracks": "Buscar canciones...", - "released": "Lanzado", - "error": "Error {error}", - "title": "Título", - "time": "Duración", - "more_actions": "Más acciones", - "download_count": "Descargas ({count})", - "add_count_to_playlist": "Agregar ({count}) a la lista", - "add_count_to_queue": "Agregar ({count}) a la lista", - "play_count_next": "Reproducir ({count}) a continuación", - "album": "Álbum", - "copied_to_clipboard": "{data} copiado al portapapeles", - "add_to_following_playlists": "Agregar {track} a las listas de reproducción siguientes", - "add": "Agregar", - "added_track_to_queue": "{track} agregada a la lista", - "add_to_queue": "Agregar a la lista", - "track_will_play_next": "{track} se reproducirá a continuación", - "play_next": "Reproducir a continuación", - "removed_track_from_queue": "{track} eliminada de la lista", - "remove_from_queue": "Eliminar de la lista", - "remove_from_favorites": "Eliminar de favoritos", - "save_as_favorite": "Guardar como favorito", - "add_to_playlist": "Agregar a la lista", - "remove_from_playlist": "Eliminar de la lista", - "add_to_blacklist": "Agregar a la lista negra", - "remove_from_blacklist": "Eliminar de la lista negra", - "share": "Compartir", - "mini_player": "Reproductor Mini", - "slide_to_seek": "Desliza para buscar adelante o atrás", - "shuffle_playlist": "Reproducir lista en orden aleatorio", - "unshuffle_playlist": "Desactivar reproducción aleatoria", - "previous_track": "Pista anterior", - "next_track": "Pista siguiente", - "pause_playback": "Pausar reproducción", - "resume_playback": "Reanudar reproducción", - "loop_track": "Repetir pista", - "repeat_playlist": "Repetir lista", - "queue": "Lista", - "alternative_track_sources": "Fuentes alternativas de canciones", - "download_track": "Descargar canción", - "tracks_in_queue": "{tracks} canciones en la lista", - "clear_all": "Limpiar todo", - "show_hide_ui_on_hover": "Mostrar/Ocultar interfaz al pasar el cursor", - "always_on_top": "Siempre visible", - "exit_mini_player": "Salir del reproductor mini", - "download_location": "Ubicación de descargas", - "account": "Cuenta", - "login_with_spotify": "Iniciar sesión con tu cuenta de Spotify", - "connect_with_spotify": "Conectar con Spotify", - "logout": "Cerrar sesión", - "logout_of_this_account": "Cerrar sesión de esta cuenta", - "language_region": "Idioma y Región", - "language": "Idioma", - "system_default": "Predeterminado del sistema", - "market_place_region": "Región de la tienda", - "recommendation_country": "País de recomendación", - "appearance": "Apariencia", - "layout_mode": "Modo de diseño", - "override_layout_settings": "Anular la configuración del modo de diseño responsive", - "adaptive": "Adaptable", - "compact": "Compacto", - "extended": "Extendido", - "theme": "Tema", - "dark": "Oscuro", - "light": "Claro", - "system": "Sistema", - "accent_color": "Color de acento", - "sync_album_color": "Sincronizar color del álbum", - "sync_album_color_description": "Usa el color dominante del arte del álbum como color de acento", - "playback": "Reproducción", - "audio_quality": "Calidad de audio", - "high": "Alta", - "low": "Baja", - "pre_download_play": "Pre-descargar y reproducir", - "pre_download_play_description": "En lugar de transmitir audio, descarga bytes y reproduce en su lugar (recomendado para usuarios con mayor ancho de banda)", - "skip_non_music": "Omitir segmentos que no son música (SponsorBlock)", - "blacklist_description": "Canciones y artistas en la lista negra", - "wait_for_download_to_finish": "Por favor, espera a que termine la descarga actual", - "desktop": "Escritorio", - "close_behavior": "Comportamiento al cerrar", - "close": "Cerrar", - "minimize_to_tray": "Minimizar en la bandeja del sistema", - "show_tray_icon": "Mostrar icono en la bandeja del sistema", - "about": "Acerca de", - "u_love_spotube": "Sabemos que te encanta Spotube", - "check_for_updates": "Buscar actualizaciones", - "about_spotube": "Acerca de Spotube", - "blacklist": "Lista negra", - "please_sponsor": "Por favor, apoya/dona", - "spotube_description": "Spotube, un cliente ligero, multiplataforma y gratuito de Spotify", - "version": "Versión", - "build_number": "Número de compilación", - "founder": "Fundador", - "repository": "Repositorio", - "bug_issues": "Errores y problemas", - "made_with": "Hecho con ❤️ en Bangladesh🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Licencia", - "add_spotify_credentials": "Agrega tus credenciales de Spotify para comenzar", - "credentials_will_not_be_shared_disclaimer": "No te preocupes, tus credenciales no serán recopiladas ni compartidas con nadie", - "know_how_to_login": "¿No sabes cómo hacerlo?", - "follow_step_by_step_guide": "Sigue la guía paso a paso", - "spotify_cookie": "Cookie de Spotify {name}", - "cookie_name_cookie": "Cookie {name}", - "fill_in_all_fields": "Por favor, completa todos los campos", - "submit": "Enviar", - "exit": "Salir", - "previous": "Anterior", - "next": "Siguiente", - "done": "Listo", - "step_1": "Paso 1", - "first_go_to": "Primero, ve a", - "login_if_not_logged_in": "e inicia sesión/registra tu cuenta si no lo has hecho aún", - "step_2": "Paso 2", - "step_2_steps": "1. Una vez que hayas iniciado sesión, presiona F12 o haz clic derecho con el ratón > Inspeccionar para abrir las herramientas de desarrollo del navegador.\n2. Luego ve a la pestaña \"Application\" (Chrome, Edge, Brave, etc.) o \"Storage\" (Firefox, Palemoon, etc.)\n3. Ve a la sección \"Cookies\" y luego la subsección \"https://accounts.spotify.com\"", - "step_3": "Paso 3", - "success_emoji": "¡Éxito! 🥳", - "success_message": "Ahora has iniciado sesión con éxito en tu cuenta de Spotify. ¡Buen trabajo!", - "step_4": "Paso 4", - "something_went_wrong": "Algo salió mal", - "piped_instance": "Instancia del servidor Piped", - "piped_description": "La instancia del servidor Piped a utilizar para la coincidencia de pistas", - "piped_warning": "Algunas pueden no funcionar bien, úsalas bajo tu propio riesgo", - "generate_playlist": "Generar Lista de reproducción", - "track_exists": "La canción {track} ya existe", - "replace_downloaded_tracks": "Reemplazar todas las canciones descargadas", - "skip_download_tracks": "Omitir la descarga de todas las canciones descargadas", - "do_you_want_to_replace": "¿Deseas reemplazar la canción existente?", - "replace": "Reemplazar", - "skip": "Omitir", - "select_up_to_count_type": "Seleccionar hasta {count} {type}", - "select_genres": "Seleccionar Géneros", - "add_genres": "Agregar Géneros", - "country": "País", - "number_of_tracks_generate": "Número de canciones a generar", - "acousticness": "Acousticness", - "danceability": "Danceability", - "energy": "Energía", - "instrumentalness": "Instrumentalidad", - "liveness": "En vivo", - "loudness": "Volumen", - "speechiness": "Habla", - "valence": "Valencia", - "popularity": "Popularidad", - "key": "Tono", - "duration": "Duración (s)", - "tempo": "Tempo (BPM)", - "mode": "Modo", - "time_signature": "Compás", - "short": "Corto", - "medium": "Medio", - "long": "Largo", - "min": "Mín.", - "max": "Máx.", - "target": "Objetivo", - "moderate": "Moderado", - "deselect_all": "Deseleccionar todo", - "select_all": "Seleccionar todo", - "are_you_sure": "¿Estás seguro?", - "generating_playlist": "Generando tu lista de reproducción personalizada...", - "selected_count_tracks": "Seleccionadas {count} canciones", - "download_warning": "Si descargas todas las canciones de golpe, estás claramente pirateando música y causando daño a la sociedad creativa de la música. Espero que seas consciente de esto y siempre intentes respetar y apoyar el arduo trabajo de los artistas", - "download_ip_ban_warning": "Por cierto, tu IP puede ser bloqueada en YouTube debido a solicitudes de descarga excesivas. El bloqueo de IP significa que no podrás usar YouTube (incluso si has iniciado sesión) durante al menos 2-3 meses desde esa dirección IP. Y Spotube no se hace responsable si esto ocurre alguna vez", - "by_clicking_accept_terms": "Al hacer clic en 'Aceptar', aceptas los siguientes términos:", - "download_agreement_1": "Sé que estoy pirateando música. Soy malo", - "download_agreement_2": "Apoyaré al artista donde pueda y solo lo hago porque no tengo dinero para comprar su arte", - "download_agreement_3": "Soy completamente consciente de que mi IP puede ser bloqueada en YouTube y no responsabilizo a Spotube ni a sus dueños/contribuyentes por cualquier incidente causado por mi acción actual", - "decline": "Rechazar", - "accept": "Aceptar", - "details": "Detalles", - "youtube": "YouTube", - "channel": "Canal", - "likes": "Me gusta", - "dislikes": "No me gusta", - "views": "Vistas", - "streamUrl": "URL del streaming", - "stop": "Detener", - "sort_newest": "Ordenar por más recientes", - "sort_oldest": "Ordenar por más antiguos", - "sleep_timer": "Temporizador de apagado", - "mins": "{minutes} minutos", - "hours": "{hours} horas", - "hour": "{hours} hora", - "custom_hours": "Horas personalizadas", - "logs": "Registros", - "developers": "Desarrolladores", - "not_logged_in": "No has iniciado sesión", - "search_mode": "Modo de búsqueda", - "audio_source": "Fuente de audio", - "ok": "OK", - "failed_to_encrypt": "Error al cifrar", - "encryption_failed_warning": "Spotube utiliza el cifrado para almacenar sus datos de forma segura. Pero ha fallado. Por lo tanto, volverá a un almacenamiento no seguro\nSi está utilizando Linux, asegúrese de tener instalados servicios secretos como gnome-keyring, kde-wallet y keepassxc", - "querying_info": "Consultando información...", - "piped_api_down": "La API de Piped no está disponible", - "piped_down_error_instructions": "La instancia de Piped {pipedInstance} no está funcionando en este momento\n\nCambie la instancia o cambie el 'Tipo de API' a la API oficial de YouTube\n\nAsegúrese de reiniciar la aplicación después del cambio", - "you_are_offline": "Actualmente estás sin conexión", - "connection_restored": "Se ha restablecido tu conexión a internet", - "use_system_title_bar": "Usar la barra de título del sistema", - "update_playlist": "Actualizar lista de reproducción", - "update": "Actualizar", - "crunching_results": "Procesando resultados...", - "search_to_get_results": "Buscar para obtener resultados", - "use_amoled_mode": "Usar modo AMOLED", - "pitch_dark_theme": "Tema oscuro de dart", - "normalize_audio": "Normalizar audio", - "change_cover": "Cambiar portada", - "add_cover": "Agregar portada", - "restore_defaults": "Restaurar valores predeterminados", - "download_music_codec": "Descargar códec de música", - "streaming_music_codec": "Códec de música en streaming", - "login_with_lastfm": "Iniciar sesión con Last.fm", - "connect": "Conectar", - "disconnect_lastfm": "Desconectar de Last.fm", - "disconnect": "Desconectar", - "username": "Nombre de usuario", - "password": "Contraseña", - "login": "Iniciar sesión", - "login_with_your_lastfm": "Iniciar sesión con tu cuenta de Last.fm", - "scrobble_to_lastfm": "Scrobble a Last.fm", - "go_to_album": "Ir al álbum", - "discord_rich_presence": "Presencia rica en Discord", - "browse_all": "Explorar todo", - "genres": "Géneros", - "explore_genres": "Explorar géneros", - "step_3_steps": "Copia el valor de la cookie \"sp_dc\"", - "step_4_steps": "Pega el valor copiado de \"sp_dc\"", - "friends": "Amigos", - "no_lyrics_available": "Lo siento, no se pueden encontrar las letras de esta pista", - "sort_duration": "Ordenar por Duración", - "start_a_radio": "Iniciar una Radio", - "how_to_start_radio": "¿Cómo quieres iniciar la radio?", - "replace_queue_question": "¿Quieres reemplazar la lista de reproducción actual o añadir a ella?", - "endless_playback": "Reproducción Infinita", - "delete_playlist": "Eliminar Lista de Reproducción", - "delete_playlist_confirmation": "¿Estás seguro de que quieres eliminar esta lista de reproducción?", - "local_tracks": "Pistas Locales", - "song_link": "Enlace de la Canción", - "skip_this_nonsense": "Saltar esta tontería", - "freedom_of_music": "“Libertad de la Música”", - "freedom_of_music_palm": "“Libertad de la Música en la palma de tu mano”", - "get_started": "Empecemos", - "youtube_source_description": "Recomendado y funciona mejor.", - "piped_source_description": "¿Te sientes libre? Igual que YouTube pero más libre.", - "jiosaavn_source_description": "Lo mejor para la región del sur de Asia.", - "highest_quality": "Mayor Calidad: {quality}", - "select_audio_source": "Seleccionar Fuente de Audio", - "endless_playback_description": "Añadir automáticamente nuevas canciones\nal final de la cola de reproducción", - "choose_your_region": "Elige tu región", - "choose_your_region_description": "Esto ayudará a Spotube a mostrarte el contenido adecuado\npara tu ubicación.", - "choose_your_language": "Elige tu idioma", - "help_project_grow": "Ayuda a que este proyecto crezca", - "help_project_grow_description": "Spotube es un proyecto de código abierto. Puedes ayudar a que este proyecto crezca contribuyendo al proyecto, informando errores o sugiriendo nuevas funciones.", - "contribute_on_github": "Contribuir en GitHub", - "donate_on_open_collective": "Donar en Open Collective", - "browse_anonymously": "Navegar Anónimamente", - "enable_connect": "Habilitar conexión", - "enable_connect_description": "Controla Spotube desde otros dispositivos", - "devices": "Dispositivos", - "select": "Seleccionar", - "connect_client_alert": "Estás siendo controlado por {client}", - "this_device": "Este dispositivo", - "remote": "Remoto", - "local_library": "Biblioteca local", - "add_library_location": "Añadir a la biblioteca", - "remove_library_location": "Eliminar de la biblioteca", - "local_tab": "Local", - "stats": "Estadísticas", - "and_n_more": "y {count} más", - "recently_played": "Recién reproducido", - "browse_more": "Explorar más", - "no_title": "Sin título", - "not_playing": "No reproduciendo", - "epic_failure": "¡Fallo épico!", - "added_num_tracks_to_queue": "Se añadieron {tracks_length} canciones a la cola", - "spotube_has_an_update": "Spotube tiene una actualización", - "download_now": "Descargar ahora", - "nightly_version": "Spotube Nightly {nightlyBuildNum} ha sido lanzado", - "release_version": "Spotube v{version} ha sido lanzado", - "read_the_latest": "Lee las últimas ", - "release_notes": "notas de la versión", - "pick_color_scheme": "Elige esquema de color", - "save": "Guardar", - "choose_the_device": "Elige el dispositivo:", - "multiple_device_connected": "Hay múltiples dispositivos conectados.\nElige el dispositivo en el que deseas realizar esta acción", - "nothing_found": "Nada encontrado", - "the_box_is_empty": "La caja está vacía", - "top_artists": "Artistas principales", - "top_albums": "Álbumes principales", - "this_week": "Esta semana", - "this_month": "Este mes", - "last_6_months": "Últimos 6 meses", - "this_year": "Este año", - "last_2_years": "Últimos 2 años", - "all_time": "Todos los tiempos", - "powered_by_provider": "Impulsado por {providerName}", - "email": "Correo electrónico", - "profile_followers": "Seguidores", - "birthday": "Cumpleaños", - "subscription": "Suscripción", - "not_born": "No nacido", - "hacker": "Hacker", - "profile": "Perfil", - "no_name": "Sin nombre", - "edit": "Editar", - "user_profile": "Perfil de usuario", - "count_plays": "{count} reproducciones", - "streaming_fees_hypothetical": "Tarifas de streaming (hipotéticas)", - "minutes_listened": "Minutos escuchados", - "streamed_songs": "Canciones reproducidas", - "count_streams": "{count} streams", - "owned_by_you": "En tu posesión", - "copied_shareurl_to_clipboard": "Copiado {shareUrl} al portapapeles", - "spotify_hipotetical_calculation": "*Esto se calcula en base al\npago por stream de Spotify de $0.003 a $0.005.\nEs un cálculo hipotético para dar\nuna idea de cuánto habría\npagado a los artistas si hubieras escuchado\nsu canción en Spotify.", - "count_mins": "{minutes} minutos", - "summary_minutes": "minutos", - "summary_listened_to_music": "Escuchó música", - "summary_songs": "canciones", - "summary_streamed_overall": "Transmitido en general", - "summary_owed_to_artists": "Debido a los artistas\nEste mes", - "summary_artists": "artistas", - "summary_music_reached_you": "La música te alcanzó", - "summary_full_albums": "álbumes completos", - "summary_got_your_love": "Obtuvo tu amor", - "summary_playlists": "listas de reproducción", - "summary_were_on_repeat": "Estaban en repetición", - "total_money": "Total {money}", - "webview_not_found": "No se encontró el Webview", - "webview_not_found_description": "No hay tiempo de ejecución de Webview instalado en su dispositivo.\nSi está instalado, asegúrese de que esté en el environment PATH\n\nDespués de instalar, reinicie la aplicación", - "unsupported_platform": "Plataforma no soportada", - "invidious_instance": "Instancia del Servidor Invidious", - "invidious_description": "La instancia del servidor Invidious para identificar pistas", - "invidious_warning": "Algunas instancias podrían no funcionar bien. Úselas bajo su propio riesgo", - "invidious_source_description": "Similar a Piped, pero con mayor disponibilidad", - "cache_music": "Caché de música", - "open": "Abrir", - "cache_folder": "Carpeta de caché", - "export": "Exportar", - "clear_cache": "Limpiar caché", - "clear_cache_confirmation": "¿Desea limpiar la caché?", - "export_cache_files": "Exportar archivos en caché", - "found_n_files": "Se encontraron {count} archivos", - "export_cache_confirmation": "¿Desea exportar estos archivos a", - "exported_n_out_of_m_files": "Se exportaron {filesExported} de {files} archivos", - "playlist": "Lista de reproducción", - "no_loop": "Sin bucle", - "generate": "Generar", - "undo": "Deshacer", - "download_all": "Descargar todo", - "add_all_to_playlist": "Agregar todo a la lista de reproducción", - "add_all_to_queue": "Agregar todo a la cola", - "play_all_next": "Reproducir todo a continuación", - "pause": "Pausa", - "view_all": "Ver todo", - "no_tracks_added_yet": "Parece que aún no has agregado ninguna canción.", - "no_tracks": "Parece que no hay canciones aquí.", - "no_tracks_listened_yet": "Parece que no has escuchado nada todavía.", - "not_following_artists": "No sigues a ningún artista.", - "no_favorite_albums_yet": "Parece que aún no has agregado ningún álbum a tus favoritos.", - "no_logs_found": "No se encontraron registros", - "youtube_engine": "Motor de YouTube", - "youtube_engine_not_installed_title": "{engine} no está instalado", - "youtube_engine_not_installed_message": "{engine} no está instalado en tu sistema.", - "youtube_engine_set_path": "Asegúrate de que esté disponible en la variable PATH o\nestablece la ruta absoluta del ejecutable de {engine} a continuación.", - "youtube_engine_unix_issue_message": "En macOS/Linux/sistemas operativos similares a Unix, establecer la ruta en .zshrc/.bashrc/.bash_profile etc. no funcionará.\nNecesitas establecer la ruta en el archivo de configuración del shell.", - "download": "Descargar", - "file_not_found": "Archivo no encontrado", - "custom": "Personalizado", - "add_custom_url": "Agregar URL personalizada", - "edit_port": "Editar puerto", - "port_helper_msg": "El valor predeterminado es -1, lo que indica un número aleatorio. Si tienes un firewall configurado, se recomienda establecer esto.", - "connect_request": "¿Permitir que {client} se conecte?", - "connection_request_denied": "Conexión denegada. El usuario denegó el acceso.", - "hipotetical_calculation": "*Este cálculo se basa en el pago promedio por reproducción en plataformas de música en línea (de 0,003 a 0,005 USD). Es hipotético y sirve para dar al usuario una idea de cuánto habría pagado a los artistas si hubiera escuchado su canción en distintas plataformas.", - "an_error_occurred": "Ocurrió un error", - "copy_to_clipboard": "Copiar al portapapeles", - "view_logs": "Ver registros", - "retry": "Reintentar", - "no_default_metadata_provider_selected": "No has configurado un proveedor de metadatos predeterminado", - "manage_metadata_providers": "Gestionar proveedores de metadatos", - "open_link_in_browser": "¿Abrir enlace en el navegador?", - "do_you_want_to_open_the_following_link": "¿Quieres abrir el siguiente enlace?", - "unsafe_url_warning": "Abrir enlaces de fuentes no confiables puede ser inseguro. ¡Ten cuidado!\nTambién puedes copiar el enlace al portapapeles.", - "copy_link": "Copiar enlace", - "building_your_timeline": "Construyendo tu línea de tiempo según tus escuchas…", - "official": "Oficial", - "author_name": "Autor: {author}", - "third_party": "Terceros", - "plugin_requires_authentication": "El complemento requiere autenticación", - "update_available": "Actualización disponible", - "supports_scrobbling": "Admite scrobbling", - "plugin_scrobbling_info": "Este complemento scrobblea tu música para generar tu historial de reproducción.", - "default_plugin": "Predeterminado", - "set_default": "Establecer como predeterminado", - "support": "Soporte", - "support_plugin_development": "Apoyar el desarrollo del complemento", - "can_access_name_api": "- Puede acceder a la API de **{name}**", - "do_you_want_to_install_this_plugin": "¿Deseas instalar este complemento?", - "third_party_plugin_warning": "Este complemento proviene de un repositorio de terceros. Asegúrate de confiar en la fuente antes de instalarlo.", - "author": "Autor", - "this_plugin_can_do_following": "Este complemento puede hacer lo siguiente", - "install": "Instalar", - "install_a_metadata_provider": "Instalar un proveedor de metadatos", - "no_tracks_playing": "No hay ninguna pista reproduciéndose actualmente", - "synced_lyrics_not_available": "Las letras sincronizadas no están disponibles para esta canción. Por favor, utiliza", - "plain_lyrics": "Letras sin formato", - "tab_instead": "en su lugar, usa la tecla Tab.", - "disclaimer": "Descargo de responsabilidad", - "third_party_plugin_dmca_notice": "El equipo de Spotube no asume ninguna responsabilidad (incluida la legal) por complementos de \"terceros\". Úsalos bajo tu propio riesgo. Para errores o problemas, repórtalos en el repositorio del complemento.\n\nSi algún complemento de “terceros” infringe los ToS/DMCA de algún servicio o entidad legal, por favor, solicita al autor del complemento o a la plataforma de alojamiento (p. ej., GitHub/Codeberg) que tome medidas. Los complementos etiquetados como “de terceros” son mantenidos públicamente por la comunidad; no los gestionamos y no podemos intervenir.\n\n", - "input_does_not_match_format": "La entrada no coincide con el formato requerido", - "metadata_provider_plugins": "Complementos de proveedor de metadatos", - "paste_plugin_download_url": "Pega la URL de descarga, el repositorio de GitHub/Codeberg o el enlace directo al archivo .smplug", - "download_and_install_plugin_from_url": "Descargar e instalar el complemento desde una URL", - "failed_to_add_plugin_error": "Error al añadir el complemento: {error}", - "upload_plugin_from_file": "Subir complemento desde archivo", - "installed": "Instalado", - "available_plugins": "Complementos disponibles", - "configure_your_own_metadata_plugin": "Configura tu propio proveedor de metadatos para listas/álbum/artista/feeds", - "audio_scrobblers": "Scrobblers de audio", - "scrobbling": "Scrobbling", - "download_music_format": "Formato de descarga de música", - "streaming_music_format": "Formato de transmisión de música", - "download_music_quality": "Calidad de descarga de música", - "streaming_music_quality": "Calidad de transmisión de música", - "default_metadata_source": "Fuente de metadatos predeterminada", - "set_default_metadata_source": "Establecer fuente de metadatos predeterminada", - "default_audio_source": "Fuente de audio predeterminada", - "set_default_audio_source": "Establecer fuente de audio predeterminada", - "plugins": "Plugins", - "configure_plugins": "Configura tus propios plugins de proveedor de metadatos y fuente de audio", - "source": "Fuente: ", - "uncompressed": "Sin comprimir", - "dab_music_source_description": "Para audiófilos. Proporciona transmisiones de audio de alta calidad/sin pérdida. Coincidencia precisa de pistas basada en ISRC." -} \ No newline at end of file diff --git a/lib/l10n/app_eu.arb b/lib/l10n/app_eu.arb deleted file mode 100644 index 8c87fd2c..00000000 --- a/lib/l10n/app_eu.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "Gonbidatua", - "browse": "Arakatu", - "search": "Bilatu", - "library": "Liburutegia", - "lyrics": "Hitzak", - "settings": "Ezarpenak", - "genre_categories_filter": "Kategoria edo generoak filtratu...", - "genre": "Generoa", - "personalized": "Pertsonalizatua", - "featured": "Nabarmenduak", - "new_releases": "Argitaratze berriak", - "songs": "Abestiak", - "playing_track": "{track} erreproduzitzen", - "queue_clear_alert": "Uneko zerrenda ezabatuko da. {track_length} abesti ezabatuko dira.\nJarraitu nahi duzu?", - "load_more": "Gehiago kargatu", - "playlists": "Zerrendak", - "artists": "Artistak", - "albums": "Albumak", - "tracks": "Kantak", - "downloads": "Deskargak", - "filter_playlists": "Zure zerrendak filtratu...", - "liked_tracks": "Gustuko Kantak", - "liked_tracks_description": "Zure gustuko kanta guztiak", - "create_playlist": "Sortu zerrenda", - "create_a_playlist": "Sortu zerrenda bat", - "update_playlist": "Eguneratu zerrenda", - "create": "Sortu", - "cancel": "Ezeztatu", - "update": "Eguneratu", - "playlist_name": "Zerrenda Izena", - "name_of_playlist": "Zerrendaren izena", - "description": "Deskribapena", - "public": "Publikoa", - "collaborative": "Kolaboratiboa", - "search_local_tracks": "Bilatu kanta lokalak...", - "play": "Erreproduzitu", - "delete": "Ezabatu", - "none": "Batere ez", - "sort_a_z": "Ordenatu A-Z", - "sort_z_a": "Ordenatu Z-A", - "sort_artist": "Ordenatu Artistaren arabera", - "sort_album": "Ordenatu Albumaren arabera", - "sort_duration": "Ordenar Iraupenaren arabera", - "sort_tracks": "Ordenatu Kantak", - "currently_downloading": "Oraintxe ({tracks_length}) deskargatzen", - "cancel_all": "Ezeztatu dena", - "filter_artist": "Filtratu artistak...", - "followers": "{followers} Jarraitzaile", - "add_artist_to_blacklist": "Gehitu artista zerrenda beltzera", - "top_tracks": "Top Kantak", - "fans_also_like": "Fan-ek hau ere gustuko dute", - "loading": "Kargatzen...", - "artist": "Artista", - "blacklisted": "Zerrenda beltzean", - "following": "Jarraitzen", - "follow": "Jarraitu", - "artist_url_copied": "Artistaren URL-a arbelera kopiatua", - "added_to_queue": "{tracks} kanta zerrendara gehituak", - "filter_albums": "Albumak filtratu...", - "synced": "Sinkronizatuta", - "plain": "Arrunta", - "shuffle": "Ausaz", - "search_tracks": "Bilatu kantak...", - "released": "Argitaratua", - "error": "Errorea: {error}", - "title": "Izenburua", - "time": "Iraupena", - "more_actions": "Ekintza gehiago", - "download_count": "({count}) deskarga", - "add_count_to_playlist": "Gehitu ({count}) zerrendara", - "add_count_to_queue": "Gehitu ({count}) ilarara", - "play_count_next": "Erreproduzitu hurrengo ({count})-ak", - "album": "Albuma", - "copied_to_clipboard": "{data} arbelean kopiatua", - "add_to_following_playlists": "Gehitu {track} hurrengo erreprodukzio-zerrendetara", - "add": "Gehitu", - "added_track_to_queue": "{track} zerrendan gehitua", - "add_to_queue": "Gehitu zerrendan", - "track_will_play_next": "{track} erreproduzituko da ondoren", - "play_next": "Hurrengo erreprodukzioa", - "removed_track_from_queue": "{track} zerrendatik ezabatua", - "remove_from_queue": "Ezabatu ilaratik", - "remove_from_favorites": "Ezabatu gogokoetatik", - "save_as_favorite": "Gorde gogokoetan", - "add_to_playlist": "Gehitu zerrendara", - "remove_from_playlist": "Ezabatu zerrendatik", - "add_to_blacklist": "Gehitu zerrenda beltzera", - "remove_from_blacklist": "Ezabatu zerrenda beltzetik", - "share": "Elkarbanatu", - "mini_player": "Mini Erreproduzitzailea", - "slide_to_seek": "Arrastatu aurrerantz edo atzearantz bilatzeko", - "shuffle_playlist": "Erreproduzitu zerrenda ausazko ordenean", - "unshuffle_playlist": "Desgaitu ausazko erreprodukzioa", - "previous_track": "Aurreko pista", - "next_track": "Hurrengo pista", - "pause_playback": "Pausatu erreprodukzioa", - "resume_playback": "Berrabiarazi erreprodukzioa", - "loop_track": "Kanta begiztan", - "repeat_playlist": "Errepikatu lista", - "queue": "Ilara", - "alternative_track_sources": "Kanten iturri alternatiboak", - "download_track": "Deskargatu kanta", - "tracks_in_queue": "{tracks} kanta zerrendan", - "clear_all": "Garbitu dena", - "show_hide_ui_on_hover": "Erakutsi/Ezkutatu interfazea kurtsorea pasatzean", - "always_on_top": "Beti ikusgai", - "exit_mini_player": "Irten mini erreproduzitzailetik", - "download_location": "Deskargen kokapena", - "local_library": "Liburutegi lokala", - "add_library_location": "Gehitu liburutegira", - "remove_library_location": "Kendu liburutegitik", - "account": "Kontua", - "login_with_spotify": "Hasi saioa zure Spotify kontuarekin", - "connect_with_spotify": "Spotify-rekin konektatu", - "logout": "Itxi saioa", - "logout_of_this_account": "Itxi kontu honen saioa", - "language_region": "Hizkuntza eta Herrialdea", - "language": "Hizkuntza", - "system_default": "Sisteman lehenetsia", - "market_place_region": "Dendaren herrialdea", - "recommendation_country": "Gomendio herrialdea", - "appearance": "Itxura", - "layout_mode": "Diseinua", - "override_layout_settings": "Responsive diseinuaren ezarpenak ezeztatu", - "adaptive": "Moldagarria", - "compact": "Trinkoa", - "extended": "Hedatua", - "theme": "Gaia", - "dark": "Iluna", - "light": "Argia", - "system": "Sistema", - "accent_color": "Azentu kolorea", - "sync_album_color": "Sinkronizatu albumaren kolorea", - "sync_album_color_description": "Albumaren artearen kolore nagusia erabili azentu kolore bezala", - "playback": "Erreprodukzioa", - "audio_quality": "Audioaren kalitatea", - "high": "Altua", - "low": "Baxua", - "pre_download_play": "Aurre-deskargatu eta erreproduzitu", - "pre_download_play_description": "Streaming egin beharrean, byte-ak deskargatu eta erreproduzitu (banda-zabalera handia duten erabiltzaileentzat gomendagarria)", - "skip_non_music": "Musika ez diren segmentuak baztertu (SponsorBlock)", - "blacklist_description": "Zerrenda beltzeko abesti eta artistak", - "wait_for_download_to_finish": "Mesedez, itxaron uneko deskarga bukatu arte", - "desktop": "Mahaigaina", - "close_behavior": "Ixterako Portaera", - "close": "Itxi", - "minimize_to_tray": "Sistemako erretilura minimizatu", - "show_tray_icon": "Erakutsi ikonoa sistemaren erretiluan", - "about": "Honi buruz", - "u_love_spotube": "Badakigu Spotube maite duzula", - "check_for_updates": "Bilatu eguneraketak", - "about_spotube": "Spotube-ri buruz", - "blacklist": "Zerrenda beltza", - "please_sponsor": "Mesedez, babestu/diruz lagundu", - "spotube_description": "Spotube, arina, plataforma-anitza eta doakoa den Spotify-ren bezeroa", - "version": "Bertsioa", - "build_number": "Konpilazio zenbakia", - "founder": "Sortzailea", - "repository": "Errepositorioa", - "bug_issues": "Erroreak eta arazoak", - "made_with": "Bangladesh🇧🇩-en ❤️-z egina", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Lizentzia", - "add_spotify_credentials": "Gehitu zure Spotify kredentzialak hasi ahal izateko", - "credentials_will_not_be_shared_disclaimer": "Ez arduratu, zure kredentzialak ez ditugu bilduko edo inorekin elkarbanatuko", - "know_how_to_login": "Ez dakizu nola egin?", - "follow_step_by_step_guide": "Jarraitu pausoz-pausoko gida", - "spotify_cookie": "Spotify-ren {name} cookiea", - "cookie_name_cookie": "{name} cookiea", - "fill_in_all_fields": "Mesedez, osatu eremu guztiak", - "submit": "Bidali", - "exit": "Irten", - "previous": "Aurrekoa", - "next": "Hurrengoa", - "done": "Eginda", - "step_1": "1. pausua", - "first_go_to": "Hasteko, joan hona", - "login_if_not_logged_in": "eta hasi saioa/sortu kontua lehendik ez baduzu eginda", - "step_2": "2. pausua", - "step_2_steps": "1. Saioa hasita duzularik, sakatu F12 edo saguaren eskuineko botoia klikatu > Ikuskatu nabigatzaileko garapen tresnak irekitzeko.\n2. Joan \"Aplikazio\" (Chrome, Edge, Brave, etab.) edo \"Biltegiratzea\" (Firefox, Palemoon, etab.)\n3. Joan \"Cookieak\" atalera eta gero \"https://accounts.spotify.com\" azpiatalera", - "step_3": "3. pausua", - "step_3_steps": "Kopiatu \"sp_dc\" cookiearen balioa", - "success_emoji": "Eginda! 🥳", - "success_message": "Ongi hasi duzu zure Spotify kontua. Lan bikaina, lagun!", - "step_4": "4. pausua", - "step_4_steps": "Itsatsi \"sp_dc\"-tik kopiatutako balioa", - "something_went_wrong": "Zerbaitek huts egin du", - "piped_instance": "Piped zerbitzariaren instantzia", - "piped_description": "Kanten koizidentzietan erabiltzeko Piped zerbitzariaren instantzia", - "piped_warning": "Batzuk agian ez dute ongi funtzionatuko, zure ardurapean erabili", - "generate_playlist": "Sortu Zerrenda", - "track_exists": "{track} kanta dagoeneko badago", - "replace_downloaded_tracks": "Ordezkatu deskargatutako kanta guztiak", - "skip_download_tracks": "Deskargatutako kanta guztien deskarga baztertu", - "do_you_want_to_replace": "Dagoen kanta ordezkatu nahi duzu??", - "replace": "Ordezkatu", - "skip": "Baztertu", - "select_up_to_count_type": "Aukertu {count} {type}", - "select_genres": "Aukeratu Generoak", - "add_genres": "Gehitu Generoak", - "country": "Herrialdea", - "number_of_tracks_generate": "Sortzeko kanta kopurua", - "acousticness": "Akustikotasuna", - "danceability": "Dantzagarritasuna", - "energy": "Energia", - "instrumentalness": "Instrumentaltasuna", - "liveness": "Zuzenean", - "loudness": "Ozentasuna", - "speechiness": "Hitzaldia", - "valence": "Balentzia", - "popularity": "Populartasuna", - "key": "Tonua", - "duration": "Iraupena (s)", - "tempo": "Tenpoa (BPM)", - "mode": "Modua", - "time_signature": "Konpasa", - "short": "Motza", - "medium": "Ertaina", - "long": "Luzea", - "min": "Min.", - "max": "Max.", - "target": "Helburua", - "moderate": "Moderatua", - "deselect_all": "Desaukeratu dena", - "select_all": "Aukeratu dena", - "are_you_sure": "Ziur zaude?", - "generating_playlist": "Zure pertsonalizatutako zerrenda sortzen...", - "selected_count_tracks": "{count} kanta aukeratuta", - "download_warning": "Abesti guztiak aldi berean deskargatuz gero, argi dago musika pirateatzen ari zarela eta musikaren gizarte sortzaileari kalte egiten diozula. Honen jakitun izan eta artisten lan gogorra errespetatu eta babestea espero dut", - "download_ip_ban_warning": "Bidenabar, baliteke zure IPa YouTuben blokeatzea deskarga eskera gehiegi egiten badituzu. IPa blokeatzeak esan nahi du ezin izango duzula YouTube erabili (nahiz eta saioa hasia izan) gutxienez 2-3 hilabetez IP helbide horretatik. Eta Spotube ez da erantzule izango hori gertatzen bazaizu", - "by_clicking_accept_terms": "'Onartu' klikatzean, ondorengo baldintzak onartzen dituzu:", - "download_agreement_1": "Badakit musika pirateatzen ari naizela. Gaiztoa naiz", - "download_agreement_2": "Ahal dudanean lagunduko diot artistari baina oraingoz ez dut bere artea erosteko dirurik", - "download_agreement_3": "Erabat jakitun naiz YouTubek nire IPa blokea dezakeela eta ez diot Spotube-ri edo bere jabe/laguntzaileei erantzukizunik eskatuko nire oraingo jokaerak ekar ditzakeen arazoengatik", - "decline": "Baztertu", - "accept": "Onartu", - "details": "Xehetasunak", - "youtube": "YouTube", - "channel": "Kanala", - "likes": "Gustukoak", - "dislikes": "Ez gustukoak", - "views": "Ikuspenak", - "streamUrl": "Streaming-aren URLa", - "stop": "Gelditu", - "sort_newest": "Ordenatu gehitu berrienetik", - "sort_oldest": "Ordenatu gehitu zaharrenetik", - "sleep_timer": "Itzaltzeko tenporizadorea", - "mins": "{minutes} minutu", - "hours": "{hours} ordu", - "hour": "{hours} ordu", - "custom_hours": "Ordu pertsonalizatuak", - "logs": "Log-ak", - "developers": "Garatzaileak", - "not_logged_in": "Ez duzu saioa hasi", - "search_mode": "Bilaketa modua", - "audio_source": "Audio Iturria", - "ok": "OK", - "failed_to_encrypt": "Errorea zifratzean", - "encryption_failed_warning": "Spotube-ek zifratzea darabil datuak modu seguruan biltegiratzeko. Baina huts egin du. Hori dela eta, biltegiratzea ez da segurua izango\nLinux erabiltzen ari bazara, ziurtatu edozein sekretu-zerbitzu (gnome-keyring, kde-wallet, keepassxc etab.) instalatuta duzula", - "querying_info": "Informazioa egiaztatzen...", - "piped_api_down": "Piped-en APIa ez dago eskuragarri", - "piped_down_error_instructions": "Piped-en {pipedInstance} instantzia ez dago martxan une honetan\n\nAldatu instantzia edo aldatu 'API mota' YouTuberen API ofizialera\n\nZiurtatu aplikazioa berrabiarazten duzula aldaketa eta gero", - "you_are_offline": "Une honetan konexiorik gabe zaude", - "connection_restored": "Internet konexioa berrezarri egin da", - "use_system_title_bar": "Erabili sistemako izenburu barra", - "crunching_results": "Emaitzak prozesatzen...", - "search_to_get_results": "Bilatu emaitzak lortzeko", - "use_amoled_mode": "Erabili AMOLED modua", - "pitch_dark_theme": "Dart-en gai iluna", - "normalize_audio": "Normalizatu audioa", - "change_cover": "Aldatu azala", - "add_cover": "Gehitu azala", - "restore_defaults": "Berrezarri berezko balioak", - "download_music_codec": "Deskargatutako musikaren codec-a", - "streaming_music_codec": "Streaming musikaren codec-a", - "login_with_lastfm": "Hasi saioa Last.fm-n", - "connect": "Konektatu", - "disconnect_lastfm": "Deskonektatu Last.fm-tik", - "disconnect": "Deskonektatu", - "username": "Erabiltzaile izena", - "password": "Pasahitza", - "login": "Hasi saioa", - "login_with_your_lastfm": "Hasi saioa Last.fm-ko zure kontuarekin", - "scrobble_to_lastfm": "Scrobble Last.fm-ra", - "go_to_album": "Albumera joan", - "discord_rich_presence": "Discord-en presentzia aberatsa", - "browse_all": "Esploratu dena", - "genres": "Generoak", - "explore_genres": "Esploratu generoak", - "friends": "Lagunak", - "no_lyrics_available": "Sentitzen dugu, ezin dira kanta honen hitzak aurkitu", - "start_a_radio": "Hasi Irrati bat", - "how_to_start_radio": "Nola hasi nahi duzu irratia?", - "replace_queue_question": "Uneko zerrenda ordezkatu nahi duzu edo bertan gehitu?", - "endless_playback": "Amaigabeko erreprodukzioa", - "delete_playlist": "Ezabatu zerrenda", - "delete_playlist_confirmation": "Ziur zaude zerrenda ezabatu nahi duzula?", - "local_tracks": "Kanta lokalak", - "local_tab": "Lokalean", - "song_link": "Kantaren lotura", - "skip_this_nonsense": "Utzi txorakeria hau", - "freedom_of_music": "“Musika Askatasuna”", - "freedom_of_music_palm": "“Musika Askatasuna zure eskuetan”", - "get_started": "Has gaitezen", - "youtube_source_description": "Gomendatua eta hobekien dabilena.", - "piped_source_description": "Aske zara? YouTube bezala, baino askeago.", - "jiosaavn_source_description": "Asia hegoaldeko herrialdeetarako hoberena.", - "highest_quality": "Kalitate Onena: {quality}", - "select_audio_source": "Aukeratu Audio Iturria", - "endless_playback_description": "Gehitu automatikoki kanta berriak\n ilararen bukaeran", - "choose_your_region": "Aukeratu zure herrialdea", - "choose_your_region_description": "Honekin Spotube-k zure kokalerakuari dagokion edukia\neskeiniko dizu.", - "choose_your_language": "Aukeratu zure hizkuntza", - "help_project_grow": "Lagundu proiektu honi hazten", - "help_project_grow_description": "Spotube kode irekiko proiektu bat da. Proiektu hau hazten lagundu dezakezu, erroreak jakinaraziz edo ezaugarri berriak proposatuz.", - "contribute_on_github": "GitHub-en lagundu", - "donate_on_open_collective": "Open Collective-en diruz lagundu", - "browse_anonymously": "Nabigatu Anonimoki", - "enable_connect": "Gaitu konexioa", - "enable_connect_description": "Kontrolatu Spotube beste gailu batzuetatik", - "devices": "Gailuak", - "select": "Aukeratu", - "connect_client_alert": "{client} gailuak kontrolatzen zaitu", - "this_device": "Gailu hau", - "remote": "Urrunekoa", - "stats": "Estatistikak", - "and_n_more": "eta {count} gehiago", - "recently_played": "Berriki entzunak", - "browse_more": "Gehiago Bilatu", - "no_title": "Titulurik ez", - "not_playing": "Erreprodukziorik ez", - "epic_failure": "Sekulako errorea!", - "added_num_tracks_to_queue": "{tracks_length} kanta gehitu dira zerrendara", - "spotube_has_an_update": "Spotube-ren eguneraketa bat dago", - "download_now": "Orain deskargatu", - "nightly_version": "Spotube {nightlyBuildNum} Nightly-a argitaratu da", - "release_version": "Spotube v{version} argitaratu da", - "read_the_latest": "Irakurri azken ", - "release_notes": "argitatratze oharrak", - "pick_color_scheme": "Aukeratu kolore eskema", - "save": "Gorde", - "choose_the_device": "Aukeratu gailua:", - "multiple_device_connected": "Hainbat gailu daude konektatuta.\nAukeratu zein gailutan aplikatu nahi duzun ekintza hau", - "nothing_found": "Ezer ez da aurkitu", - "the_box_is_empty": "Kaxa hutsik dago", - "top_artists": "Top Artistak", - "top_albums": "Top Albumak", - "this_week": "Aste honetan", - "this_month": "Hilabete honetan", - "last_6_months": "Azken 6 hilabeteetan", - "this_year": "Aurten", - "last_2_years": "Azken 2 urtetan", - "all_time": "Betidanik", - "powered_by_provider": "{providerName}-ren eskutik", - "email": "Email", - "profile_followers": "Jarraitzaileak", - "birthday": "Jaiotze-data", - "subscription": "Harpidetzak", - "not_born": "Jaio gabe", - "hacker": "Hacker", - "profile": "Profila", - "no_name": "Izenik Ez", - "edit": "Editatu", - "user_profile": "Erabiltzaile Profila", - "count_plays": "{count} erreprodukzio", - "streaming_fees_hypothetical": "Streaming ordainketa (hipotetikoa)", - "minutes_listened": "Entzundako minutuak", - "streamed_songs": "Streaming-ez entzundako kantak", - "count_streams": "{count} stream", - "owned_by_you": "Zure jabetzakoa", - "copied_shareurl_to_clipboard": "{shareUrl} arbelera kopiatua", - "spotify_hipotetical_calculation": "*Sportify-k stream bakoitzeko duen $0.003 eta $0.005\nordainsarian oinarritua da. Kalkulu hipotetiko bat,\nkanta hauek Spotify-n entzun bazenitu,\nberaiek artistari zenbat ordaiduko lioketen jakin dezazun.", - "count_mins": "{minutes} minutu", - "summary_minutes": "minutu", - "summary_listened_to_music": "Musika entzuten", - "summary_songs": "kanta", - "summary_streamed_overall": "Streaming abesti oro har", - "summary_owed_to_artists": "Hilabete honetan\nartistei zor zaiena", - "summary_artists": "artisten", - "summary_music_reached_you": "Musika ailegatu zaizu", - "summary_full_albums": "album osok", - "summary_got_your_love": "Jaso dute zure maitasuna", - "summary_playlists": "zerrenda", - "summary_were_on_repeat": "Dituzu errepikatze moduan", - "total_money": "Guztira {money}", - "webview_not_found": "Ez da Webview aurkitu", - "webview_not_found_description": "Ez dago Webview abiarazte denbora-instalaziorik zure gailuan.\nInstalatuta badago, ziurtatu environment PATH-an dagoela\n\nInstalatu ondoren, berrabiarazi aplikazioa", - "unsupported_platform": "Plataforma ez onartua", - "invidious_instance": "Invidious zerbitzari instantzia", - "invidious_description": "Invidious zerbitzari instantzia, pistak bat egiteko", - "invidious_warning": "Instantzia batzuek ez dute ondo funtzionatuko. Zure erantzukizunpean erabili", - "invidious_source_description": "Piped-en antzekoa, baina eskuragarritasun handiagoarekin", - "cache_music": "Musika cachean", - "open": "Ireki", - "cache_folder": "Cache karpeta", - "export": "Esportatu", - "clear_cache": "Garbitu cachea", - "clear_cache_confirmation": "Cachea garbitu nahi al duzu?", - "export_cache_files": "Esportatu cache fitxategiak", - "found_n_files": "{count} fitxategi aurkitu dira", - "export_cache_confirmation": "Fitxategi hauek esportatu nahi al dituzu", - "exported_n_out_of_m_files": "{filesExported} fitxategi esportatu dira {files} -tik", - "playlist": "Playlist", - "no_loop": "Ez dago loop-ik", - "generate": "Sortu", - "undo": "Desegondu", - "download_all": "Guztia deskargatu", - "add_all_to_playlist": "Guztia playlist-era gehitu", - "add_all_to_queue": "Guztia zerrendara gehitu", - "play_all_next": "Guztia hurrengoan jolastu", - "pause": "Pausatu", - "view_all": "Ikusi guztia", - "no_tracks_added_yet": "Dirudienez, oraindik ez duzu abestirik gehitu.", - "no_tracks": "Ez dirudi hemen abestirik dagoenik.", - "no_tracks_listened_yet": "Dirudienez, oraindik ez duzu ezer entzun.", - "not_following_artists": "Ez zaude artisten atzetik.", - "no_favorite_albums_yet": "Dirudienez, oraindik ez duzu albumik gehitu zure gogokoen artean.", - "no_logs_found": "Ez dira log-ak aurkitu", - "youtube_engine": "YouTube Motorra", - "youtube_engine_not_installed_title": "{engine} ez dago instalatuta", - "youtube_engine_not_installed_message": "{engine} ez dago zure sisteman instalatuta.", - "youtube_engine_set_path": "Ziurtatu PATH aldagaiaren barruan dagoela edo\nezarri {engine} exekutagarriaren helbide absolutua behean.", - "youtube_engine_unix_issue_message": "macOS/Linux/Unix bezalako sistemetan, .zshrc/.bashrc/.bash_profile bezalako fitxategietan bidearen ezarpenak ez dira funtzionatuko.\nBidearen ezarpena shell konfigurazio fitxategian egin behar duzu.", - "download": "Deskargatu", - "file_not_found": "Fitxategia ez da aurkitu", - "custom": "Pertsonalizatua", - "add_custom_url": "Gehitu URL pertsonalizatua", - "edit_port": "Editatu portua", - "port_helper_msg": "Lehenetsitako balioa -1 da, zenbaki aleatorioa adierazten duena. Su firewall konfiguratu baduzu, gomendatzen da hau ezartzea.", - "connect_request": "{client} konektatzea baimendu?", - "connection_request_denied": "Konektatzea ukatu da. Erabiltzaileak sarbidea ukatu du.", - "hipotetical_calculation": "*Kalkulu hau online musika-streaming plataformetako batez besteko irteerako ordainari (0,003–0,005 USD) oinarrituta dago. Hipotetikoa da eta erabiltzaileari ideia bat ematen laguntzen dio artista nork zenbat kobratu zuen jakiteko, bere abestia plataform desberdinetan entzungo balu.", - "an_error_occurred": "Errore bat gertatu da", - "copy_to_clipboard": "Hiztegiraino kopiatzea", - "view_logs": "Erregistroak ikusi", - "retry": "Berriro saiatu", - "no_default_metadata_provider_selected": "Ezarri ez duzu metadaten hornitzaile lehenetsirik", - "manage_metadata_providers": "Metadaten hornitzaileak kudeatu", - "open_link_in_browser": "Esteka nabigatzailean irekiko duzu?", - "do_you_want_to_open_the_following_link": "Hurrengo esteka irekiko duzu?", - "unsafe_url_warning": "Iturri seguru gabeko estekak irekiz gero, ez da seguru suerta daiteke. Arduratu zaitez!\nEsteka ere hiztegirainokoan kopiatu dezakezu.", - "copy_link": "Esteka kopiatu", - "building_your_timeline": "Zure entzuteen arabera zure kronologia eraikitzen…", - "official": "Ofiziala", - "author_name": "Egilea: {author}", - "third_party": "Hirugarrena", - "plugin_requires_authentication": "Pluginak autentifikazioa eskatzen du", - "update_available": "Eguneratze bat dago eskuragarri", - "supports_scrobbling": "Scrobbling-a onartzen du", - "plugin_scrobbling_info": "Plugin honek zure musika scrobbled egiten du zure entzuteen historia sortzeko.", - "default_plugin": "Lehenetsia", - "set_default": "Lehenetsi gisa ezarri", - "support": "Laguntza", - "support_plugin_development": "Pluginaren garapena lagundu", - "can_access_name_api": "- **{name}** API-ra sar daiteke", - "do_you_want_to_install_this_plugin": "Plugin hau instalatu nahiko zenuke?", - "third_party_plugin_warning": "Plugin hau hirugarrenen biltegi batetik dator. Instalatu aurretik iturriari konfiantza behar diozu.", - "author": "Egilea", - "this_plugin_can_do_following": "Plugin honek honako hau egin dezake:", - "install": "Instalatu", - "install_a_metadata_provider": "Metadaten hornitzaile bat instalatu", - "no_tracks_playing": "Une honetan ez dago abestirik erreproduzitzen", - "synced_lyrics_not_available": "Abestiarentzako letra sinkronizatua ez dago erabilgarri. Mesedez, erabili", - "plain_lyrics": "Letra arrunta", - "tab_instead": "horren ordez, Tab teklatxaza erabili.", - "disclaimer": "Aldez aurreko oharra", - "third_party_plugin_dmca_notice": "Spotube taldea ezin da arduratu (“hirugarrenen”) plugin-en>gatik (barne legala). Erabili zure arriskuarekin. Erroreak/ arazoak dituzu, jakinarazi pluginaren biltegiari.\n\nPlugin batek edozein zerbitzu/legalki entitate baten ToS/DMCA hautsi baditu, eska iezaiozu pluginaren egileari edo hosting plataformari (adibidez GitHub/Codeberg) neurriak har ditzaten. “Hirugarrena” etiketatutako plugin guztiak komunitate publikoaren bidez mantentzen dira; ez ditugu kuratoriatu, beraz ezin dugu inplikatu.\n\n", - "input_does_not_match_format": "Sarrera ezin da beharrezko formatutik desberdina izan", - "metadata_provider_plugins": "Metadaten hornitzailearen pluginak", - "paste_plugin_download_url": "Kopiatu deskarga-URLa, GitHub/Codeberg biltegi-URLa edo .smplug fitxategiaren esteka zuzena", - "download_and_install_plugin_from_url": "Download eta instalatu plugin-a URL batetik", - "failed_to_add_plugin_error": "Plugin gehitu ezin izan da: {error}", - "upload_plugin_from_file": "Plugin fitxategi batetik igo", - "installed": "Instalatuta", - "available_plugins": "Eskaintzen diren pluginak", - "configure_your_own_metadata_plugin": "Konfiguratu zureko playlists-/album-/artista-/feed-metadaten hornitzailea", - "audio_scrobblers": "Audio scrobbler-ak", - "scrobbling": "Scrobbling", - "download_music_format": "Musika deskargatzeko formatua", - "streaming_music_format": "Musika streaming bidezko formatua", - "download_music_quality": "Musika deskargaren kalitatea", - "streaming_music_quality": "Streaming bidezko musika kalitatea", - "default_metadata_source": "Metadatu-iturburu lehenetsia", - "set_default_metadata_source": "Ezarri metadatu-iturburu lehenetsia", - "default_audio_source": "Audio-iturburu lehenetsia", - "set_default_audio_source": "Ezarri audio-iturburu lehenetsia", - "plugins": "Pluginak", - "configure_plugins": "Konfiguratu zure metadatu-hornitzaile eta audio-iturburu pluginak", - "source": "Iturburua: ", - "uncompressed": "Konprimitu gabea", - "dab_music_source_description": "Audiozaleentzat. Kalitate handiko/galerarik gabeko audio-streamak eskaintzen ditu. ISRC oinarritutako pistaren parekatze zehatza." -} \ No newline at end of file diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb deleted file mode 100644 index 72f775fc..00000000 --- a/lib/l10n/app_fa.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "مهمان", - "browse": "مرور", - "search": "جستجو", - "library": "مجموعه", - "lyrics": "متن", - "settings": "تنظیمات", - "genre_categories_filter": "دسته ها یا ژانر ها را فیلتر کنید", - "genre": "ژانر", - "personalized": " شخصی سازی شده", - "featured": "ویژه", - "new_releases": "آخرین انتشارات", - "songs": "آهنگ ها", - "playing_track": "درحال پخش {track}", - "queue_clear_alert": "با این کار صف فعلی پاک می شود. {track_length} آهنگ از صف حذف میشود\n؟آیا ادامه میدهید", - "load_more": "بارگذاری بیشتر", - "playlists": "لیست های پخش", - "artists": "هنرمندان", - "albums": "آلبوم ها", - "tracks": "آهنگ ها", - "downloads": "بارگیری شده ها", - "filter_playlists": "لیست پخش خود را فیلتر کنید...", - "liked_tracks": "آهنگ های مورد علاقه", - "liked_tracks_description": "همه آهنگ های دوست داشتنی شما", - "create_playlist": "ساخت لیست پخش", - "create_a_playlist": "ساخت لیست پخش", - "update_playlist": "بروز کردن لیست پخش", - "create": "ساختن", - "cancel": "لغو", - "update": "بروز رسانی", - "playlist_name": "نام لیست پخش", - "name_of_playlist": "نام لیست پخش", - "description": "توضیحات", - "public": "عمومی", - "collaborative": "مبتنی بر همکاری", - "search_local_tracks": "جستجوی آهنگ های محلی...", - "play": "پخش", - "delete": "حذف", - "none": "هیچ کدام", - "sort_a_z": "مرتب سازی بر اساس حروف الفبا", - "sort_z_a": "مرتب سازی برعکس حروف الفبا", - "sort_artist": "مرتب سازی بر اساس هنرمند", - "sort_album": "مرتب سازی بر اساس آلبوم", - "sort_tracks": "مرتب سازی آهنگ ها", - "currently_downloading": "در حال بارگیری ({tracks_length})", - "cancel_all": "لغو همه", - "filter_artist": "فیلتر کردن هنرمند...", - "followers": "{followers} دنبال کننده", - "add_artist_to_blacklist": "اضافه کردن هنرمند به لیست سیاه", - "top_tracks": "بهترین آهنگ ها", - "fans_also_like": "طرفداران هم دوست داشتند", - "loading": "بارگزاری...", - "artist": "هنرمند", - "blacklisted": "در لیست سیاه قرار گرفته است", - "following": "دنبال کننده", - "follow": "دنبال کردن", - "artist_url_copied": "لینک هنرمند در کلیپ بورد کپی شد", - "added_to_queue": "تعداد {tracks} آهنگ به صف اضافه شد", - "filter_albums": "فیلتر کردن آلبوم...", - "synced": "همگام سازی شد", - "plain": "ساده", - "shuffle": "تصادفی", - "search_tracks": "جستجوی آهنگ ها...", - "released": "منتشر شده", - "error": "خطا {error}", - "title": "عنوان", - "time": "زمان", - "more_actions": "اقدامات بیشتر", - "download_count": "دانلود ({count})", - "add_count_to_playlist": "اضافه کردن ({count}) به لیست پخش", - "add_count_to_queue": "اضافه کردن ({count}) به صف", - "play_count_next": "پخش ({count}) بعدی", - "album": "آلبوم", - "copied_to_clipboard": "{data} در کلیپ بورد کپی شد", - "add_to_following_playlists": "اضافه کردن {track} به لیست پخش زیر", - "add": "اضافه کردن", - "added_track_to_queue": "{track} به لیست پخش اضافه شد", - "add_to_queue": "اضافه کردن به صف", - "track_will_play_next": "{track} پخش خواهد شد", - "play_next": "پخش آهنگ بعدی", - "removed_track_from_queue": "{track} از لیست پخش حذف شد", - "remove_from_queue": "از لیست پخش حذف شد", - "remove_from_favorites": "از علاقمندی ها حدف شد", - "save_as_favorite": "ذخیره به عنوان علاقمندی ها", - "add_to_playlist": "به لیست پخش اضافه کردن", - "remove_from_playlist": "از لیست پخش حذف کردن", - "add_to_blacklist": "به لیست سیاه اضافه کردن", - "remove_from_blacklist": "از لیست سیاه حذف کردن", - "share": "اشتراک گذاری", - "mini_player": "پخش کننده ", - "slide_to_seek": "برای جستجو عقب یا جلو بکشید", - "shuffle_playlist": "پخش تصادفی", - "unshuffle_playlist": "خاموش کردن پخش تصادفی", - "previous_track": "آهنگ قبلی", - "next_track": "آهنگ بعدی", - "pause_playback": "توقف آهنگ", - "resume_playback": "ادامه آهنگ", - "loop_track": "تکرار آهنگ", - "repeat_playlist": "تکرار لیست پخش", - "queue": "صف", - "alternative_track_sources": " منبع آهنگ را جاگزین کردن ", - "download_track": "بارگیری آهنگ", - "tracks_in_queue": "{tracks} آهنگ در صف", - "clear_all": "همه را حدف کن", - "show_hide_ui_on_hover": "نمایش/پنهان رابط کاربری در حالت شناور", - "always_on_top": "همیشه روشن", - "exit_mini_player": "از پخش کننده خارج شوید", - "download_location": "محل بارگیری", - "account": "حساب کاربری", - "login_with_spotify": "با حساب اسپوتیفای خود وارد شوید", - "connect_with_spotify": "متصل شدن به اسپوتیفای", - "logout": "خارج شدن", - "logout_of_this_account": "از حساب کاربری خارج شوید", - "language_region": "زبان و منطقه ", - "language": "زبان ", - "system_default": "پیش فرض سیستم", - "market_place_region": "منطقه", - "recommendation_country": "کشور های پیشنهادی", - "appearance": "ظاهر", - "layout_mode": "حالت چیدمان", - "override_layout_settings": "تنطیمات حالت واکنشگرای چیدمان را لغو کن", - "adaptive": "قابل تطبیق", - "compact": "فشرده", - "extended": "گسترده", - "theme": "تم", - "dark": "تاریک", - "light": "روشن", - "system": "سیستم", - "accent_color": "رنگ تاکیدی", - "sync_album_color": "هنگام سازی رنگ البوم", - "sync_album_color_description": "از رنگ البوم هنرمند به عنوان رنگ تاکیدی استفاده میکند", - "playback": "پخش", - "audio_quality": "کیفیت صدا", - "high": "زیاد", - "low": "کم", - "pre_download_play": "دانلود و پخش کنید", - "pre_download_play_description": "به جای پخش جریانی صدا، بایت ها را دانلود کنید و به جای آن پخش کنید (برای کاربران با پهنای باند بالاتر توصیه می شود)", - "skip_non_music": "رد شدن از پخش های غیر موسیقی (SponsorBlock)", - "blacklist_description": "آهنگ ها و هنرمند های در لیست سیاه", - "wait_for_download_to_finish": "لطفا صبر کنید تا دانلود آهنگ جاری تمام شود", - "desktop": "میز کار", - "close_behavior": "رفتار نزدیک", - "close": "بستن", - "minimize_to_tray": "پتجره را کوچک کنید", - "show_tray_icon": "نماد را نمایش بده", - "about": "درباره", - "u_love_spotube": "دوست داریدSpotubeما میدانیم شما ", - "check_for_updates": "بروزرسانی را بررسی کنید", - "about_spotube": "Spotube درباره", - "blacklist": "لیست سیاه", - "please_sponsor": "لطفا کمک/حمایت کنید", - "spotube_description": "یک برنامه سبک و مولتی پلتفرم و رایگان برای همه استSpotube", - "version": "نسخه", - "build_number": "شماره ساخت", - "founder": "بنیانگذار", - "repository": "مخزن", - "bug_issues": "اشکال+مسایل", - "made_with": "🇧🇩ساخته شده با ❤️ در بنگلادش", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "مجوز", - "add_spotify_credentials": "برای شروع اعتبار اسپوتیفای خود را اضافه کنید", - "credentials_will_not_be_shared_disclaimer": "نگران نباشید هیچ کدوما از اعتبارات شما جمع اوری نمیشود یا با کسی اشتراک گزاشته نمیشود", - "know_how_to_login": "نمیدانی چگونه این کار را انجام بدهی؟", - "follow_step_by_step_guide": "راهنما را گام به گام دنبال کنید", - "spotify_cookie": "Spotify {name} کوکی", - "cookie_name_cookie": "{name} کوکی", - "fill_in_all_fields": "لطفا تمام فلید ها را پر کنید", - "submit": "ثبت", - "exit": "خروج", - "previous": "قبلی", - "next": "بعدی ", - "done": "اتمام", - "step_1": "گام 1", - "first_go_to": "اول برو داخل ", - "login_if_not_logged_in": "و اگر وارد نشده اید، وارد/ثبت نام کنید", - "step_2": "گام 2", - "step_2_steps": "1. پس از ورود به سیستم، F12 یا کلیک راست ماوس > Inspect را فشار دهید تا ابزارهای توسعه مرورگر باز شود..\n2. سپس به تب \"Application\" (Chrome, Edge, Brave etc..) یا \"Storage\" Tab (Firefox, Palemoon etc..)\n3. به قسمت \"Cookies\" و به پخش \"https://accounts.spotify.com\" بروید", - "step_3": "گام 3", - "success_emoji": "موفقیت🥳", - "success_message": "اکنون با موفقیت با حساب اسپوتیفای خود وارد شده اید", - "step_4": "مرحله 4", - "something_went_wrong": "اشتباهی رخ داده", - "piped_instance": "مشکل در ارتباط با سرور", - "piped_description": "مشکل در ارتباط با سرور در دریافت آهنگ ها", - "piped_warning": "برخی از آنها ممکن است خوب کارنکند.بنابراین با مسولیت خود استفاده کنید", - "generate_playlist": "ساخت لیست پخش", - "track_exists": "آهنگ {track} وجود دارد", - "replace_downloaded_tracks": "همه ی آهنگ های دانلود شده را جایگزین کنید", - "skip_download_tracks": "همه ی آهنگ های دانلود شده را رد کنید", - "do_you_want_to_replace": "ایا میخواهید آهنگ های موجود جایگزین کنید؟", - "replace": "جایگزین کردن", - "skip": "رد کردن", - "select_up_to_count_type": "انتخاب کنید تا {count} {type}", - "select_genres": "ژانر ها را انتخاب کنید", - "add_genres": "ژانر را اطافه کنید", - "country": "کشور", - "number_of_tracks_generate": "تعداد آهنگ های ساخته شده", - "acousticness": "آکوستیک", - "danceability": "رقصیدن", - "energy": "انرژی", - "instrumentalness": "بی کلام", - "liveness": "حس زندگی", - "loudness": "صدای بلند", - "speechiness": "دکلمه", - "valence": "ظرفیت", - "popularity": "محبوبیت", - "key": "کلید", - "duration": "مدت زمان (ثانیه)", - "tempo": "تمپو (BPM)", - "mode": "حالت", - "time_signature": "امضای زمان", - "short": "کوتاه", - "medium": "متوسط", - "long": "بلند", - "min": "حداقل", - "max": "حداکثر", - "target": "هدف", - "moderate": "حد وسط", - "deselect_all": "همه را لغو انتخاب کنید", - "select_all": "همه را انتخاب کنید", - "are_you_sure": "ایا مطمعن هستید؟", - "generating_playlist": " درحال ایجاد لیست پخش سفارشی شما", - "selected_count_tracks": "آهنگ انتخاب شده {count}", - "download_warning": "اگر همه ی آهنگ ها را به صورت انبو دانلود کنید به وضوح در حال دزدی موسقی هستید و در حال اسیب وارد کردن به جامه ی خلاق هنری می باشید .امیدوارم که از این موضوع اگاه باشید .همیشه سعی کنید به کار سخت هنرمند اخترام بگذارید.", - "download_ip_ban_warning": "راستی آی پی شما می تواند در یوتوب به دلیل درخواست های دانلود بیش از حد معمول مسدود شود. بلوک آی پی به این معنی است که شما نمی توانید از یوتوب (حتی اگر وارد سیستم شده باشید) حداقل 2-3 ماه از آن دستگاه آی پی استفاده کنید. و Spotube هیچ مسئولیتی در صورت وقوع این اتفاق ندارد", - "by_clicking_accept_terms": "با کلیک بر روی قبول با شرایط زیر موافقت می کنید:", - "download_agreement_1": "من میدانم در حال دزدی هستم .من بد هستم", - "download_agreement_2": "من هر کجا ک بتوانم از هنرمندان حمایت میکنم اما این کارا فقط به دلیل اینکه توانایی مالی ندارم انجام میدهم", - "download_agreement_3": "من کاملا میدانم که از طرف یوتوب بلاک میشم و این برنامه و مالکان را مسول این حادثه نمیدانم.", - "decline": "قبول نکردن", - "accept": "قبول", - "details": "جزئیات", - "youtube": "یوتیوب", - "channel": "کانال", - "likes": "دوست داشتن", - "dislikes": "دوست نداشتن", - "views": "بازدید", - "streamUrl": "لینک اثر", - "stop": "توقف", - "sort_newest": "مرتب سازی بر اساس جدید ترین اضافه شده", - "sort_oldest": "مرتب سازی بر اساس قدیمی ترین اضافه شده", - "sleep_timer": "زمان خواب", - "mins": "{minutes} دقیقه", - "hours": "{hours} ساعت", - "hour": "{hours} ساعت", - "custom_hours": "ساعت سفارشی", - "logs": "رسید خطا", - "developers": "توسعه دهنده ها", - "not_logged_in": "شما وارد نشده اید ", - "search_mode": "حالت جستجو", - "audio_source": "منبع صدا", - "ok": "باشد", - "failed_to_encrypt": "رمز گذاری نشده", - "encryption_failed_warning": "Spotube از رمزگذاری برای ذخیره ایمن داده های شما استفاده می کند. اما موفق به انجام این کار نشد. بنابراین به فضای ذخیره‌سازی ناامن تبدیل می‌شود\nاگر از لینوکس استفاده می‌کنید، لطفاً مطمئن شوید که سرویس مخفی (gnome-keyring، kde-wallet، keepassxc و غیره) را نصب کرده‌اید.", - "querying_info": "جستجو درباره ", - "piped_api_down": "ایراد در سرور", - "piped_down_error_instructions": "به دلیل مشکل {pipedInstance} ارتباط با سرور مقدور نیست\n\nنمونه را تغییر دهید یا «نوع API» را به API رسمی YouTube تغییر دهید\n\nحتماً پس از تغییر، برنامه را دوباره راه‌اندازی کنید", - "you_are_offline": "شما در حال حاضر افلاین هستید ", - "connection_restored": "اتصال به اینترنت شما بازیابی شد ", - "use_system_title_bar": "از نوار عنوان سیستم استفاده کنید ", - "crunching_results": "نتایج خرد کردن...", - "search_to_get_results": "جستجو کنید تا به نتیجه برسید", - "use_amoled_mode": "استفاده از حالت AMOLED", - "pitch_dark_theme": "تم تیره دارت", - "normalize_audio": "نرمال کردن صدا", - "change_cover": "تغییر جلد", - "add_cover": "افزودن جلد", - "restore_defaults": "بازیابی پیش فرض ها", - "download_music_codec": "دانلود کدک موسیقی", - "streaming_music_codec": "کدک موسیقی استریمینگ", - "login_with_lastfm": "ورود با Last.fm", - "connect": "اتصال", - "disconnect_lastfm": "قطع ارتباط با Last.fm", - "disconnect": "قطع ارتباط", - "username": "نام کاربری", - "password": "رمز عبور", - "login": "ورود", - "login_with_your_lastfm": "ورود با حساب کاربری Last.fm خود", - "scrobble_to_lastfm": "Scrobble به Last.fm", - "go_to_album": "رفتن به آلبوم", - "discord_rich_presence": "حضور غنی دیسکورد", - "browse_all": "مرور همه", - "genres": "ژانرها", - "explore_genres": "استکشاف ژانرها", - "step_3_steps": "مقدار کوکی \"sp_dc\" را کپی کنید", - "step_4_steps": "مقدار کپی شده \"sp_dc\" را الصاق کنید", - "friends": "دوستان", - "no_lyrics_available": "متاسفیم، قادر به یافتن متن این قطعه نیستیم", - "sort_duration": "مرتب کردن بر اساس مدت زمان", - "start_a_radio": "شروع یک رادیو", - "how_to_start_radio": "چگونه می‌خواهید رادیو را شروع کنید؟", - "replace_queue_question": "آیا می‌خواهید لیست پخش فعلی را جایگزین کنید یا به آن اضافه کنید؟", - "endless_playback": "پخش بی‌پایان", - "delete_playlist": "حذف لیست پخش", - "delete_playlist_confirmation": "آیا مطمئن هستید که می‌خواهید این لیست پخش را حذف کنید؟", - "local_tracks": "موسیقی‌های محلی", - "song_link": "پیوند آهنگ", - "skip_this_nonsense": "این احمقانه را بگذرانید", - "freedom_of_music": "“آزادی موسیقی”", - "freedom_of_music_palm": "“آزادی موسیقی در دستان شما”", - "get_started": "بیایید شروع کنیم", - "youtube_source_description": "پیشنهاد شده و بهترین عمل می‌کند.", - "piped_source_description": "احساس آزادی می‌کنید؟ مانند یوتیوب اما بیشتر آزاد.", - "jiosaavn_source_description": "بهترین برای منطقه جنوب آسیا.", - "highest_quality": "بالاترین کیفیت: {quality}", - "select_audio_source": "انتخاب منبع صوتی", - "endless_playback_description": "خودکار اضافه کردن آهنگ‌های جدید\nبه انتهای صف", - "choose_your_region": "منطقه خود را انتخاب کنید", - "choose_your_region_description": "این به Spotube کمک می‌کند تا محتوای مناسبی را برای موقعیت شما نشان دهد.", - "choose_your_language": "زبان خود را انتخاب کنید", - "help_project_grow": "کمک به رشد این پروژه", - "help_project_grow_description": "Spotube یک پروژه متن باز است. شما می‌توانید با به پروژه کمک کردن، گزارش دادن اشکالات یا پیشنهاد ویژگی‌های جدید، به این پروژه کمک کنید.", - "contribute_on_github": "مشارکت در GitHub", - "donate_on_open_collective": "کمک مالی در Open Collective", - "browse_anonymously": "مرور به صورت ناشناس", - "enable_connect": "فعال‌سازی اتصال", - "enable_connect_description": "کنترل Spotube از دیگر دستگاه‌ها", - "devices": "دستگاه‌ها", - "select": "انتخاب", - "connect_client_alert": "شما توسط {client} کنترل می‌شوید", - "this_device": "این دستگاه", - "remote": "راه‌دور", - "local_library": "کتابخانه محلی", - "add_library_location": "اضافه کردن به کتابخانه", - "remove_library_location": "حذف از کتابخانه", - "local_tab": "محلی", - "stats": "آمار", - "and_n_more": "و {count} بیشتر", - "recently_played": "اخیراً پخش شده", - "browse_more": "بیشتر مرور کنید", - "no_title": "بدون عنوان", - "not_playing": "در حال پخش نیست", - "epic_failure": "شکست حماسی!", - "added_num_tracks_to_queue": "{tracks_length} ترک به صف اضافه شد", - "spotube_has_an_update": "Spotube یک بروزرسانی دارد", - "download_now": "اکنون دانلود کنید", - "nightly_version": "نسخه شبانه Spotube {nightlyBuildNum} منتشر شد", - "release_version": "نسخه Spotube v{version} منتشر شد", - "read_the_latest": "آخرین‌ها را بخوانید", - "release_notes": "یادداشت‌های انتشار", - "pick_color_scheme": "طرح رنگ را انتخاب کنید", - "save": "ذخیره", - "choose_the_device": "دستگاه را انتخاب کنید:", - "multiple_device_connected": "چندین دستگاه متصل هستند.\nدستگاهی را انتخاب کنید که می‌خواهید این عملیات بر روی آن انجام شود", - "nothing_found": "چیزی پیدا نشد", - "the_box_is_empty": "جعبه خالی است", - "top_artists": "بهترین هنرمندان", - "top_albums": "بهترین آلبوم‌ها", - "this_week": "این هفته", - "this_month": "این ماه", - "last_6_months": "۶ ماه گذشته", - "this_year": "امسال", - "last_2_years": "۲ سال گذشته", - "all_time": "همیشه", - "powered_by_provider": "توسط {providerName} پشتیبانی شده است", - "email": "ایمیل", - "profile_followers": "دنبال‌کنندگان", - "birthday": "تولد", - "subscription": "اشتراک", - "not_born": "متولد نشده", - "hacker": "هکر", - "profile": "پروفایل", - "no_name": "بدون نام", - "edit": "ویرایش", - "user_profile": "پروفایل کاربر", - "count_plays": "{count} پخش", - "streaming_fees_hypothetical": "هزینه‌های پخش (فرضی)", - "minutes_listened": "دقایق گوش داده شده", - "streamed_songs": "ترانه‌های پخش شده", - "count_streams": "{count} پخش", - "owned_by_you": "توسط شما مالکیت شده", - "copied_shareurl_to_clipboard": "{shareUrl} به کلیپ‌بورد کپی شد", - "spotify_hipotetical_calculation": "*این بر اساس پرداخت هر پخش اسپاتیفای\nبه مبلغ 0.003 تا 0.005 دلار محاسبه شده است.\nاین یک محاسبه فرضی است که به کاربران نشان دهد چقدر ممکن است\nبه هنرمندان پرداخت می‌کردند اگر ترانه آنها را در اسپاتیفای گوش می‌دادند.", - "count_mins": "{minutes} دقیقه", - "summary_minutes": "دقیقه‌ها", - "summary_listened_to_music": "به موسیقی گوش داده شده", - "summary_songs": "ترانه‌ها", - "summary_streamed_overall": "پخش شده به طور کلی", - "summary_owed_to_artists": "به هنرمندان بدهکار است\nاین ماه", - "summary_artists": "هنرمندان", - "summary_music_reached_you": "موسیقی به شما رسیده است", - "summary_full_albums": "آلبوم‌های کامل", - "summary_got_your_love": "عشق شما را به دست آورد", - "summary_playlists": "لیست‌های پخش", - "summary_were_on_repeat": "در تکرار بودند", - "total_money": "مجموع {money}", - "webview_not_found": "وب‌ویو پیدا نشد", - "webview_not_found_description": "هیچ اجرای وب‌ویو روی دستگاه شما نصب نشده است.\nدر صورت نصب، مطمئن شوید که در environment PATH قرار دارد\n\nپس از نصب، برنامه را مجدداً راه‌اندازی کنید", - "unsupported_platform": "پلتفرم پشتیبانی نمی‌شود", - "invidious_instance": "نمونه سرور Invidious", - "invidious_description": "نمونه سرور Invidious برای تطبیق آهنگ", - "invidious_warning": "برخی از نمونه‌ها ممکن است به خوبی کار نکنند. با احتیاط استفاده کنید", - "invidious_source_description": "شبیه Piped اما با در دسترس بودن بیشتر", - "cache_music": "موسیقی در حافظه موقت", - "open": "باز کردن", - "cache_folder": "پوشه حافظه موقت", - "export": "صادر کردن", - "clear_cache": "پاک کردن حافظه موقت", - "clear_cache_confirmation": "آیا می‌خواهید حافظه موقت را پاک کنید؟", - "export_cache_files": "صادر کردن فایل‌های حافظه موقت", - "found_n_files": "{count} فایل یافت شد", - "export_cache_confirmation": "آیا می‌خواهید این فایل‌ها را صادر کنید به", - "exported_n_out_of_m_files": "{filesExported} از {files} فایل صادر شد", - "playlist": "لیست پخش", - "no_loop": "بدون حلقه", - "generate": "ایجاد", - "undo": "بازگشت", - "download_all": "دانلود همه", - "add_all_to_playlist": "افزودن همه به لیست پخش", - "add_all_to_queue": "افزودن همه به صف", - "play_all_next": "پخش همه بعدی", - "pause": "مکث", - "view_all": "مشاهده همه", - "no_tracks_added_yet": "به نظر می‌رسد هنوز هیچ آهنگی اضافه نکرده‌اید.", - "no_tracks": "به نظر می‌رسد هیچ آهنگی در اینجا وجود ندارد.", - "no_tracks_listened_yet": "به نظر می‌رسد هنوز چیزی نشنیده‌اید.", - "not_following_artists": "شما هیچ هنرمندی را دنبال نمی‌کنید.", - "no_favorite_albums_yet": "به نظر می‌رسد هنوز هیچ آلبومی را به علاقه‌مندی‌هایتان اضافه نکرده‌اید.", - "no_logs_found": "هیچ لاگی پیدا نشد", - "youtube_engine": "موتور YouTube", - "youtube_engine_not_installed_title": "{engine} نصب نشده است", - "youtube_engine_not_installed_message": "{engine} در سیستم شما نصب نشده است.", - "youtube_engine_set_path": "اطمینان حاصل کنید که در متغیر PATH موجود است یا\nآدرس مطلق فایل اجرایی {engine} را در زیر تنظیم کنید.", - "youtube_engine_unix_issue_message": "در macOS/Linux/سیستم‌عامل‌های مشابه Unix، تنظیم مسیر در .zshrc/.bashrc/.bash_profile و غیره کار نمی‌کند.\nباید مسیر را در فایل پیکربندی شل تنظیم کنید.", - "download": "دانلود", - "file_not_found": "فایل پیدا نشد", - "custom": "شخصی‌سازی شده", - "add_custom_url": "اضافه کردن URL سفارشی", - "edit_port": "ویرایش پورت", - "port_helper_msg": "پیش‌فرض -1 است که نشان‌دهنده یک عدد تصادفی است. اگر فایروال شما پیکربندی شده است، توصیه می‌شود این را تنظیم کنید.", - "connect_request": "آیا اجازه می‌دهید {client} متصل شود؟", - "connection_request_denied": "اتصال رد شد. کاربر دسترسی را رد کرد.", - "hipotetical_calculation": "*این محاسبه بر اساس میانگین پرداخت به ازای هر پخش (0.003 تا 0.005 دلار) در پلتفرم‌های استریم موزیک آنلاین انجام شده است. این یک محاسبه فرضی است که به کاربر دیدی از مقدار پرداختی به هنرمندان در صورت گوش دادن به آهنگ آن‌ها در پلتفرم‌های مختلف ارائه می‌دهد.", - "an_error_occurred": "خطایی رخ داد", - "copy_to_clipboard": "کپی به کلیپ‌بورد", - "view_logs": "مشاهده لاگ‌ها", - "retry": "دوباره تلاش کن", - "no_default_metadata_provider_selected": "هیچ ارائه‌دهندهٔ پیش‌فرض متادیتا تعیین نکرده‌اید", - "manage_metadata_providers": "مدیریت ارائه‌دهندگان متادیتا", - "open_link_in_browser": "باز کردن لینک در مرورگر؟", - "do_you_want_to_open_the_following_link": "آیا می‌خواهید لینک زیر را باز کنید؟", - "unsafe_url_warning": "باز کردن لینک از منابع نامطمئن می‌تواند ناامن باشد. مراقب باشید!\nهمچنین می‌توانید لینک را در کلیپ‌بورد خود کپی کنید.", - "copy_link": "کپی لینک", - "building_your_timeline": "در حال ساخت جدول زمانی بر اساس شنیده‌هایتان…", - "official": "رسمی", - "author_name": "نویسنده: {author}", - "third_party": "سوم‌شخص", - "plugin_requires_authentication": "افزونه نیاز به احراز هویت دارد", - "update_available": "به‌روزرسانی در دسترس است", - "supports_scrobbling": "پشتیبانی از اسکراب‌بلینگ", - "plugin_scrobbling_info": "این افزونه موسیقی شما را اسکراب می‌کند تا تاریخچهٔ شنیداری‌تان را تولید کند.", - "default_plugin": "پیش‌فرض", - "set_default": "تنظیم به عنوان پیش‌فرض", - "support": "پشتیبانی", - "support_plugin_development": "حمایت از توسعهٔ افزونه", - "can_access_name_api": "- می‌تواند به API **{name}** دسترسی پیدا کند", - "do_you_want_to_install_this_plugin": "می‌خواهید این افزونه را نصب کنید؟", - "third_party_plugin_warning": "این افزونه از مخزن شخص ثالث آمده است. لطفاً قبل از نصب از منابع آن مطمئن شوید.", - "author": "نویسنده", - "this_plugin_can_do_following": "این افزونه می‌تواند موارد زیر را انجام دهد", - "install": "نصب", - "install_a_metadata_provider": "نصب یک ارائه‌دهندهٔ متادیتا", - "no_tracks_playing": "در حال‌ حاضر هیچ تراکی در حال پخش نیست", - "synced_lyrics_not_available": "متن هم‌زمان‌شده برای این آهنگ در دسترس نیست. لطفاً از", - "plain_lyrics": "متن ساده", - "tab_instead": "به‌جای آن از کلید Tab استفاده کنید.", - "disclaimer": "سلب مسئولیت", - "third_party_plugin_dmca_notice": "تیم Spotube هیچ مسئولیتی (حتی قانونی) در قبال افزونه‌های \"شخص ثالث\" ندارد. از آن‌ها به‌خاطر خود استفاده کنید. برای خطاها/مشکلات، لطفاً در مخزن افزونه گزارش دهید.\n\nاگر هر افزونهٔ \"شخص ثالث\" قوانین ToS/DMCA سرویس یا نهاد قانونی را نقض کند، لطفاً از نویسندهٔ افزونه یا پلتفرم میزبانی (مثل GitHub/Codeberg) درخواست اقدام کنید. افزونه‌هایی که با برچسب \"شخص ثالث\" مشخص شده‌اند، عمومی هستند و توسط جامعه نگهداری می‌شوند؛ ما آن‌ها را تغییر یا مدیریت نمی‌کنیم و نمی‌توانیم دخالت کنیم.\n\n", - "input_does_not_match_format": "ورودی با قالب مورد نیاز تطابق ندارد", - "metadata_provider_plugins": "افزونه‌های ارائه‌دهندهٔ متادیتا", - "paste_plugin_download_url": "URL دانلود یا مخزن GitHub/Codeberg یا لینک مستقیم فایل .smplug را الصاق کنید", - "download_and_install_plugin_from_url": "دانلود و نصب افزونه از طریق لینک", - "failed_to_add_plugin_error": "افزونه اضافه نشد: {error}", - "upload_plugin_from_file": "بارگذاری افزونه از فایل", - "installed": "نصب شد", - "available_plugins": "افزونه‌های موجود", - "configure_your_own_metadata_plugin": "پیکربندی ارائه‌دهندهٔ متادیتا برای پلی‌لیست/آلبوم/هنرمند/فید به‌صورت سفارشی", - "audio_scrobblers": "اسکراب‌بلرهای صوتی", - "scrobbling": "اسکراب‌بلینگ", - "download_music_format": "فرمت دانلود موسیقی", - "streaming_music_format": "فرمت پخش آنلاین موسیقی", - "download_music_quality": "کیفیت دانلود موسیقی", - "streaming_music_quality": "کیفیت پخش آنلاین موسیقی", - "default_metadata_source": "منبع پیش‌فرض فراداده", - "set_default_metadata_source": "تنظیم منبع پیش‌فرض فراداده", - "default_audio_source": "منبع پیش‌فرض صوت", - "set_default_audio_source": "تنظیم منبع پیش‌فرض صوت", - "plugins": "افزونه‌ها", - "configure_plugins": "افزونه‌های منبع صوت و ارائه‌دهنده فراداده خود را پیکربندی کنید", - "source": "منبع: ", - "uncompressed": "بدون فشرده‌سازی", - "dab_music_source_description": "مخصوص علاقه‌مندان صدا. ارائه‌دهنده استریم‌های باکیفیت/بدون افت. تطبیق دقیق آهنگ بر اساس ISRC." -} \ No newline at end of file diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb deleted file mode 100644 index d92e5acf..00000000 --- a/lib/l10n/app_fi.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "Vieras", - "browse": "Selaa", - "search": "Hae", - "library": "Kirjasto", - "lyrics": "Lyriikat", - "settings": "Asetukset", - "genre_categories_filter": "Suodata kategorioita tai genrejä", - "genre": "Genre", - "personalized": "Personoidut", - "featured": "Esittelyssä", - "new_releases": "Uusi julkaisu", - "songs": "Laulut", - "playing_track": "Soitetaan {track}", - "queue_clear_alert": "Tämä tulee tyhjentämään jonon. {track_length} Kappaleita poistetaan\nHaluatko jatkaa?", - "load_more": "Lataa lisää", - "playlists": "Soittolistat", - "artists": "Artistit", - "albums": "Albumit", - "tracks": "Kappaleet", - "downloads": "Lataukset", - "filter_playlists": "Suodata soittolistasi...", - "liked_tracks": "Tykätyt kappaleet", - "liked_tracks_description": "Kaikki tykättysi kappaleet", - "create_playlist": "Luo soittolista", - "create_a_playlist": "Luo soittolista", - "update_playlist": "Päivitä soittolista", - "create": "Luo", - "cancel": "Peruuta", - "update": "Päivitä", - "playlist_name": "Soittolistan nimi", - "name_of_playlist": "Soittolistan nimi", - "description": "Kuvaus", - "public": "Julkinen", - "collaborative": "Collaborative", - "search_local_tracks": "Hae paikallisia lauluja...", - "play": "Soita", - "delete": "Poista", - "none": "Ei mitään", - "sort_a_z": "Suodata A-Z", - "sort_z_a": "Suodata Z-A", - "sort_artist": "Suodata Artistilta", - "sort_album": "Suodata Albumilta", - "sort_duration": "Suodata Pituudelta", - "sort_tracks": "Suodata Kappaleet", - "currently_downloading": "Ladataan ({tracks_length})", - "cancel_all": "Peru kaikki", - "filter_artist": "Suodata artistit...", - "followers": "{followers} Seuraajaa", - "add_artist_to_blacklist": "Lisää artisti mustalle listalle", - "top_tracks": "Suosituimmat kappaleet", - "fans_also_like": "Fanit myös tykkäsivät", - "loading": "Ladataan...", - "artist": "Artisti", - "blacklisted": "Mustalistattu", - "following": "Seurataan", - "follow": "Seuraa", - "artist_url_copied": "Aristin URL kopioitiin leikepöytään", - "added_to_queue": "Lisättiin {tracks} kappaletta jonoon", - "filter_albums": "Suodata albumit...", - "synced": "Synkronoitu", - "plain": "Tavallinen", - "shuffle": "Sekoita", - "search_tracks": "Hae kappaleita...", - "released": "Julkaistu", - "error": "Virhe {error}", - "title": "Otsikko", - "time": "Aika", - "more_actions": "Lisää toimintoja", - "download_count": "Lataa ({count})", - "add_count_to_playlist": "Lisää ({count}) Soittolistaasi", - "add_count_to_queue": "Lisää ({count}) Jonoon", - "play_count_next": "Soita ({count}) seuraavaksi", - "album": "Albumi", - "copied_to_clipboard": "Kopioitiin {data} leikepöytään", - "add_to_following_playlists": "Lisää {track} seuraaviin soittolistoihin", - "add": "Lisää", - "added_track_to_queue": "Lisättiin {track} jonoon", - "add_to_queue": "Lisää jonoon", - "track_will_play_next": "{track} Soitetaan seuraavaksi", - "play_next": "Soita seuraavaksi", - "removed_track_from_queue": "Poistettiin {track} jonosta", - "remove_from_queue": "Poista jonosta", - "remove_from_favorites": "Poista suosikeista", - "save_as_favorite": "Tallenna soittolistana", - "add_to_playlist": "Lisää soittolistaan", - "remove_from_playlist": "Poista soittolistasta", - "add_to_blacklist": "Lisää mustalle listalle", - "remove_from_blacklist": "Poista mustalistalta", - "share": "Jaa", - "mini_player": "Minisoitin", - "slide_to_seek": "Liu'uta mennäkseen eteenpäin tai taaksepäin", - "shuffle_playlist": "Sekoita soittolista", - "unshuffle_playlist": "Poista sekoitus soittolistasta", - "previous_track": "Äskeinen kappale", - "next_track": "Seuraava kappale", - "pause_playback": "Pysäytä soittolistan toisto", - "resume_playback": "Jatka soittolistan toistoa", - "loop_track": "Uudelleentoista kappale", - "repeat_playlist": "Toista soittolista uudelleen", - "queue": "Jono", - "alternative_track_sources": "Toinen kappale lähde", - "download_track": "Lataa kappale", - "tracks_in_queue": "{tracks} kappaletta jonossa", - "clear_all": "Tyhjennä kaikki", - "show_hide_ui_on_hover": "Näytä/Piilota UI leijumalla", - "always_on_top": "Aina päällimmäisenä", - "exit_mini_player": "Lähde minisoittimesta", - "download_location": "Lataus sijainti", - "account": "Käyttäjä", - "login_with_spotify": "Kirjaudu Spotify-käyttäjällä", - "connect_with_spotify": "Yhdistä Spotify:lla", - "logout": "Kirjaudu ulos", - "logout_of_this_account": "Kirjaudu ulos tältä käyttäjältä", - "language_region": "Kieli ja Maa", - "language": "Kieli", - "system_default": "Järjestelmän oletus", - "market_place_region": "Markkina-alue", - "recommendation_country": "Suositeltu maa", - "appearance": "Ulkomuto", - "layout_mode": "Asettelutila", - "override_layout_settings": "Jätä reagoiva asettelutila huomioimatta", - "adaptive": "Mukautuva", - "compact": "Kompakti", - "extended": "Laajennettu", - "theme": "Teema", - "dark": "Tumma", - "light": "Vaalea", - "system": "Järjestelmä", - "accent_color": "Korostusväri", - "sync_album_color": "Synkronoi albumin väri", - "sync_album_color_description": "Käyttää albumin kansitaiteen vallitsevaa väirä korostuvärinä", - "playback": "Toisto", - "audio_quality": "Äänenlaatu", - "high": "Korkea", - "low": "Matala", - "pre_download_play": "Esilataa ja soita", - "pre_download_play_description": "Audion suoratoiston sijaan, lataa tavut ja soita ne (Suositeltu korkeamman kaistanleveyden käyttäjille)", - "skip_non_music": "Ohita ei-musiikki kohdat (SponsorBlock)", - "blacklist_description": "Mustalistat kappaleet aja artistit", - "wait_for_download_to_finish": "Odota nykyisen latauksen lopetteluun", - "desktop": "Työpöytä", - "close_behavior": "Sulkemisen käyttäytyminen", - "close": "Sulje", - "minimize_to_tray": "Minimisoi tehtäväpalkkiin", - "show_tray_icon": "Näytä järjestelmäkuvake", - "about": "Tietoa", - "u_love_spotube": "Tiedämme että rakastat Spotubea", - "check_for_updates": "Tarkista päivitykset", - "about_spotube": "Tietoa Spotube:sta", - "blacklist": "Mustalista", - "please_sponsor": "Sponsoroi/Lahjoita, kiitos", - "spotube_description": "Spotube, kevyt, cross-platform, vapaa-kaikille spotify clientti", - "version": "Versio", - "build_number": "Rakennusnumero", - "founder": "Perustaja", - "repository": "Arkisto", - "bug_issues": "Bugit+Ongelmat", - "made_with": "Tehty ❤️ Bangladeshista 🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Lisenssi", - "add_spotify_credentials": "Lisää Spotify-tunnuksesi aloittaaksesi", - "credentials_will_not_be_shared_disclaimer": "Älä huoli, tunnuksiasi ei talleteta tai jaeta kenenkään kanssa", - "know_how_to_login": "Etkö tiedä miten tehdä tämä?", - "follow_step_by_step_guide": "Seuraa askel askeleelta opasta", - "spotify_cookie": "Spotify {name} Keksi", - "cookie_name_cookie": "{name} Keksi", - "fill_in_all_fields": "Täytä kaikki kentät", - "submit": "Lähetä", - "exit": "Poistu", - "previous": "Edellinen", - "next": "Seuraava", - "done": "Tehty", - "step_1": "Vaihe 1", - "first_go_to": "Ensiksi, mene", - "login_if_not_logged_in": "ja Kirjaudu/Tee tili jos et ole kirjautunut sisään", - "step_2": "Vaihe 2", - "step_2_steps": "1. Kun olet kirjautunut, paina F12 tai oikeaa hiiren näppäintä > Tarkista ja avaa selaimen kehittäjä työkalut.\n2. Mene sitten \"Application\"-välilehteen (Chrome, Edge, Brave jne..) tai \"Storage\"-välilehteen (Firefox, Palemoon jne..)\n3. Mene \"Cookies\"-osastoon, sitten \"https://accounts.spotify.com\" alakohtaan.", - "step_3": "Vaihe 3", - "step_3_steps": "Kopioi Keksin \"sp_dc\" arvo", - "success_emoji": "Onnistuit🥳", - "success_message": "Olet nyt kirjautunut sisään Spotify-käyttäjällesi. Hyvää työtä toveri!", - "step_4": "Vaihe 4", - "step_4_steps": "Liitä kopioitu \"sp_dc\" arvo", - "something_went_wrong": "Jotain meni pieleen", - "piped_instance": "Johdettu palvelinesiintymä", - "piped_description": "Johdettu palvelinesiintymä Kappale täsmäyksiin", - "piped_warning": "Jotkut niistä eivät toimi hyvin, käytä siis omalla vastuullasi", - "generate_playlist": "Tuota soittolista", - "track_exists": "Kappale {track} on jo olemassa!", - "replace_downloaded_tracks": "Korvaa kaikki ladatut kappaleet", - "skip_download_tracks": "Ohita ladattujen laulujen lataaminen", - "do_you_want_to_replace": "Haluatko korvata olemassa olevan kappaleen??", - "replace": "Korvaa", - "skip": "Ohita", - "select_up_to_count_type": "Valitse enintään {count} {type}", - "select_genres": "Valitse Genret", - "add_genres": "Lisää Genrejä", - "country": "Maa", - "number_of_tracks_generate": "Numero tuotettavia kappaleita", - "acousticness": "Akustisuus", - "danceability": "Tanssittavuus", - "energy": "Energia", - "instrumentalness": "Instrumentaalisuus", - "liveness": "Elävyyttä", - "loudness": "Äänekkyys", - "speechiness": "Puheisuus", - "valence": "Valenssi", - "popularity": "Suosio", - "key": "Sävellaji", - "duration": "Pituus (s)", - "tempo": "Tempo (BPM)", - "mode": "Tila", - "time_signature": "Aikamerkki", - "short": "Lyhyt", - "medium": "Keskikokoinen", - "long": "Pitkä", - "min": "Minimi", - "max": "Maximi", - "target": "Kohde", - "moderate": "Kohtalainen", - "deselect_all": "Poista kaikki valinnat", - "select_all": "Valitse kaikki", - "are_you_sure": "Oletko varma?", - "generating_playlist": "Luodaan mukautettua soittolistoa...", - "selected_count_tracks": "Valittu {count} kappaletta", - "download_warning": "Jos lataat kaikki laulut kerrällä olet selkeästi Piratoimassa ja aiheuttamassa vahinkoa musiikin luovaan yhteiskuntaan. Toivottavasti olet tietoinen tästä. Yritä aina kunnioittaa ja tukea Artistin kovaa työtä.", - "download_ip_ban_warning": "BTW, YouTube voi estää IP-Osoitteesi tavallista liiallisten latauspyyntöjen takia. IP-Osoitteen esto tarkoittaa sitä, ettet voi käyttää YouTubea (vaikka olisit kirjautunut) vähintään 2-3kk aikana kyseiseltä laitteelta. Spotube ei kanna yhtään vastuuta jos se tapahtuu.", - "by_clicking_accept_terms": "Painamalla 'hyväksy' hyväksyt seuraaviin ehtoihin:", - "download_agreement_1": "Tiedän että Piratoin musiikkia. Olen paha.", - "download_agreement_2": "Tuen Artisteja silloin kun pystyn, ja teen tämän vain koska minulla ei ole rahaa ostaa heidän taidetta", - "download_agreement_3": "Ymmärrän että minun YouTube voi estää IP-Osoitteeni ja en pidä Spotubea tai omistajiinsa/avustajia vastuullisena mistään omista teoistsani", - "decline": "Hylkää", - "accept": "Hyväksy", - "details": "Yksityiskohdat", - "youtube": "YouTube", - "channel": "Kanava", - "likes": "Tykkäykset", - "dislikes": "Epä-tykkäykset", - "views": "Näyttökerrat", - "streamUrl": "Suoratoiston URL", - "stop": "Lopeta", - "sort_newest": "Suodata uusimmista", - "sort_oldest": "Suodata vanhimmista", - "sleep_timer": "Uniajastin", - "mins": "{minutes} Minuuttia", - "hours": "{hours} Tuntia", - "hour": "{hours} Tunti", - "custom_hours": "Mukautetut tunnit", - "logs": "Lokit", - "developers": "Kehittäjät", - "not_logged_in": "Et ole kirjautunut sisään.", - "search_mode": "Hakutila", - "audio_source": "Äänilähde", - "ok": "Ok", - "failed_to_encrypt": "Salaaminen epäonnistui", - "encryption_failed_warning": "Spotube käyttää salausta tallentaakseen tietosi, mutta epäonnistui, joten se palaa epäturvalliseen tallennukseen\nJos käytät Linuxia, varmista että sinulla on turvallisuuspalvelu (gnome-keyring, kde-wallet, keepassxc jne) asennettu", - "querying_info": "Hankitaan tietoa...", - "piped_api_down": "Johdettu palvelinesiintymä on alhaalla", - "piped_down_error_instructions": "Johdettu palvelinesiintymä {pipedInstance} on alhaalla.\n\nVaihda joko ilmeytymä tia vahda 'API tyyppi' YouTuben viralliseen API\n\nKäynnistä sovellus uudestaan vaihdon jälkeen", - "you_are_offline": "Et ole yhdistetty verkkoon", - "connection_restored": "Verkkoyhteys palautettu", - "use_system_title_bar": "Käytä järjestelmäpalkkia", - "crunching_results": "Paloitellaan tuloksia...", - "search_to_get_results": "Hae saadakseen tuloksia", - "use_amoled_mode": "Pilkkopimeä tumma teema", - "pitch_dark_theme": "AMOLED Tila", - "normalize_audio": "Normalisoi audio", - "change_cover": "Vaihda koveri", - "add_cover": "Lisää koveri", - "restore_defaults": "Palauta oletukset", - "download_music_codec": "Ladatun musiikin codefc", - "streaming_music_codec": "Suoratoistetun musiikin codec", - "login_with_lastfm": "Kirjaudu sisään Last.fm:llä", - "connect": "Yhdistä", - "disconnect_lastfm": "Katkaise Last.fm", - "disconnect": "Katkaise", - "username": "Käyttäjänimi", - "password": "Salasana", - "login": "Kirjaudu", - "login_with_your_lastfm": "Kirjaudu Last.fm käyttäjälläsi", - "scrobble_to_lastfm": "Scrobble Last.fm:ään", - "go_to_album": "Mene albumiin", - "discord_rich_presence": "Discord Rich Presence", - "browse_all": "Selaa kaikki", - "genres": "Genret", - "explore_genres": "Seikkaile genrejä", - "friends": "Kaverit", - "no_lyrics_available": "Anteeksi, emme löytäneet lyriikoita tälle laululle", - "start_a_radio": "Aloita Radio", - "how_to_start_radio": "Kuinka haluat aloittaa radion?", - "replace_queue_question": "Haluatko korvata nykyisen jonon vai lisätä siihen?", - "endless_playback": "Loputon toisto", - "delete_playlist": "Poista soittolista", - "delete_playlist_confirmation": "Oletko varma että haluat poistaa tämän soittolistan?", - "local_tracks": "Paikalliset kappaleet", - "song_link": "Laulun linkki", - "skip_this_nonsense": "Ohita tämä hölynpöly", - "freedom_of_music": "“Musiikin vapaus”", - "freedom_of_music_palm": "“Musiikin vapaus käsissäsi”", - "get_started": "Aloitetaan", - "youtube_source_description": "Suositeltu ja toimii parhaiten.", - "piped_source_description": "Tuntuuko vapaalta? Sama kuin YouTube mutta paljon vapautta", - "jiosaavn_source_description": "Paras Etelä-Aasian alueelle.", - "highest_quality": "Korkein laatu: {quality}", - "select_audio_source": "Valitse äänilähde", - "endless_playback_description": "Lisää automaattisesti uusia lauluja\njonon perään", - "choose_your_region": "Valitse alueesi", - "choose_your_region_description": "Tämä auttaa Spotube näyttämään sinulle oikeaa sisältöä\nsijaintiasi varten.", - "choose_your_language": "Valitse kielesi", - "help_project_grow": "Auta tätä projektia kasvamaan", - "help_project_grow_description": "Spotube projekti minkä lähdekoodi on julkisesti saatavilla. Voit autta tätä projektia kasvamaan muutoksilla, ilmoittamalla bugeista, tai ehdottamalla uusia ominaisuuksia.", - "contribute_on_github": "Auta GitHub:ssa", - "donate_on_open_collective": "Lahjoita avoimessa kollektiivissa", - "browse_anonymously": "Selaa anonyyminä", - "enable_connect": "Ota käyttöön yhdistäminen", - "enable_connect_description": "Ohjaa Spotubea toiselta laitteelta", - "devices": "Laitteet", - "select": "Valitse", - "connect_client_alert": "{client} ohjaa sinua", - "this_device": "Tämä laite", - "remote": "Etä", - "local_library": "Paikallinen kirjasto", - "add_library_location": "Lisää kirjastoon", - "remove_library_location": "Poista kirjastosta", - "local_tab": "Paikallinen", - "stats": "Tilastot", - "and_n_more": "ja {count} lisää", - "recently_played": "Äskettäin soitetut", - "browse_more": "Selaa lisää", - "no_title": "Ei otsikkoa", - "not_playing": "Ei soi", - "epic_failure": "Epäonnistuminen!", - "added_num_tracks_to_queue": "Lisätty {tracks_length} kappaletta jonoon", - "spotube_has_an_update": "Spotubella on päivitys", - "download_now": "Lataa nyt", - "nightly_version": "Spotube Nightly {nightlyBuildNum} on julkaistu", - "release_version": "Spotube v{version} on julkaistu", - "read_the_latest": "Lue viimeisimmät", - "release_notes": "julkaisumuistiinpanot", - "pick_color_scheme": "Valitse värimaailma", - "save": "Tallenna", - "choose_the_device": "Valitse laite:", - "multiple_device_connected": "Useita laitteita on kytketty.\nValitse laite, jossa haluat toiminnon suorittaa", - "nothing_found": "Ei tuloksia", - "the_box_is_empty": "Laatikko on tyhjä", - "top_artists": "Suosituimmat artistit", - "top_albums": "Suosituimmat albumit", - "this_week": "Tällä viikolla", - "this_month": "Tässä kuussa", - "last_6_months": "Viimeiset 6 kuukautta", - "this_year": "Tänä vuonna", - "last_2_years": "Viimeiset 2 vuotta", - "all_time": "Kaikki ajat", - "powered_by_provider": "Tuottanut {providerName}", - "email": "Sähköposti", - "profile_followers": "Seuraajat", - "birthday": "Syntymäpäivä", - "subscription": "Tilaus", - "not_born": "Ei syntynyt", - "hacker": "Hakkeri", - "profile": "Profiili", - "no_name": "Ei nimeä", - "edit": "Muokkaa", - "user_profile": "Käyttäjäprofiili", - "count_plays": "{count} toistoa", - "streaming_fees_hypothetical": "Suoratoiston maksut (hypoteettinen)", - "minutes_listened": "Kuunneltuja minuutteja", - "streamed_songs": "Suoratoistettuja kappaleita", - "count_streams": "{count} suoratoistoa", - "owned_by_you": "Sinun omistama", - "copied_shareurl_to_clipboard": "{shareUrl} kopioitu leikepöydälle", - "spotify_hipotetical_calculation": "*Tämä on laskettu Spotifyn suoratoiston\nmaksun perusteella, joka on 0,003–0,005 dollaria.\nTämä on hypoteettinen laskelma, joka antaa käyttäjälle käsityksen\nsiitä, kuinka paljon he olisivat maksaneet artisteille,\njollei heidän kappaleensa olisi kuunneltu Spotifyssa.", - "count_mins": "{minutes} min", - "summary_minutes": "minuuttia", - "summary_listened_to_music": "Kuunneltu musiikkia", - "summary_songs": "kappaletta", - "summary_streamed_overall": "Suoratoistettu yhteensä", - "summary_owed_to_artists": "Maksettava artisteille\nTässä kuussa", - "summary_artists": "artisti", - "summary_music_reached_you": "Musiikki saavutti sinut", - "summary_full_albums": "täydet albumit", - "summary_got_your_love": "Sai rakkautesi", - "summary_playlists": "soittolistat", - "summary_were_on_repeat": "Olivat toistossa", - "total_money": "Yhteensä {money}", - "webview_not_found": "Webview ei löydy", - "webview_not_found_description": "Laitteellasi ei ole asennettua Webview-ajonaikaa.\nJos se on asennettu, varmista, että se on environment PATH:ssa\n\nAsennuksen jälkeen käynnistä sovellus uudelleen", - "unsupported_platform": "Ei tuettu alusta", - "invidious_instance": "Invidious-palvelinesiintymä", - "invidious_description": "Invidious-palvelinesiintymä raitojen yhteensovittamiseen", - "invidious_warning": "Jotkin esiintymät eivät välttämättä toimi hyvin. Käytä omalla vastuullasi", - "invidious_source_description": "Samankaltainen kuin Piped, mutta korkeammalla saatavuudella", - "cache_music": "Musiikki välimuistissa", - "open": "Avaa", - "cache_folder": "Välimuistikansio", - "export": "Vie", - "clear_cache": "Tyhjennä välimuisti", - "clear_cache_confirmation": "Haluatko tyhjentää välimuistin?", - "export_cache_files": "Vie välimuistitiedostot", - "found_n_files": "Löydettiin {count} tiedostoa", - "export_cache_confirmation": "Haluatko viedä nämä tiedostot", - "exported_n_out_of_m_files": "Vietiin {filesExported}/{files} tiedostoa", - "playlist": "Soittolista", - "no_loop": "Ei silmukkaa", - "generate": "Luo", - "undo": "Peruuta", - "download_all": "Lataa kaikki", - "add_all_to_playlist": "Lisää kaikki soittolistalle", - "add_all_to_queue": "Lisää kaikki jonoon", - "play_all_next": "Toista kaikki seuraavaksi", - "pause": "Pysäytä", - "view_all": "Näytä kaikki", - "no_tracks_added_yet": "Näyttää siltä, että et ole lisännyt vielä mitään kappaleita.", - "no_tracks": "Näyttää siltä, että täällä ei ole kappaleita.", - "no_tracks_listened_yet": "Näyttää siltä, että et ole kuunnellut mitään vielä.", - "not_following_artists": "Et seuraa yhtään artistia.", - "no_favorite_albums_yet": "Näyttää siltä, että et ole lisännyt yhtään albumia suosikkeihisi.", - "no_logs_found": "Ei lokitietoja löydetty", - "youtube_engine": "YouTube-moottori", - "youtube_engine_not_installed_title": "{engine} ei ole asennettu", - "youtube_engine_not_installed_message": "{engine} ei ole asennettu järjestelmääsi.", - "youtube_engine_set_path": "Varmista, että se on saatavilla PATH-muuttujassa tai\nasetetaan {engine} suoritettavan tiedoston absoluuttinen polku alla.", - "youtube_engine_unix_issue_message": "macOS/Linux/unix-tyyppisissä käyttöjärjestelmissä polun asettaminen .zshrc/.bashrc/.bash_profile jne. ei toimi.\nSinun täytyy asettaa polku shellin asetustiedostoon.", - "download": "Lataa", - "file_not_found": "Tiedostoa ei löydy", - "custom": "Mukautettu", - "add_custom_url": "Lisää mukautettu URL", - "edit_port": "Muokkaa porttia", - "port_helper_msg": "Oletusarvo on -1, mikä tarkoittaa satunnaista numeroa. Jos sinulla on palomuuri määritetty, tämän asettamista suositellaan.", - "connect_request": "Salli {client} yhdistää?", - "connection_request_denied": "Yhteys evätty. Käyttäjä eväsi pääsyn.", - "hipotetical_calculation": "*Tämä on laskettu keskimääräisen musiikin suoratoistopalvelun 0,003–0,005 dollarin kappalekohtaisen maksun perusteella. Tämä on hypoteettinen laskelma, joka antaa käyttäjälle käsityksen siitä, kuinka paljon he olisivat maksaneet artisteille, jos he kuuntelisivat heidän kappaleitaan eri musiikin suoratoistopalveluissa.", - "an_error_occurred": "Tapahtui virhe", - "copy_to_clipboard": "Kopioi leikepöydälle", - "view_logs": "Näytä lokit", - "retry": "Yritä uudelleen", - "no_default_metadata_provider_selected": "Et ole asettanut oletusmetatietojen tarjoajaa", - "manage_metadata_providers": "Hallinnoi metatietojen tarjoajia", - "open_link_in_browser": "Avaa linkki selaimessa?", - "do_you_want_to_open_the_following_link": "Haluatko avata seuraavan linkin", - "unsafe_url_warning": "Linkkien avaaminen epäluotettavista lähteistä voi olla vaarallista. Ole varovainen!\nVoit myös kopioida linkin leikepöydälle.", - "copy_link": "Kopioi linkki", - "building_your_timeline": "Rakennetaan aikajanaasi kuuntelujesi perusteella...", - "official": "Virallinen", - "author_name": "Tekijä: {author}", - "third_party": "Kolmannen osapuolen", - "plugin_requires_authentication": "Lisäosa vaatii todentamisen", - "update_available": "Päivitys saatavilla", - "supports_scrobbling": "Tukee scrobblingia", - "plugin_scrobbling_info": "Tämä lisäosa scrobblaa musiikkisi luodakseen kuunteluhistoriasi.", - "default_plugin": "Oletus", - "set_default": "Aseta oletukseksi", - "support": "Tuki", - "support_plugin_development": "Tue lisäosan kehitystä", - "can_access_name_api": "- Voi käyttää **{name}** APIa", - "do_you_want_to_install_this_plugin": "Haluatko asentaa tämän lisäosan?", - "third_party_plugin_warning": "Tämä lisäosa on kolmannen osapuolen arkistosta. Varmista, että luotat lähteeseen ennen asennusta.", - "author": "Tekijä", - "this_plugin_can_do_following": "Tämä lisäosa voi tehdä seuraavaa", - "install": "Asenna", - "install_a_metadata_provider": "Asenna metatietojen tarjoaja", - "no_tracks_playing": "Ei kappaletta toistossa tällä hetkellä", - "synced_lyrics_not_available": "Synkronoidut sanoitukset eivät ole saatavilla tälle kappaleelle. Käytä sen sijaan", - "plain_lyrics": "Pelkät sanoitukset", - "tab_instead": "välilehteä.", - "disclaimer": "Vastuuvapauslauseke", - "third_party_plugin_dmca_notice": "Spotube-tiimi ei ota mitään vastuuta (mukaan lukien oikeudellinen) mistään \"kolmannen osapuolen\" lisäosista.\nKäytä niitä omalla vastuullasi. Ilmoita kaikista virheistä/ongelmista lisäosan arkistoon.\n\nJos jokin \"kolmannen osapuolen\" lisäosa rikkoo jonkin palvelun/oikeushenkilön käyttöehtoja/DMCA:ta, pyydä \"kolmannen osapuolen\" lisäosan tekijää tai isännöintialustaa, esim. GitHubia/Codebergiä, ryhtymään toimiin. Yllä luetellut (\"kolmannen osapuolen\" merkityt) ovat kaikki julkisia/yhteisön ylläpitämiä lisäosia. Emme kuratoi niitä, joten emme voi ryhtyä niihin toimiin.\n\n", - "input_does_not_match_format": "Syöte ei vastaa vaadittua muotoa", - "metadata_provider_plugins": "Metatietojen tarjoajan lisäosat", - "paste_plugin_download_url": "Liitä lataus-URL-osoite tai GitHub/Codeberg-arkiston URL-osoite tai suora linkki .smplug-tiedostoon", - "download_and_install_plugin_from_url": "Lataa ja asenna lisäosa URL-osoitteesta", - "failed_to_add_plugin_error": "Lisäosan lisääminen epäonnistui: {error}", - "upload_plugin_from_file": "Lataa lisäosa tiedostosta", - "installed": "Asennettu", - "available_plugins": "Saatavilla olevat lisäosat", - "configure_your_own_metadata_plugin": "Määritä oma soittolistan/albumin/artistin/syötteen metatietojen tarjoaja", - "audio_scrobblers": "Äänen scrobblerit", - "scrobbling": "Scrobbling", - "download_music_format": "Musiikin latausmuoto", - "streaming_music_format": "Musiikin suoratoistomuoto", - "download_music_quality": "Musiikin latauslaatu", - "streaming_music_quality": "Musiikin suoratoistolaadun", - "default_metadata_source": "Oletusarvoinen metatietolähde", - "set_default_metadata_source": "Aseta oletusmetatietolähde", - "default_audio_source": "Oletusarvoinen äänilähde", - "set_default_audio_source": "Aseta oletusäänilähde", - "plugins": "Laajennukset", - "configure_plugins": "Määritä omat metatietojen tarjoaja- ja äänilähdelaajennukset", - "source": "Lähde: ", - "uncompressed": "Pakkaamaton", - "dab_music_source_description": "Audiofiileille. Tarjoaa korkealaatuisia/häviöttömiä äänivirtoja. Tarkka ISRC-pohjainen kappaleiden tunnistus." -} \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb deleted file mode 100644 index e73c2eb2..00000000 --- a/lib/l10n/app_fr.arb +++ /dev/null @@ -1,495 +0,0 @@ -{ - "guest": "Invité", - "browse": "Explorer", - "search": "Rechercher", - "library": "Bibliothèque", - "lyrics": "Paroles", - "settings": "Paramètres", - "genre_categories_filter": "Filtrer les catégories ou les genres...", - "genre": "Genre", - "personalized": "Personnalisé", - "featured": "En vedette", - "new_releases": "Nouvelles sorties", - "songs": "Chansons", - "playing_track": "Lecture de {track}", - "queue_clear_alert": "Cela effacera la file d'attente actuelle. {track_length} pistes seront supprimées\nVoulez-vous continuer?", - "load_more": "Charger plus", - "playlists": "Listes de lecture", - "artists": "Artistes", - "albums": "Albums", - "tracks": "Pistes", - "downloads": "Téléchargements", - "filter_playlists": "Filtrer vos listes de lecture...", - "liked_tracks": "Pistes aimées", - "liked_tracks_description": "Toutes vos pistes aimées", - "create_playlist": "Créer une liste de lecture", - "create_a_playlist": "Créer une liste de lecture", - "create": "Créer", - "cancel": "Annuler", - "playlist_name": "Nom de la liste de lecture", - "name_of_playlist": "Nom de la liste de lecture", - "description": "Description", - "public": "Public", - "collaborative": "Collaborative", - "search_local_tracks": "Rechercher des pistes locales...", - "play": "Lecture", - "delete": "Supprimer", - "none": "Aucun", - "sort_a_z": "Trier par ordre alphabétique", - "sort_z_a": "Trier par ordre alphabétique inverse", - "sort_artist": "Trier par artiste", - "sort_album": "Trier par album", - "sort_tracks": "Trier les pistes", - "currently_downloading": "Téléchargement en cours ({tracks_length})", - "cancel_all": "Tout annuler", - "filter_artist": "Filtrer les artistes...", - "followers": "{followers} abonnés", - "add_artist_to_blacklist": "Ajouter l'artiste à la liste noire", - "top_tracks": "Meilleures pistes", - "fans_also_like": "Les fans aiment aussi", - "loading": "Chargement...", - "artist": "Artiste", - "blacklisted": "Liste noire", - "following": "Abonné", - "follow": "S'abonner", - "artist_url_copied": "URL de l'artiste copiée dans le presse-papiers", - "added_to_queue": "{tracks} pistes ajoutées à la file d'attente", - "filter_albums": "Filtrer les albums...", - "synced": "Synchronisé", - "plain": "Simple", - "shuffle": "Lecture aléatoire", - "search_tracks": "Rechercher des pistes...", - "released": "Sorti", - "error": "Erreur {error}", - "title": "Titre", - "time": "Durée", - "more_actions": "Plus d'actions", - "download_count": "Téléchargement ({count})", - "add_count_to_playlist": "Ajouter ({count}) à la liste de lecture", - "add_count_to_queue": "Ajouter ({count}) à la file d'attente", - "play_count_next": "Lire ({count}) ensuite", - "album": "Album", - "copied_to_clipboard": "{data} copié dans le presse-papiers", - "add_to_following_playlists": "Ajouter {track} aux listes de lecture suivantes", - "add": "Ajouter", - "added_track_to_queue": "{track} ajouté à la file d'attente", - "add_to_queue": "Ajouter à la file d'attente", - "track_will_play_next": "{track} sera joué ensuite", - "play_next": "Lire ensuite", - "removed_track_from_queue": "{track} retiré de la file d'attente", - "remove_from_queue": "Retirer de la file d'attente", - "remove_from_favorites": "Retirer des favoris", - "save_as_favorite": "Enregistrer comme favori", - "add_to_playlist": "Ajouter à la liste de lecture", - "remove_from_playlist": "Retirer de la liste de lecture", - "add_to_blacklist": "Ajouter à la liste noire", - "remove_from_blacklist": "Retirer de la liste noire", - "share": "Partager", - "mini_player": "Lecteur mini", - "slide_to_seek": "Faites glisser pour avancer ou reculer", - "shuffle_playlist": "Lecture aléatoire de la liste de lecture", - "unshuffle_playlist": "Annuler la lecture aléatoire de la liste de lecture", - "previous_track": "Piste précédente", - "next_track": "Piste suivante", - "pause_playback": "Mettre en pause la lecture", - "resume_playback": "Reprendre la lecture", - "loop_track": "Lecture en boucle de la piste", - "repeat_playlist": "Répéter la liste de lecture", - "queue": "File d'attente", - "alternative_track_sources": "Sources alternatives de pistes", - "download_track": "Télécharger la piste", - "tracks_in_queue": "{tracks} pistes dans la file d'attente", - "clear_all": "Tout effacer", - "show_hide_ui_on_hover": "Afficher/Masquer l'interface utilisateur au survol", - "always_on_top": "Toujours au-dessus", - "exit_mini_player": "Quitter le lecteur mini", - "download_location": "Emplacement de téléchargement", - "account": "Compte", - "login_with_spotify": "Se connecter avec votre compte Spotify", - "connect_with_spotify": "Se connecter avec Spotify", - "logout": "Se déconnecter", - "logout_of_this_account": "Se déconnecter de ce compte", - "language_region": "Langue et région", - "language": "Langue", - "system_default": "Paramètres par défaut du système", - "market_place_region": "Région du marché", - "recommendation_country": "Pays de recommandation", - "appearance": "Apparence", - "layout_mode": "Mode de mise en page", - "override_layout_settings": "Remplacer les paramètres de mise en page adaptative", - "adaptive": "Adaptatif", - "compact": "Compact", - "extended": "Étendu", - "theme": "Thème", - "dark": "Sombre", - "light": "Clair", - "system": "Système", - "accent_color": "Couleur d'accentuation", - "sync_album_color": "Synchroniser la couleur de l'album", - "sync_album_color_description": "Utilise la couleur dominante de l'art de l'album comme couleur d'accentuation", - "playback": "Lecture", - "audio_quality": "Qualité audio", - "high": "Haute", - "low": "Basse", - "pre_download_play": "Pré-télécharger et lire", - "pre_download_play_description": "Au lieu de diffuser de l'audio, téléchargez les octets et lisez-les à la place (recommandé pour les utilisateurs à bande passante élevée)", - "skip_non_music": "Ignorer les segments non musicaux (SponsorBlock)", - "blacklist_description": "Pistes et artistes en liste noire", - "wait_for_download_to_finish": "Veuillez attendre la fin du téléchargement en cours", - "desktop": "Bureau", - "close_behavior": "Comportement de fermeture", - "close": "Fermer", - "minimize_to_tray": "Réduire dans la zone de notification", - "show_tray_icon": "Afficher l'icône de la zone de notification", - "about": "À propos", - "u_love_spotube": "Nous savons que vous aimez Spotube", - "check_for_updates": "Vérifier les mises à jour", - "about_spotube": "À propos de Spotube", - "blacklist": "Liste noire", - "please_sponsor": "S'il vous plaît Sponsoriser/Donner", - "spotube_description": "Spotube, un client Spotify léger, multiplateforme et gratuit pour tous", - "version": "Version", - "build_number": "Numéro de version", - "founder": "Fondateur", - "repository": "Dépôt", - "bug_issues": "Bugs + Problèmes", - "made_with": "Fabriqué avec ❤️ au Bangladesh🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Licence", - "add_spotify_credentials": "Ajoutez vos identifiants Spotify pour commencer", - "credentials_will_not_be_shared_disclaimer": "Ne vous inquiétez pas, vos identifiants ne seront ni collectés ni partagés avec qui que ce soit", - "know_how_to_login": "Vous ne savez pas comment faire?", - "follow_step_by_step_guide": "Suivez le guide étape par étape", - "spotify_cookie": "Cookie Spotify {name}", - "cookie_name_cookie": "Cookie {name}", - "fill_in_all_fields": "Veuillez remplir tous les champs", - "submit": "Soumettre", - "exit": "Quitter", - "previous": "Précédent", - "next": "Suivant", - "done": "Terminé", - "step_1": "Étape 1", - "first_go_to": "Tout d'abord, allez sur", - "login_if_not_logged_in": "et connectez-vous/inscrivez-vous si vous n'êtes pas connecté", - "step_2": "Étape 2", - "step_2_steps": "1. Une fois connecté, appuyez sur F12 ou clic droit de la souris > Inspecter pour ouvrir les outils de développement du navigateur.\n2. Ensuite, allez dans l'onglet \"Application\" (Chrome, Edge, Brave, etc.) ou l'onglet \"Stockage\" (Firefox, Palemoon, etc.)\n3. Allez dans la section \"Cookies\", puis dans la sous-section \"https://accounts.spotify.com\"", - "step_3": "Étape 3", - "success_emoji": "Succès🥳", - "success_message": "Vous êtes maintenant connecté avec succès à votre compte Spotify. Bon travail, mon ami!", - "step_4": "Étape 4", - "something_went_wrong": "Quelque chose s'est mal passé", - "piped_instance": "Instance pipée", - "piped_description": "L'instance de serveur Piped à utiliser pour la correspondance des pistes", - "piped_warning": "Certaines d'entre elles peuvent ne pas fonctionner correctement. Alors utilisez à vos risques et périls", - "generate_playlist": "Générer une playlist", - "track_exists": "La piste {track} existe déjà", - "replace_downloaded_tracks": "Remplacer toutes les pistes téléchargées", - "skip_download_tracks": "Ignorer le téléchargement de toutes les pistes téléchargées", - "do_you_want_to_replace": "Voulez-vous remplacer la piste existante ?", - "replace": "Remplacer", - "skip": "Passer", - "select_up_to_count_type": "Sélectionnez jusqu'à {count} {type}", - "select_genres": "Sélectionner les genres", - "add_genres": "Ajouter des genres", - "country": "Pays", - "number_of_tracks_generate": "Nombre de pistes à générer", - "acousticness": "Acoustique", - "danceability": "Dansabilité", - "energy": "Énergie", - "instrumentalness": "Instrumentalité", - "liveness": "Interprétation en direct", - "loudness": "Sonorité", - "speechiness": "Parlé", - "valence": "Valeur émotionnelle", - "popularity": "Popularité", - "key": "Clé", - "duration": "Durée (s)", - "tempo": "Tempo (BPM)", - "mode": "Mode", - "time_signature": "Signature rythmique", - "short": "Court", - "medium": "Moyen", - "long": "Long", - "min": "Min", - "max": "Max", - "target": "Cible", - "moderate": "Modéré", - "deselect_all": "Tout désélectionner", - "select_all": "Tout sélectionner", - "are_you_sure": "Êtes-vous sûr(e) ?", - "generating_playlist": "Génération de votre playlist personnalisée en cours...", - "selected_count_tracks": "{count} pistes sélectionnées", - "download_warning": "Si vous téléchargez toutes les pistes en vrac, vous violez clairement les droits d'auteur de la musique et vous causez des dommages à la société créative de la musique. J'espère que vous en êtes conscient. Essayez toujours de respecter et de soutenir le travail acharné des artistes.", - "download_ip_ban_warning": "Au fait, votre adresse IP peut être bloquée sur YouTube en raison d'une demande excessive de téléchargements par rapport à la normale. Le blocage de l'IP signifie que vous ne pourrez pas utiliser YouTube (même si vous êtes connecté) pendant au moins 2 à 3 mois à partir de cet appareil IP. Et Spotube ne peut être tenu responsable si cela se produit.", - "by_clicking_accept_terms": "En cliquant sur 'accepter', vous acceptez les conditions suivantes :", - "download_agreement_1": "Je sais que je pirate de la musique. Je suis méchant(e).", - "download_agreement_2": "Je soutiendrai l'artiste autant que possible et je ne fais cela que parce que je n'ai pas d'argent pour acheter leur art.", - "download_agreement_3": "Je suis parfaitement conscient(e) que mon adresse IP peut être bloquée sur YouTube et je ne tiens pas Spotube ni ses propriétaires/contributeurs responsables de tout accident causé par mon action actuelle.", - "decline": "Refuser", - "accept": "Accepter", - "details": "Détails", - "youtube": "YouTube", - "channel": "Chaîne", - "likes": "J'aime", - "dislikes": "Je n'aime pas", - "views": "Vues", - "streamUrl": "URL de diffusion", - "stop": "Arrêter", - "sort_newest": "Trier par les plus récents", - "sort_oldest": "Trier par les plus anciens", - "sleep_timer": "Minuteur de veille", - "mins": "{minutes} minutes", - "hours": "{hours} heures", - "hour": "{hours} heure", - "custom_hours": "Heures personnalisées", - "logs": "Journaux", - "developers": "Développeurs", - "not_logged_in": "Vous n'êtes pas connecté(e)", - "search_mode": "Mode de recherche", - "audio_source": "Source audio", - "ok": "OK", - "failed_to_encrypt": "Échec de la cryptage", - "encryption_failed_warning": "Spotube utilise le cryptage pour stocker vos données en toute sécurité. Mais cela a échoué. Il basculera donc vers un stockage non sécurisé\nSi vous utilisez Linux, assurez-vous d'avoir installé des services secrets tels que gnome-keyring, kde-wallet et keepassxc", - "querying_info": "Interrogation des info...", - "piped_api_down": "L'API Piped est hors service", - "piped_down_error_instructions": "L'instance Piped {pipedInstance} est actuellement indisponible\n\nChangez soit l'instance, soit le 'Type d'API' pour utiliser l'API officielle de YouTube\n\nN'oubliez pas de redémarrer l'application après la modification", - "you_are_offline": "Vous êtes actuellement hors ligne", - "connection_restored": "Votre connexion internet a été rétablie", - "use_system_title_bar": "Utiliser la barre de titre système", - "update_playlist": "Mettre à jour la playlist", - "update": "Mettre à jour", - "crunching_results": "Traitement des résultats...", - "search_to_get_results": "Recherche pour obtenir des résultats", - "use_amoled_mode": "Utiliser le mode AMOLED", - "pitch_dark_theme": "Thème Dart noir intense", - "normalize_audio": "Normaliser l'audio", - "change_cover": "Changer de couverture", - "add_cover": "Ajouter une couverture", - "restore_defaults": "Restaurer les valeurs par défaut", - "download_music_codec": "Télécharger le codec musical", - "streaming_music_codec": "Codec de musique en streaming", - "login_with_lastfm": "Se connecter avec Last.fm", - "connect": "Connecter", - "disconnect_lastfm": "Déconnecter de Last.fm", - "disconnect": "Déconnecter", - "username": "Nom d'utilisateur", - "password": "Mot de passe", - "login": "Se connecter", - "login_with_your_lastfm": "Se connecter avec votre compte Last.fm", - "scrobble_to_lastfm": "Scrobble à Last.fm", - "go_to_album": "Aller à l'album", - "discord_rich_presence": "Présence riche de Discord", - "browse_all": "Parcourir tout", - "genres": "Genres", - "explore_genres": "Explorer les genres", - "step_3_steps": "Copiez la valeur du cookie \"sp_dc\"", - "step_4_steps": "Collez la valeur copiée de \"sp_dc\"", - "friends": "Amis", - "no_lyrics_available": "Désolé, impossible de trouver les paroles de cette piste", - "sort_duration": "Trier par durée", - "start_a_radio": "Démarrer une radio", - "how_to_start_radio": "Comment voulez-vous démarrer la radio ?", - "replace_queue_question": "Voulez-vous remplacer la file d'attente actuelle ou y ajouter ?", - "endless_playback": "Lecture sans fin", - "delete_playlist": "Supprimer la playlist", - "delete_playlist_confirmation": "Êtes-vous sûr de vouloir supprimer cette playlist ?", - "local_tracks": "Titres locaux", - "song_link": "Lien de la chanson", - "skip_this_nonsense": "Passer cette absurdité", - "freedom_of_music": "“Liberté de la musique”", - "freedom_of_music_palm": "“Liberté de la musique dans la paume de votre main”", - "get_started": "Commençons", - "youtube_source_description": "Recommandé et fonctionne mieux.", - "piped_source_description": "Vous vous sentez libre ? Comme YouTube mais beaucoup plus gratuit.", - "jiosaavn_source_description": "Le meilleur pour la région d'Asie du Sud.", - "highest_quality": "Meilleure qualité : {quality}", - "select_audio_source": "Sélectionner la source audio", - "endless_playback_description": "Ajouter automatiquement de nouvelles chansons à la fin de la file d'attente", - "choose_your_region": "Choisissez votre région", - "choose_your_region_description": "Cela aidera Spotube à vous montrer le bon contenu pour votre emplacement.", - "choose_your_language": "Choisissez votre langue", - "help_project_grow": "Aidez ce projet à grandir", - "help_project_grow_description": "Spotube est un projet open-source. Vous pouvez aider ce projet à grandir en contribuant au projet, en signalant des bugs ou en suggérant de nouvelles fonctionnalités.", - "contribute_on_github": "Contribuer sur GitHub", - "donate_on_open_collective": "Faire un don sur Open Collective", - "browse_anonymously": "Naviguer anonymement", - "enable_connect": "Activer la connexion", - "enable_connect_description": "Contrôlez Spotube depuis d'autres appareils", - "devices": "Appareils", - "select": "Sélectionner", - "connect_client_alert": "Vous êtes contrôlé par {client}", - "this_device": "Cet appareil", - "remote": "À distance", - "local_library": "Bibliothèque locale", - "add_library_location": "Ajouter à la bibliothèque", - "remove_library_location": "Retirer de la bibliothèque", - "local_tab": "Local", - "stats": "Statistiques", - "and_n_more": "et {count} de plus", - "recently_played": "Récemment joué", - "browse_more": "Parcourir plus", - "no_title": "Sans titre", - "not_playing": "Non joué", - "epic_failure": "Échec épique!", - "added_num_tracks_to_queue": "{tracks_length} morceaux ajoutés à la file d'attente", - "spotube_has_an_update": "Spotube a une mise à jour", - "download_now": "Télécharger maintenant", - "nightly_version": "Spotube Nightly {nightlyBuildNum} a été publié", - "release_version": "Spotube v{version} a été publié", - "read_the_latest": "Lisez les dernières ", - "release_notes": "notes de version", - "pick_color_scheme": "Choisissez le schéma de couleurs", - "save": "Sauvegarder", - "choose_the_device": "Choisissez l'appareil:", - "multiple_device_connected": "Plusieurs appareils sont connectés.\nChoisissez l'appareil sur lequel vous souhaitez effectuer cette action", - "nothing_found": "Rien trouvé", - "the_box_is_empty": "La boîte est vide", - "top_artists": "Meilleurs artistes", - "top_albums": "Meilleurs albums", - "this_week": "Cette semaine", - "this_month": "Ce mois-ci", - "last_6_months": "Les 6 derniers mois", - "this_year": "Cette année", - "last_2_years": "Les 2 dernières années", - "all_time": "De tous les temps", - "powered_by_provider": "Propulsé par {providerName}", - "email": "Email", - "profile_followers": "Abonnés", - "birthday": "Anniversaire", - "subscription": "Abonnement", - "not_born": "Non né", - "hacker": "Hacker", - "profile": "Profil", - "no_name": "Sans nom", - "edit": "Modifier", - "user_profile": "Profil utilisateur", - "count_plays": "{count} lectures", - "streaming_fees_hypothetical": "Frais de streaming (hypothétiques)", - "minutes_listened": "Minutes écoutées", - "streamed_songs": "Morceaux diffusés", - "count_streams": "{count} streams", - "owned_by_you": "Possédé par vous", - "copied_shareurl_to_clipboard": "{shareUrl} copié dans le presse-papier", - "spotify_hipotetical_calculation": "*Cela est calculé en fonction du\npaiement par stream de Spotify de 0,003 $ à 0,005 $.\nIl s'agit d'un calcul hypothétique pour donner\nune idée de combien vous auriez\npayé aux artistes si vous aviez\nécouté leur chanson sur Spotify.", - "count_mins": "{minutes} minutes", - "summary_minutes": "minutes", - "summary_listened_to_music": "A écouté de la musique", - "summary_songs": "morceaux", - "summary_streamed_overall": "Diffusé en général", - "summary_owed_to_artists": "Dû aux artistes\nCe mois-ci", - "summary_artists": "artistes", - "summary_music_reached_you": "La musique vous a atteint", - "summary_full_albums": "albums complets", - "summary_got_your_love": "A obtenu votre amour", - "summary_playlists": "playlists", - "summary_were_on_repeat": "Était en répétition", - "total_money": "Total {money}", - "webview_not_found": "Webview non trouvé", - "webview_not_found_description": "Aucun environnement d'exécution Webview installé sur votre appareil.\nSi c'est installé, assurez-vous qu'il soit dans le environment PATH\n\nAprès l'installation, redémarrez l'application", - "unsupported_platform": "Plateforme non prise en charge", - "invidious_instance": "Instance de serveur Invidious", - "invidious_description": "L'instance de serveur Invidious à utiliser pour la correspondance de pistes", - "invidious_warning": "Certaines instances pourraient ne pas bien fonctionner. À utiliser à vos risques et périls", - "invidious_source_description": "Similaire à Piped mais avec une meilleure disponibilité", - "cache_music": "Mettre la musique en cache", - "open": "Ouvrir", - "cache_folder": "Dossier du cache", - "export": "Exporter", - "clear_cache": "Effacer le cache", - "clear_cache_confirmation": "Voulez-vous effacer le cache ?", - "export_cache_files": "Exporter les fichiers en cache", - "found_n_files": "{count} fichiers trouvés", - "export_cache_confirmation": "Voulez-vous exporter ces fichiers vers", - "exported_n_out_of_m_files": "{filesExported} fichiers exportés sur {files}", - "playlist": "Playlist", - "no_loop": "Pas de boucle", - "generate": "Générer", - "undo": "Annuler", - "download_all": "Télécharger tout", - "add_all_to_playlist": "Ajouter tout à la playlist", - "add_all_to_queue": "Ajouter tout à la file d'attente", - "play_all_next": "Lire tout suivant", - "pause": "Pause", - "view_all": "Voir tout", - "no_tracks_added_yet": "Il semble que vous n'avez encore ajouté aucun morceau.", - "no_tracks": "Il semble qu'il n'y ait pas de morceaux ici.", - "no_tracks_listened_yet": "Il semble que vous n'avez encore rien écouté.", - "not_following_artists": "Vous ne suivez aucun artiste.", - "no_favorite_albums_yet": "Il semble que vous n'ayez encore ajouté aucun album à vos favoris.", - "no_logs_found": "Aucun log trouvé", - "youtube_engine": "Moteur YouTube", - "youtube_engine_not_installed_title": "{engine} n'est pas installé", - "youtube_engine_not_installed_message": "{engine} n'est pas installé sur votre système.", - "youtube_engine_set_path": "Assurez-vous qu'il est disponible dans la variable PATH ou\nfixez le chemin absolu du fichier exécutable {engine} ci-dessous.", - "youtube_engine_unix_issue_message": "Dans macOS/Linux/les systèmes d'exploitation similaires à Unix, définir le chemin dans .zshrc/.bashrc/.bash_profile etc. ne fonctionnera pas.\nVous devez définir le chemin dans le fichier de configuration du shell.", - "download": "Télécharger", - "file_not_found": "Fichier non trouvé", - "custom": "Personnalisé", - "add_custom_url": "Ajouter une URL personnalisée", - "edit_port": "Modifier le port", - "port_helper_msg": "La valeur par défaut est -1, ce qui indique un nombre aléatoire. Si vous avez configuré un pare-feu, il est recommandé de le définir.", - "connect_request": "Autoriser {client} à se connecter ?", - "connection_request_denied ": "Connexion refusée. L'utilisateur a refusé l'accès.", - "hipotetical_calculation": "*Ce calcul est basé sur le paiement moyen par lecture des plateformes de streaming musical en ligne, de 0,003 $ à 0,005 $. Il s'agit d'un calcul hypothétique pour donner à l'utilisateur un aperçu de ce qu'il aurait payé aux artistes s'il écoutait leur chanson sur différentes plateformes de streaming musical.", - "connection_request_denied": "Connexion refusée. L'utilisateur a refusé l'accès.", - "an_error_occurred": "Une erreur est survenue", - "copy_to_clipboard": "Copier dans le presse-papiers", - "view_logs": "Afficher les journaux", - "retry": "Réessayer", - "no_default_metadata_provider_selected": "Vous n'avez pas de fournisseur de métadonnées par défaut", - "manage_metadata_providers": "Gérer les fournisseurs de métadonnées", - "open_link_in_browser": "Ouvrir le lien dans le navigateur ?", - "do_you_want_to_open_the_following_link": "Voulez-vous ouvrir le lien suivant", - "unsafe_url_warning": "L'ouverture de liens provenant de sources non fiables peut être dangereuse. Soyez prudent !\nVous pouvez également copier le lien dans votre presse-papiers.", - "copy_link": "Copier le lien", - "building_your_timeline": "Construction de votre chronologie en fonction de vos écoutes...", - "official": "Officiel", - "author_name": "Auteur : {author}", - "third_party": "Tiers", - "plugin_requires_authentication": "Le plugin nécessite une authentification", - "update_available": "Mise à jour disponible", - "supports_scrobbling": "Supporte le scrobbling", - "plugin_scrobbling_info": "Ce plugin scrobble votre musique pour générer votre historique d'écoute.", - "default_plugin": "Par défaut", - "set_default": "Définir par défaut", - "support": "Soutien", - "support_plugin_development": "Soutenir le développement de plugins", - "can_access_name_api": "- Peut accéder à l'API **{name}**", - "do_you_want_to_install_this_plugin": "Voulez-vous installer ce plugin ?", - "third_party_plugin_warning": "Ce plugin provient d'un dépôt tiers. Assurez-vous de faire confiance à la source avant de l'installer.", - "author": "Auteur", - "this_plugin_can_do_following": "Ce plugin peut faire ce qui suit", - "install": "Installer", - "install_a_metadata_provider": "Installer un fournisseur de métadonnées", - "no_tracks_playing": "Aucune piste n'est en cours de lecture actuellement", - "synced_lyrics_not_available": "Les paroles synchronisées ne sont pas disponibles pour cette chanson. Veuillez utiliser l'onglet", - "plain_lyrics": "Paroles simples", - "tab_instead": "à la place.", - "disclaimer": "Avertissement", - "third_party_plugin_dmca_notice": "L'équipe de Spotube n'assume aucune responsabilité (y compris juridique) pour les plugins \"tiers\".\nVeuillez les utiliser à vos propres risques. Pour tout bug/problème, veuillez le signaler au dépôt du plugin.\n\nSi un plugin \"tiers\" enfreint les conditions d'utilisation/DMCA d'un service/entité juridique, veuillez demander à l'auteur du plugin \"tiers\" ou à la plateforme d'hébergement (par exemple GitHub/Codeberg) de prendre des mesures. Les plugins listés ci-dessus (étiquetés \"tiers\") sont tous des plugins publics/maintenus par la communauté. Nous ne les gérons pas, nous ne pouvons donc prendre aucune mesure à leur sujet.\n\n", - "input_does_not_match_format": "L'entrée ne correspond pas au format requis", - "metadata_provider_plugins": "Plugins de fournisseur de métadonnées", - "paste_plugin_download_url": "Collez l'URL de téléchargement ou l'URL du dépôt GitHub/Codeberg ou un lien direct vers le fichier .smplug", - "download_and_install_plugin_from_url": "Télécharger et installer le plugin à partir de l'URL", - "failed_to_add_plugin_error": "Échec de l'ajout du plugin : {error}", - "upload_plugin_from_file": "Télécharger le plugin à partir d'un fichier", - "installed": "Installé", - "available_plugins": "Plugins disponibles", - "configure_your_own_metadata_plugin": "Configurer votre propre fournisseur de métadonnées de playlist/album/artiste/flux", - "audio_scrobblers": "Scrobblers audio", - "scrobbling": "Scrobbling", - "download_music_format": "Format de téléchargement de musique", - "streaming_music_format": "Format de streaming de musique", - "download_music_quality": "Qualité de téléchargement de musique", - "streaming_music_quality": "Qualité de streaming de musique", - "default_metadata_source": "Source de métadonnées par défaut", - "set_default_metadata_source": "Définir la source de métadonnées par défaut", - "default_audio_source": "Source audio par défaut", - "set_default_audio_source": "Définir la source audio par défaut", - "plugins": "Plugins", - "configure_plugins": "Configurez vos propres plugins de fournisseur de métadonnées et de source audio", - "source": "Source : ", - "uncompressed": "Non compressé", - "dab_music_source_description": "Pour les audiophiles. Fournit des flux audio de haute qualité/sans perte. Correspondance précise des pistes basée sur ISRC." -} \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb deleted file mode 100644 index 8e8087bb..00000000 --- a/lib/l10n/app_hi.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "अतिथि", - "browse": "ब्राउज़ करें", - "search": "खोजें", - "library": "लाइब्रेरी", - "lyrics": "गीतों के बोल", - "settings": "सेटिंग्स", - "genre_categories_filter": "श्रेणियों या जानरों को फिल्टर करें...", - "genre": "जानर", - "personalized": "व्यक्तिगत", - "featured": "विशेष रुप से प्रदर्शित", - "new_releases": "नई रिलीज़", - "songs": "गाने", - "playing_track": "{track} चल रहा है", - "queue_clear_alert": "यह मौजूदा कतार को साफ़ कर देगा। {track_length} ट्रैक हटा दिए जाएंगे\nक्या आप जारी रखना चाहते हैं?", - "load_more": "और लोड करें", - "playlists": "प्लेलिस्ट", - "artists": "कलाकार", - "albums": "एल्बम", - "tracks": "ट्रैक", - "downloads": "डाउनलोड", - "filter_playlists": "अपनी प्लेलिस्टों को फ़िल्टर करें...", - "liked_tracks": "पसंदीदा ट्रैक", - "liked_tracks_description": "आपके सभी पसंदीदा ट्रैक", - "create_playlist": "प्लेलिस्ट बनाएं", - "create_a_playlist": "एक प्लेलिस्ट बनाएं", - "create": "बनाएं", - "cancel": "रद्द करें", - "playlist_name": "प्लेलिस्ट का नाम", - "name_of_playlist": "प्लेलिस्ट का नाम", - "description": "विवरण", - "public": "सार्वजनिक", - "collaborative": "सहयोगी", - "search_local_tracks": "स्थानीय ट्रैक खोजें...", - "play": "चलाएँ", - "delete": "हटाएँ", - "none": "कोई नहीं", - "sort_a_z": "A-Z सॉर्ट करें", - "sort_z_a": "Z-A सॉर्ट करें", - "sort_artist": "कलाकार के अनुसार सॉर्ट करें", - "sort_album": "एल्बम के अनुसार सॉर्ट करें", - "sort_tracks": "ट्रैक को सॉर्ट करें", - "currently_downloading": "वर्तमान में डाउनलोड हो रहा है ({tracks_length})", - "cancel_all": "सभी को रद्द करें", - "filter_artist": "कलाकारों को फ़िल्टर करें...", - "followers": "{followers} फॉलोअर्स", - "add_artist_to_blacklist": "काल सूची में कलाकार जोड़ें", - "top_tracks": "शीर्ष ट्रैक", - "fans_also_like": "फैंस भी पसंद करते हैं", - "loading": "लोड हो रहा है...", - "artist": "कलाकार", - "blacklisted": "काल सूची में है", - "following": "फॉलो करना", - "follow": "फॉलो करें", - "artist_url_copied": "कलाकार URL क्लिपबोर्ड पर कॉपी हुआ", - "added_to_queue": "{tracks} ट्रैक कतार में जोड़े गए", - "filter_albums": "एल्बमों को फ़िल्टर करें...", - "synced": "सिंक किया गया", - "plain": "सादा", - "shuffle": "शफल", - "search_tracks": "ट्रैक खोजें...", - "released": "जारी हुआ", - "error": "त्रुटि {error}", - "title": "शीर्षक", - "time": "समय", - "more_actions": "अधिक कार्रवाई", - "download_count": "डाउनलोड ({count})", - "add_count_to_playlist": "({count}) को प्लेलिस्ट में जोड़ें", - "add_count_to_queue": "({count}) को कतार में जोड़ें", - "play_count_next": "({count}) अगले में चलाएँ", - "album": "एल्बम", - "copied_to_clipboard": "{data} क्लिपबोर्ड पर कॉपी किया गया", - "add_to_following_playlists": "{track} को निम्नलिखित प्लेलिस्ट में जोड़ें", - "add": "जोड़ें", - "added_track_to_queue": "{track} को कतार में जोड़ दिया गया", - "add_to_queue": "कतार में जोड़ें", - "track_will_play_next": "{track} अगले में चलेगा", - "play_next": "अगले में चलाएँ", - "removed_track_from_queue": "{track} को कतार से हटा दिया गया", - "remove_from_queue": "कतार से हटाएँ", - "remove_from_favorites": "पसंदीदा से हटाएँ", - "save_as_favorite": "पसंदीदा के रूप में सहेजें", - "add_to_playlist": "प्लेलिस्ट में जोड़ें", - "remove_from_playlist": "प्लेलिस्ट से हटाएँ", - "add_to_blacklist": "ब्लैकलिस्ट में जोड़ें", - "remove_from_blacklist": "ब्लैकलिस्ट से हटाएँ", - "share": "साझा करें", - "mini_player": "मिनी प्लेयर", - "slide_to_seek": "आगे या पीछे खोजने के लिए स्लाइड करें", - "shuffle_playlist": "प्लेलिस्ट शफल करें", - "unshuffle_playlist": "अनशफल प्लेलिस्ट", - "previous_track": "पिछला ट्रैक", - "next_track": "अगला ट्रैक", - "pause_playback": "वापसी बंद करें", - "resume_playback": "पुनः चलाना", - "loop_track": "लूप ट्रैक", - "repeat_playlist": "प्लेलिस्ट दोहराएं", - "queue": "कतार", - "alternative_track_sources": "वैकल्पिक ट्रैक स्रोत", - "download_track": "ट्रैक डाउनलोड करें", - "tracks_in_queue": "{tracks} ट्रैक कतार में हैं", - "clear_all": "सभी हटाएं", - "show_hide_ui_on_hover": "होवर पर यूआई दिखाएँ/छिपाएँ", - "always_on_top": "हमेशा ऊपर हो", - "exit_mini_player": "मिनी प्लेयर से बाहर निकलें", - "download_location": "डाउनलोड स्थान", - "account": "खाता", - "login_with_spotify": "अपने Spotify खाते से लॉग इन करें", - "connect_with_spotify": "Spotify से कनेक्ट करें", - "logout": "लॉगआउट", - "logout_of_this_account": "इस खाते से लॉगआउट करें", - "language_region": "भाषा और क्षेत्र", - "language": "भाषा", - "system_default": "सिस्टम डिफ़ॉल्ट", - "market_place_region": "मार्केटप्लेस क्षेत्र", - "recommendation_country": "सिफ़ारिश देने वाला देश", - "appearance": "दिखने में", - "layout_mode": "लेआउट मोड", - "override_layout_settings": "ओवरराइड रेस्पॉन्सिव लेआउट मोड सेटिंग्स", - "adaptive": "अनुकूल", - "compact": "कॉम्पैक्ट", - "extended": "विस्तृत", - "theme": "थीम", - "dark": "डार्क", - "light": "लाइट", - "system": "सिस्टम", - "accent_color": "अक्षरशैली का रंग", - "sync_album_color": "एल्बम का रंग सिंक करें", - "sync_album_color_description": "एल्बम कला का प्रधान रंग एक्सेंट रंग के रूप में उपयोग किया जाता है", - "playback": "प्लेबैक", - "audio_quality": "ऑडियो क्वालिटी", - "high": "उच्च", - "low": "निम्न", - "pre_download_play": "पूर्वावत डाउनलोड और प्ले करें", - "pre_download_play_description": "ऑडियो स्ट्रीमिंग की बजाय बाइट्स डाउनलोड करें और बजाय में प्ले करें (उच्च बैंडविड्थ उपयोगकर्ताओं के लिए सिफारिश किया जाता है)", - "skip_non_music": "गाने के अलावा सेगमेंट्स को छोड़ें (स्पॉन्सरब्लॉक)", - "blacklist_description": "ब्लैकलिस्ट में शामिल ट्रैक और कलाकार", - "wait_for_download_to_finish": "वर्तमान डाउनलोड समाप्त होने तक कृपया प्रतीक्षा करें", - "desktop": "डेस्कटॉप", - "close_behavior": "बंद करने का व्यवहार", - "close": "बंद करें", - "minimize_to_tray": "ट्रे में कम करें", - "show_tray_icon": "सिस्टम ट्रे आइकन दिखाएं", - "about": "के बारे में", - "u_love_spotube": "हम जानते हैं कि आप Spotube से प्यार करते हैं", - "check_for_updates": "अपडेट के लिए जाँच करें", - "about_spotube": "Spotube के बारे में", - "blacklist": "ब्लैकलिस्ट", - "please_sponsor": "कृपया स्पॉन्सर / डोनेट करें", - "spotube_description": "Spotube, एक हल्का, सभी प्लेटफॉर्मों पर चलने वाला, मुफ्त स्पॉटिफाई क्लाइंट", - "version": "संस्करण", - "build_number": "बिल्ड नंबर", - "founder": "संस्थापक", - "repository": "भण्डार", - "bug_issues": "बग+मुद्दे", - "made_with": "बांग्लादेश🇧🇩 में दिल से बनाया गया", - "kingkor_roy_tirtho": "किंगकोर रॉय तिर्थो", - "copyright": "© 2021-{current_year} किंगकोर रॉय तिर्थो", - "license": "लाइसेंस", - "add_spotify_credentials": "शुरू होने के लिए अपने स्पॉटिफाई क्रेडेंशियल जोड़ें", - "credentials_will_not_be_shared_disclaimer": "चिंता न करें, आपके क्रेडेंशियल किसी भी तरह से नहीं एकत्रित या साझा किए जाएंगे", - "know_how_to_login": "इसे कैसे करें पता नहीं?", - "follow_step_by_step_guide": "कदम से कदम गाइड के साथ चलें", - "spotify_cookie": "स्पॉटिफाई {name} कुकी", - "cookie_name_cookie": "{name} कुकी", - "fill_in_all_fields": "कृपया सभी फ़ील्ड भरें", - "submit": "सबमिट", - "exit": "बाहर निकलें", - "previous": "पिछला", - "next": "अगला", - "done": "किया हुआ", - "step_1": "1 चरण", - "first_go_to": "पहले, जाएं", - "login_if_not_logged_in": "और यदि आप लॉगिन नहीं हैं तो लॉगिन / साइनअप करें", - "step_2": "2 चरण", - "step_2_steps": "1. जब आप लॉगिन हो जाएँ, तो F12 दबाएं या माउस राइट क्लिक> निरीक्षण करें ताकि ब्राउज़र डेवटूल्स खुलें।\n2. फिर ब्राउज़र के \"एप्लिकेशन\" टैब (Chrome, Edge, Brave आदि) या \"स्टोरेज\" टैब (Firefox, Palemoon आदि) में जाएं\n3. \"कुकीज़\" अनुभाग में जाएं फिर \"https: //accounts.spotify.com\" उप-अनुभाग में जाएं", - "step_3": "स्टेप 3", - "success_emoji": "सफलता🥳", - "success_message": "अब आप अपने स्पॉटिफाई अकाउंट से सफलतापूर्वक लॉगइन हो गए हैं। अच्छा काम किया!", - "step_4": "स्टेप 4", - "something_went_wrong": "कुछ गलत हो गया", - "piped_instance": "पाइप्ड सर्वर", - "piped_description": "पाइप किए गए सर्वर", - "piped_warning": "गानों का मिलान करने के लिए उपयोग किए जाते हैं, हो सकता है कि उनमें से कुछ के साथ ठीक से काम न करें इसलिए अपने जोखिम पर उपयोग करें", - "generate_playlist": "प्लेलिस्ट बनाएं", - "track_exists": "ट्रैक {track} पहले से मौजूद है", - "replace_downloaded_tracks": "सभी डाउनलोड किए गए ट्रैक्स को बदलें", - "skip_download_tracks": "सभी डाउनलोड किए गए ट्रैक्स को छोड़ें", - "do_you_want_to_replace": "क्या आप मौजूदा ट्रैक को बदलना चाहते हैं?", - "replace": "बदलें", - "skip": "छोड़ें", - "select_up_to_count_type": "{count} {type} तक चुनें", - "select_genres": "जान्र चुनें", - "add_genres": "जान्र जोड़ें", - "country": "देश", - "number_of_tracks_generate": "उत्पन्न करने के लिए ट्रैक की संख्या", - "acousticness": "ध्वनिकता", - "danceability": "नृत्यता", - "energy": "ऊर्जा", - "instrumentalness": "आलापिकता", - "liveness": "जीवंतता", - "loudness": "शोर", - "speechiness": "बोलचालता", - "valence": "मनोदयता", - "popularity": "लोकप्रियता", - "key": "कुंजी", - "duration": "अवधि (सेकंड)", - "tempo": "गति (BPM)", - "mode": "मोड", - "time_signature": "समय छाप", - "short": "संक्षेप", - "medium": "मध्यम", - "long": "लंबा", - "min": "न्यूनतम", - "max": "अधिकतम", - "target": "लक्ष्य", - "moderate": "मध्यम", - "deselect_all": "सभी को अचयनित करें", - "select_all": "सभी को चुनें", - "are_you_sure": "क्या आपको यकीन है?", - "generating_playlist": "आपकी कस्टम प्लेलिस्ट बनाई जा रही है...", - "selected_count_tracks": "{count} ट्रैक्स चयनित हैं", - "download_warning": "यदि आप सभी ट्रैक्स को बल्क में डाउनलोड करते हैं, तो आप स्पष्ट रूप से संगीत की अवैध नकली बना रहे हैं और संगीत के रचनात्मक समाज को क्षति पहुंचा रहे हैं। मुझे आशा है कि आप इसके बारे में जागरूक हैं। हमेशा कोशिश करें कि कलाकार के मेहनत का सम्मान और समर्थन करें।", - "download_ip_ban_warning": "बाहरी डाउनलोड अनुरोधों के कारण आपका आईपी YouTube पर अधिक से अधिक ब्लॉक हो सकता है। आईपी ब्लॉक का अर्थ है कि आप उसी आईपी उपकरण से कम से कम 2-3 महीनों तक YouTube का उपयोग नहीं कर सकेंगे (यदि आप लॉग इन हैं तो भी)। और स्पोट्यूब किसी भी जिम्मेदारी को नहीं उठाता है अगर ऐसा कभी होता है।", - "by_clicking_accept_terms": "'स्वीकार' पर क्लिक करके आप निम्नलिखित शर्तों से सहमत होते हैं:", - "download_agreement_1": "मुझे पता है कि मैं संगीत की अवैध नकली बना रहा हूं। मैं बुरा हूं", - "download_agreement_2": "मैं कलाकार का समर्थन करूंगा जहां भी मुझे संभव हो और मैं केवल इसल िए ऐसा कर रहा हूं क्योंकि मेरे पास उनकी कला खरीदने के लिए पैसे नहीं हैं।", - "download_agreement_3": "मैं पूरी तरह से जागरूक हूं कि मेरा आईपी YouTube पर ब्लॉक हो सकता है और मैं स्पोट्यूब या उसके मालिकों / सहयोगियों को किसी भी दुर्घटना के लिए जिम्मेदार नहीं मानता।", - "decline": "इनकार करें", - "accept": "स्वीकार करें", - "details": "विवरण", - "youtube": "YouTube", - "channel": "चैनल", - "likes": "पसंद", - "dislikes": "अप्रिय", - "views": "दृश्य", - "streamUrl": "स्ट्रीम URL", - "stop": "रोकें", - "sort_newest": "नवीनतम जोड़े गए के अनुसार क्रमबद्ध करें", - "sort_oldest": "सबसे पुराने जोड़े गए के अनुसार क्रमबद्ध करें", - "sleep_timer": "स्लीप टाइमर", - "mins": "{minutes} मिनट", - "hours": "{hours} घंटे", - "hour": "{hours} घंटा", - "custom_hours": "कस्टम घंटे", - "logs": "लॉग", - "developers": "डेवलपर्स", - "not_logged_in": "आप लॉग इन नहीं हैं", - "search_mode": "खोज मोड", - "audio_source": "ऑडियो स्रोत", - "ok": "ठीक है", - "failed_to_encrypt": "एन्क्रिप्ट करने में विफल रहा", - "encryption_failed_warning": "Spotube आपके डेटा को सुरक्षित रूप से स्टोर करने के लिए एन्क्रिप्शन का उपयोग करता है। लेकिन इसमें विफल रहा। इसलिए, यह असुरक्षित स्टोरेज पर फॉलबैक करेगा\nयदि आप Linux का उपयोग कर रहे हैं, तो कृपया सुनिश्चित करें कि आपके पास gnome-keyring, kde-wallet, keepassxc आदि जैसी कोई सीक्रेट-सर्विस इंस्टॉल की गई है", - "querying_info": "जानकारी प्राप्त करना", - "piped_api_down": "पाइप्ड एपीआई डाउन है", - "piped_down_error_instructions": "पाइप्ड इंस्टेंस {pipedInstance} वर्तमान में डाउन है\n\nइंस्टेंस बदलें या 'एपीआई प्रकार' को आधिकृत YouTube एपीआई में बदलें\n\nपरिवर्तन के बाद ऐप को फिर से चालने की सुनिश्चित करें", - "you_are_offline": "आप वर्तमान में ऑफ़लाइन हैं", - "connection_restored": "आपका इंटरनेट कनेक्शन बहाल हो गया है", - "use_system_title_bar": "सिस्टम शीर्षक पट्टी का उपयोग करें", - "update_playlist": "प्लेलिस्ट अपडेट करें", - "update": "अपडेट करें", - "crunching_results": "परिणाम को प्रसंस्कृत किया जा रहा है...", - "search_to_get_results": "परिणाम प्राप्त करने के लिए खोजें", - "use_amoled_mode": "AMOLED मोड का उपयोग करें", - "pitch_dark_theme": "पिच ब्लैक डार्ट थीम", - "normalize_audio": "ऑडियो को सामान्य करें", - "change_cover": "कवर बदलें", - "add_cover": "कवर जोड़ें", - "restore_defaults": "डिफ़ॉल्ट सेटिंग्स को बहाल करें", - "download_music_codec": "संगीत कोडेक डाउनलोड करें", - "streaming_music_codec": "स्ट्रीमिंग संगीत कोडेक", - "login_with_lastfm": "Last.fm से लॉगिन करें", - "connect": "कनेक्ट करें", - "disconnect_lastfm": "Last.fm से डिस्कनेक्ट करें", - "disconnect": "डिस्कनेक्ट करें", - "username": "उपयोगकर्ता नाम", - "password": "पासवर्ड", - "login": "लॉग इन करें", - "login_with_your_lastfm": "अपने Last.fm अकाउंट से लॉगिन करें", - "scrobble_to_lastfm": "Last.fm पर स्क्रॉबल करें", - "go_to_album": "एल्बम पर जाएं", - "discord_rich_presence": "डिस्कॉर्ड रिच प्रेजेंस", - "browse_all": "सभी को ब्राउज़ करें", - "genres": "शैलियाँ", - "explore_genres": "शैलियों का अन्वेषण करें", - "step_3_steps": "\"sp_dc\" कुकी का मूल्य कॉपी करें", - "step_4_steps": "कॉपी किए गए \"sp_dc\" मूल्य को पेस्ट करें", - "friends": "दोस्त", - "no_lyrics_available": "क्षमा करें, इस ट्रैक के लिए गाने नहीं मिल सके", - "sort_duration": "समय के आधार पर क्रमबद्ध करें", - "start_a_radio": "रेडियो शुरू करें", - "how_to_start_radio": "रेडियो कैसे शुरू करना चाहते हैं?", - "replace_queue_question": "क्या आप वर्तमान कतार को बदलना चाहते हैं या इसे जोड़ना चाहते हैं?", - "endless_playback": "अंतहीन प्लेबैक", - "delete_playlist": "प्लेलिस्ट हटाएं", - "delete_playlist_confirmation": "क्या आप वाकई इस प्लेलिस्ट को हटाना चाहते हैं?", - "local_tracks": "स्थानीय ट्रैक्स", - "song_link": "गाने का लिंक", - "skip_this_nonsense": "इस माया को छोड़ें", - "freedom_of_music": "“संगीत की स्वतंत्रता”", - "freedom_of_music_palm": "“हाथ में संगीत की स्वतंत्रता”", - "get_started": "आइए शुरू करें", - "youtube_source_description": "सिफारिश किया गया और सबसे अच्छा काम करता है।", - "piped_source_description": "मुफ्त महसूस कर रहे हैं? YouTube के समान लेकिन काफी अधिक मुफ्त।", - "jiosaavn_source_description": "दक्षिण एशियाई क्षेत्र के लिए सर्वोत्तम।", - "highest_quality": "सर्वोत्तम गुणवत्ता: {quality}", - "select_audio_source": "ऑडियो स्रोत चुनें", - "endless_playback_description": "क्रमबद्ध कतार के अंत में नए गाने स्वचालित रूप से जोड़ें", - "choose_your_region": "अपना क्षेत्र चुनें", - "choose_your_region_description": "यह Spotube को आपके स्थान के लिए सही सामग्री दिखाने में मदद करेगा।", - "choose_your_language": "अपनी भाषा चुनें", - "help_project_grow": "इस परियोजना को बढ़ावा दें", - "help_project_grow_description": "Spotube एक ओपन सोर्स परियोजना है। आप इस परियोजना को योगदान देकर, बग रिपोर्ट करके या नई विशेषताओं का सुझाव देकर इस परियोजना को बढ़ा सकते हैं।", - "contribute_on_github": "GitHub पर योगदान करें", - "donate_on_open_collective": "ओपन कलेक्टिव पर दान करें", - "browse_anonymously": "बिना नाम के ब्राउज़ करें", - "enable_connect": "कनेक्ट सक्षम करें", - "enable_connect_description": "अन्य उपकरणों से Spotube को नियंत्रित करें", - "devices": "उपकरण", - "select": "चयन करें", - "connect_client_alert": "आप {client} द्वारा नियंत्रित हो रहे हैं", - "this_device": "यह उपकरण", - "remote": "रिमोट", - "local_library": "स्थानीय पुस्तकालय", - "add_library_location": "पुस्तकालय में जोड़ें", - "remove_library_location": "पुस्तकालय से हटाएं", - "local_tab": "स्थानीय", - "stats": "आंकड़े", - "and_n_more": "और {count} और", - "recently_played": "हाल ही में खेले गए", - "browse_more": "अधिक ब्राउज़ करें", - "no_title": "कोई शीर्षक नहीं", - "not_playing": "नहीं चल रहा", - "epic_failure": "महान असफलता!", - "added_num_tracks_to_queue": "{tracks_length} ट्रैक्स कतार में जोड़े गए", - "spotube_has_an_update": "Spotube में एक अपडेट है", - "download_now": "अभी डाउनलोड करें", - "nightly_version": "Spotube Nightly {nightlyBuildNum} जारी किया गया है", - "release_version": "Spotube v{version} जारी किया गया है", - "read_the_latest": "नवीनतम पढ़ें", - "release_notes": "रिलीज़ नोट्स", - "pick_color_scheme": "रंग योजना चुनें", - "save": "सहेजें", - "choose_the_device": "उपकरण चुनें:", - "multiple_device_connected": "कई उपकरण जुड़े हुए हैं।\nउस उपकरण को चुनें जिस पर आप यह क्रिया करना चाहते हैं", - "nothing_found": "कुछ भी नहीं मिला", - "the_box_is_empty": "बॉक्स खाली है", - "top_artists": "शीर्ष कलाकार", - "top_albums": "शीर्ष एल्बम", - "this_week": "इस हफ्ते", - "this_month": "इस महीने", - "last_6_months": "पिछले 6 महीने", - "this_year": "इस साल", - "last_2_years": "पिछले 2 साल", - "all_time": "सभी समय", - "powered_by_provider": "{providerName} द्वारा संचालित", - "email": "ईमेल", - "profile_followers": "अनुयायी", - "birthday": "जन्मदिन", - "subscription": "सदस्यता", - "not_born": "अभी पैदा नहीं हुआ", - "hacker": "हैकर", - "profile": "प्रोफ़ाइल", - "no_name": "कोई नाम नहीं", - "edit": "संपादित करें", - "user_profile": "उपयोगकर्ता प्रोफ़ाइल", - "count_plays": "{count} प्ले", - "streaming_fees_hypothetical": "*Spotify की प्रति स्ट्रीम भुगतान के आधार पर\n$0.003 से $0.005 तक गणना की गई है। यह एक काल्पनिक\nगणना है जो उपयोगकर्ता को यह जानकारी देती है कि वे कितना भुगतान\nकरते यदि वे Spotify पर गाने सुनते।", - "count_mins": "{minutes} मिनट", - "summary_minutes": "मिनट", - "summary_listened_to_music": "सुनी गई संगीत", - "summary_songs": "गाने", - "summary_streamed_overall": "कुल स्ट्रीम", - "summary_owed_to_artists": "कलाकारों को देनदार\nइस महीने", - "summary_artists": "कलाकार", - "summary_music_reached_you": "संगीत आपके पास पहुंच गया", - "summary_full_albums": "पूरा एल्बम", - "summary_got_your_love": "आपका प्यार मिला", - "summary_playlists": "प्लेलिस्ट", - "summary_were_on_repeat": "दोहराया गया", - "total_money": "कुल {money}", - "minutes_listened": "सुनिएका मिनेटहरू", - "streamed_songs": "स्ट्रीम गरिएका गीतहरू", - "count_streams": "{count} स्ट्रिम", - "owned_by_you": "तपाईंले स्वामित्व गरेको", - "copied_shareurl_to_clipboard": "{shareUrl} क्लिपबोर्डमा कपी गरियो", - "spotify_hipotetical_calculation": "*यो Spotify को प्रति स्ट्रीम भुगतानको आधारमा\n$0.003 देखि $0.005 को बीचमा गणना गरिएको हो। यो एक काल्पनिक\nगणना हो जसले प्रयोगकर्तालाई देखाउँछ कि उनीहरूले कति\nअर्टिस्टहरूलाई तिनीहरूका गीतहरू Spotify मा सुनेमा\nभुक्तान गर्नुपर्ने थियो।", - "webview_not_found": "वेबव्यू नहीं मिला", - "webview_not_found_description": "आपके डिवाइस पर वेबव्यू रनटाइम इंस्टॉल नहीं है।\nअगर इंस्टॉल है, तो सुनिश्चित करें कि यह environment PATH में है\n\nइंस्टॉल करने के बाद, ऐप को पुनः शुरू करें", - "unsupported_platform": "असमर्थित प्लेटफार्म", - "invidious_instance": "इन्विडियस सर्वर इंस्टेंस", - "invidious_description": "ट्रैक मिलान के लिए इन्विडियस सर्वर इंस्टेंस", - "invidious_warning": "कुछ इंस्टेंस अच्छी तरह से काम नहीं कर सकते। अपने जोखिम पर उपयोग करें", - "invidious_source_description": "पाइप्ड के समान, लेकिन अधिक उपलब्धता के साथ", - "cache_music": "संगीत को कैश करें", - "open": "खोलें", - "cache_folder": "कैश फ़ोल्डर", - "export": "निर्यात करें", - "clear_cache": "कैश साफ़ करें", - "clear_cache_confirmation": "क्या आप कैश साफ़ करना चाहते हैं?", - "export_cache_files": "कैश फ़ाइलें निर्यात करें", - "found_n_files": "{count} फ़ाइलें मिलीं", - "export_cache_confirmation": "क्या आप इन फ़ाइलों को निर्यात करना चाहते हैं", - "exported_n_out_of_m_files": "{filesExported} फ़ाइलें निर्यात की गईं {files} में से", - "playlist": "प्लेलिस्ट", - "no_loop": "कोई लूप नहीं", - "generate": "उत्पन्न करें", - "undo": "पूर्ववत करें", - "download_all": "सभी डाउनलोड करें", - "add_all_to_playlist": "सभी को प्लेलिस्ट में जोड़ें", - "add_all_to_queue": "सभी को कतार में जोड़ें", - "play_all_next": "सभी को अगले खेलने के लिए", - "pause": "रोकें", - "view_all": "सभी देखें", - "no_tracks_added_yet": "लगता है आपने अभी तक कोई ट्रैक नहीं जोड़ा है।", - "no_tracks": "लगता है यहाँ कोई ट्रैक नहीं है।", - "no_tracks_listened_yet": "लगता है आपने अभी तक कुछ नहीं सुना है।", - "not_following_artists": "आप किसी भी कलाकार को फॉलो नहीं कर रहे हैं।", - "no_favorite_albums_yet": "लगता है आपने अभी तक कोई एल्बम अपनी पसंदीदा सूची में नहीं जोड़ा है।", - "no_logs_found": "कोई लॉग नहीं मिला", - "youtube_engine": "YouTube इंजन", - "youtube_engine_not_installed_title": "{engine} स्थापित नहीं है", - "youtube_engine_not_installed_message": "{engine} आपके सिस्टम में स्थापित नहीं है।", - "youtube_engine_set_path": "यह सुनिश्चित करें कि यह PATH वेरिएबल में उपलब्ध हो या\nनीचे {engine} निष्पादन योग्य फ़ाइल का पूर्ण पथ सेट करें।", - "youtube_engine_unix_issue_message": "macOS/Linux/यूनिक्स जैसे OS में, .zshrc/.bashrc/.bash_profile आदि में पथ सेट करना काम नहीं करेगा।\nआपको पथ को शेल कॉन्फ़िगरेशन फ़ाइल में सेट करना होगा।", - "download": "डाउनलोड करें", - "file_not_found": "फाइल नहीं मिली", - "custom": "कस्टम", - "add_custom_url": "कस्टम URL जोड़ें", - "edit_port": "पोर्ट संपादित करें", - "port_helper_msg": "डिफ़ॉल्ट -1 है जो यादृच्छिक संख्या को दर्शाता है। यदि आपने फ़ायरवॉल कॉन्फ़िगर किया है, तो इसे सेट करना अनुशंसित है।", - "connect_request": "{client} को कनेक्ट करने की अनुमति दें?", - "connection_request_denied": "कनेक्शन अस्वीकृत। उपयोगकर्ता ने पहुंच अस्वीकृत कर दी।", - "hipotetical_calculation": "*यह औसत ऑनलाइन संगीत स्ट्रीमिंग प्लेटफ़ॉर्म के प्रति स्ट्रीम भुगतान ($0.003 से $0.005) के आधार पर गणना की गई है। यह एक काल्पनिक गणना है जो उपयोगकर्ता को यह जानकारी देने के लिए है कि यदि वे विभिन्न संगीत स्ट्रीमिंग प्लेटफ़ॉर्म पर अपने गाने सुनते हैं तो उन्होंने कलाकारों को कितना भुगतान किया होगा।", - "an_error_occurred": "एक त्रुटि हुई", - "copy_to_clipboard": "क्लिपबोर्ड पर कॉपी करें", - "view_logs": "लॉग देखें", - "retry": "पुनः प्रयास करें", - "no_default_metadata_provider_selected": "आपने कोई डिफ़ॉल्ट मेटाडेटा प्रदाता सेट नहीं किया है", - "manage_metadata_providers": "मेटाडेटा प्रदाताओं को प्रबंधित करें", - "open_link_in_browser": "ब्राउज़र में लिंक खोलें?", - "do_you_want_to_open_the_following_link": "क्या आप निम्नलिखित लिंक खोलना चाहते हैं", - "unsafe_url_warning": "अविश्वसनीय स्रोतों से लिंक खोलना असुरक्षित हो सकता है। सावधान रहें!\nआप लिंक को अपने क्लिपबोर्ड पर भी कॉपी कर सकते हैं।", - "copy_link": "लिंक कॉपी करें", - "building_your_timeline": "आपकी सुनने की आदतों के आधार पर आपकी टाइमलाइन बनाई जा रही है...", - "official": "आधिकारिक", - "author_name": "लेखक: {author}", - "third_party": "तृतीय-पक्ष", - "plugin_requires_authentication": "प्लगइन को प्रमाणीकरण की आवश्यकता है", - "update_available": "अपडेट उपलब्ध है", - "supports_scrobbling": "स्क्रॉबलिंग का समर्थन करता है", - "plugin_scrobbling_info": "यह प्लगइन आपके सुनने के इतिहास को उत्पन्न करने के लिए आपके संगीत को स्क्रॉबल करता है।", - "default_plugin": "डिफ़ॉल्ट", - "set_default": "डिफ़ॉल्ट सेट करें", - "support": "समर्थन", - "support_plugin_development": "प्लगइन विकास का समर्थन करें", - "can_access_name_api": "- **{name}** API तक पहुंच सकता है", - "do_you_want_to_install_this_plugin": "क्या आप इस प्लगइन को स्थापित करना चाहते हैं?", - "third_party_plugin_warning": "यह प्लगइन एक तृतीय-पक्ष रिपॉजिटरी से है। कृपया सुनिश्चित करें कि आप इसे स्थापित करने से पहले स्रोत पर भरोसा करते हैं।", - "author": "लेखक", - "this_plugin_can_do_following": "यह प्लगइन निम्नलिखित कर सकता है", - "install": "स्थापित करें", - "install_a_metadata_provider": "एक मेटाडेटा प्रदाता स्थापित करें", - "no_tracks_playing": "वर्तमान में कोई ट्रैक नहीं चल रहा है", - "synced_lyrics_not_available": "इस गाने के लिए सिंक्रनाइज़ किए गए बोल उपलब्ध नहीं हैं। कृपया", - "plain_lyrics": "सादे बोल", - "tab_instead": "टैब का उपयोग करें।", - "disclaimer": "अस्वीकरण", - "third_party_plugin_dmca_notice": "स्पॉट्यूब टीम किसी भी \"तृतीय-पक्ष\" प्लगइन के लिए कोई जिम्मेदारी (कानूनी सहित) नहीं लेती है।\nकृपया उन्हें अपने जोखिम पर उपयोग करें। किसी भी बग/समस्या के लिए, कृपया उन्हें प्लगइन रिपॉजिटरी को रिपोर्ट करें।\n\nयदि कोई \"तृतीय-पक्ष\" प्लगइन किसी सेवा/कानूनी इकाई के ToS/DMCA को तोड़ रहा है, तो कृपया \"तृतीय-पक्ष\" प्लगइन लेखक या होस्टिंग प्लेटफ़ॉर्म जैसे GitHub/Codeberg से कार्रवाई करने के लिए कहें। ऊपर सूचीबद्ध (\"तृतीय-पक्ष\" लेबल वाले) सभी सार्वजनिक/समुदाय-द्वारा-रखरखाव किए गए प्लगइन हैं। हम उन्हें क्यूरेट नहीं कर रहे हैं, इसलिए हम उन पर कोई कार्रवाई नहीं कर सकते हैं।\n\n", - "input_does_not_match_format": "इनपुट आवश्यक प्रारूप से मेल नहीं खाता है", - "metadata_provider_plugins": "मेटाडेटा प्रदाता प्लगइन", - "paste_plugin_download_url": "डाउनलोड यूआरएल या गिटहब/कोडबर्ग रेपो यूआरएल या .smplug फ़ाइल का सीधा लिंक पेस्ट करें", - "download_and_install_plugin_from_url": "यूआरएल से प्लगइन डाउनलोड और स्थापित करें", - "failed_to_add_plugin_error": "प्लगइन जोड़ने में विफल: {error}", - "upload_plugin_from_file": "फ़ाइल से प्लगइन अपलोड करें", - "installed": "स्थापित", - "available_plugins": "उपलब्ध प्लगइन", - "configure_your_own_metadata_plugin": "अपनी खुद की प्लेलिस्ट/एल्बम/कलाकार/फ़ीड मेटाडेटा प्रदाता कॉन्फ़िगर करें", - "audio_scrobblers": "ऑडियो स्क्रॉबलर्स", - "scrobbling": "स्क्रॉबलिंग", - "download_music_format": "संगीत डाउनलोड प्रारूप", - "streaming_music_format": "संगीत स्ट्रीमिंग प्रारूप", - "download_music_quality": "संगीत डाउनलोड गुणवत्ता", - "streaming_music_quality": "संगीत स्ट्रीमिंग गुणवत्ता", - "default_metadata_source": "डिफ़ॉल्ट मेटाडेटा स्रोत", - "set_default_metadata_source": "डिफ़ॉल्ट मेटाडेटा स्रोत सेट करें", - "default_audio_source": "डिफ़ॉल्ट ऑडियो स्रोत", - "set_default_audio_source": "डिफ़ॉल्ट ऑडियो स्रोत सेट करें", - "plugins": "प्लगइन्स", - "configure_plugins": "अपने स्वयं के मेटाडेटा प्रदाता और ऑडियो स्रोत प्लगइन्स कॉन्फ़िगर करें", - "source": "स्रोत: ", - "uncompressed": "असंपीड़ित", - "dab_music_source_description": "ऑडियोफाइलों के लिए। उच्च-गुणवत्ता/बिना हानि वाले ऑडियो स्ट्रीम प्रदान करता है। सटीक ISRC आधारित ट्रैक मिलान।" -} \ No newline at end of file diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb deleted file mode 100644 index 3405fd2f..00000000 --- a/lib/l10n/app_id.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "Tamu", - "browse": "Jelajahi", - "search": "Cari", - "library": "Pustaka", - "lyrics": "Lirik", - "settings": "Pengaturan", - "genre_categories_filter": "Urutkan kategori atau genre...", - "genre": "Genre", - "personalized": "Dipersonalisasi", - "featured": "Unggulan", - "new_releases": "Rilis Terbaru", - "songs": "Lagu", - "playing_track": "Memutar {track}", - "queue_clear_alert": "Ini akan menghapus antrian saat ini This will clear the current queue. {track_length} trek akan dihapus\nAnda ingin melanjutkan?", - "load_more": "Lebih Banyak", - "playlists": "Daftar Putar", - "artists": "Artis", - "albums": "Album", - "tracks": "Trek", - "downloads": "Unduhan", - "filter_playlists": "Urutkan daftar putar Anda...", - "liked_tracks": "Lagu Yang Disukai", - "liked_tracks_description": "Semua lagu yang Anda sukai", - "create_playlist": "Buat Daftar Putar", - "create_a_playlist": "Buat daftar putar", - "update_playlist": "Ubah daftar putar", - "create": "Buat", - "cancel": "Batal", - "update": "Ubah", - "playlist_name": "Nama Daftar Putar", - "name_of_playlist": "Nama daftar putar", - "description": "Deskripsi", - "public": "Publik", - "collaborative": "Kolaboratif", - "search_local_tracks": "Cari trek lokal...", - "play": "Putar", - "delete": "Hapus", - "none": "Tidak Ada", - "sort_a_z": "Urutkan berdasarkan A-Z", - "sort_z_a": "Urutkan berdasarkan Z-A", - "sort_artist": "Urutkan berdasarkan Artis", - "sort_album": "Urutkan berdasarkan Album", - "sort_duration": "Urutkan berdasarkan Durasi", - "sort_tracks": "Urutkan trek", - "currently_downloading": "Sedang Mengunduh ({tracks_length})", - "cancel_all": "Batalkan Semua", - "filter_artist": "Urutkan artis...", - "followers": "{followers} Pengikut", - "add_artist_to_blacklist": "Tambah artis ke daftar hitam", - "top_tracks": "Lagu Teratas", - "fans_also_like": "Penggemar juga menyukainya", - "loading": "Memuat...", - "artist": "Artis", - "blacklisted": "Masuk Daftar Hitam", - "following": "Mengikuti", - "follow": "Ikuti", - "artist_url_copied": "URL artis telah disalin", - "added_to_queue": "Menambah trek {tracks} ke antrean", - "filter_albums": "Urutkan album...", - "synced": "Disinkronkan", - "plain": "Normal", - "shuffle": "Acak", - "search_tracks": "Cari trek...", - "released": "Dirilis", - "error": "Kesalahan {error}", - "title": "Judul", - "time": "Waktu", - "more_actions": "Tindakan Lainnya", - "download_count": "Unduhan ({count})", - "add_count_to_playlist": "Menambah ({count}) ke Daftar Putar", - "add_count_to_queue": "Menambah ({count}) ke Antrian", - "play_count_next": "Mainkan ({count}) selanjutnya", - "album": "Album", - "copied_to_clipboard": "{data} telah disalin", - "add_to_following_playlists": "Menambah {track} ke Daftar Putar berikut", - "add": "Tambah", - "added_track_to_queue": "Menambah {track} ke antrian", - "add_to_queue": "Tambah ke antrian", - "track_will_play_next": "{track} akan diputar berikutnya", - "play_next": "Mainkan selanjutnya", - "removed_track_from_queue": "Menghapus {track} dari antrian", - "remove_from_queue": "Hapus dari antrian", - "remove_from_favorites": "Hapus dari favorit", - "save_as_favorite": "Simpan sebagai favorit", - "add_to_playlist": "Tambah ke daftar putar", - "remove_from_playlist": "Hapus dari daftar putar", - "add_to_blacklist": "Tambah ke daftar hitam", - "remove_from_blacklist": "Hapus dari daftar hitam", - "share": "Bagikan", - "mini_player": "Pemutar Mini", - "slide_to_seek": "Geser untuk maju atau mundur", - "shuffle_playlist": "Acak daftar putar", - "unshuffle_playlist": "Batalkan pengacakan daftar putar", - "previous_track": "Lagu sebelumnya", - "next_track": "Lagu berikutnya", - "pause_playback": "Jeda Pemutaran", - "resume_playback": "Lanjutkan Pemutaran", - "loop_track": "Ulangi Pemutaran", - "repeat_playlist": "Ulangi daftar putar", - "queue": "Antrian", - "alternative_track_sources": "Sumber trek alternatif", - "download_track": "Unduh lagu", - "tracks_in_queue": "{tracks} trek dalam antrian", - "clear_all": "Bersihkan semua", - "show_hide_ui_on_hover": "Tampil/Sembunyikan UI saat mengarahkan kursor", - "always_on_top": "Selalu di atas", - "exit_mini_player": "Keluar Pemutar Mini", - "download_location": "Lokasi unduhan", - "account": "Akun", - "login_with_spotify": "Masuk dengan Spotify", - "connect_with_spotify": "Hubungkan dengan Spotify", - "logout": "Keluar", - "logout_of_this_account": "Keluar dari akun", - "language_region": "Bahasa & Wilayah", - "language": "Bahasa", - "system_default": "Bawaan Sistem", - "market_place_region": "Wilayah Pasar", - "recommendation_country": "Negara Rekomendasi", - "appearance": "Tampilan", - "layout_mode": "Mode Tata Letak", - "override_layout_settings": "Ganti pengaturan mode tata letak responsif", - "adaptive": "Adaptif", - "compact": "Ringkas", - "extended": "Diperluas", - "theme": "Tema", - "dark": "Gelap", - "light": "Terang", - "system": "Sistem", - "accent_color": "Warna Aksen", - "sync_album_color": "Sinkronkan warna album", - "sync_album_color_description": "Menggunakan warna dominan sampul album sebagai warna aksen", - "playback": "Pemutaran", - "audio_quality": "Kualitas Suara", - "high": "Tinggi", - "low": "Rendah", - "pre_download_play": "Unduh dan putar", - "pre_download_play_description": "Daripada streaming audio, unduh byte dan mainkan (Direkomendasikan untuk pengguna bandwidth yang lebih tinggi)", - "skip_non_music": "Lewati segmen non-musik (SponsorBlock)", - "blacklist_description": "Lagu dan artis di daftar hitam", - "wait_for_download_to_finish": "Tunggu hingga unduhan saat ini selesai", - "desktop": "Desktop", - "close_behavior": "Tutup Perilaku", - "close": "Tutup", - "minimize_to_tray": "Perkecil ke tray", - "show_tray_icon": "Tampilkan tray ikon sistem", - "about": "Tentang", - "u_love_spotube": "Kami tahu Anda menyukai Spotube", - "check_for_updates": "Periksa pembaruan", - "about_spotube": "Tentang Spotube", - "blacklist": "Daftar Hitam", - "please_sponsor": "Silakan Sponsor/Menyumbang", - "spotube_description": "Spotube, klien Spotify yang ringan, lintas platform, dan gratis untuk semua", - "version": "Versi", - "build_number": "Nomor Pembuatan", - "founder": "Pendiri", - "repository": "Repositori", - "bug_issues": "Bug+Masalah", - "made_with": "Dibuat dengan ❤️ di Bangladesh🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Lisensi", - "add_spotify_credentials": "Tambahkan kredensial Spotify Anda untuk memulai", - "credentials_will_not_be_shared_disclaimer": "Jangan khawatir, kredensial Anda tidak akan dikumpulkan atau dibagikan kepada siapa pun", - "know_how_to_login": "Tidak tahu bagaimana melakukan ini?", - "follow_step_by_step_guide": "Ikuti panduan Langkah demi Langkah", - "spotify_cookie": "Spotify {name} Cookie", - "cookie_name_cookie": "{name} Cookie", - "fill_in_all_fields": "Silakan isi semua kolom", - "submit": "Kirim", - "exit": "Keluar", - "previous": "Sebelumnya", - "next": "Berikutnya", - "done": "Selesai", - "step_1": "Langkah 1", - "first_go_to": "Pertama, Pergi ke", - "login_if_not_logged_in": "dan Masuk/Daftar jika Anda belum masuk", - "step_2": "Langkah 2", - "step_2_steps": "1. Setelah Anda masuk, tekan F12 atau Klik Kanan Mouse > Buka Browser Devtools.\n2. Lalu buka Tab \"Aplikasi\" (Chrome, Edge, Brave, dll.) atau Tab \"Penyimpanan\" (Firefox, Palemoon, dll.)\n3. Buka bagian \"Cookie\" lalu subbagian \"https://accounts.spotify.com\"", - "step_3": "Langkah 3", - "step_3_steps": "Salin nilai Cookie \"sp_dc\" ", - "success_emoji": "Berhasil🥳", - "success_message": "Sekarang Anda telah berhasil Masuk dengan akun Spotify Anda. Kerja bagus, sobat!", - "step_4": "Langkah 4", - "step_4_steps": "Tempel nilai \"sp_dc\" yang disalin", - "something_went_wrong": "Terjadi kesalahan", - "piped_instance": "Piped Server Instance", - "piped_description": "The Piped server instance untuk digunakan sebagai pencocokan trek", - "piped_warning": "Beberapa di antaranya mungkin tidak berfungsi dengan baik. Jadi gunakan dengan risiko Anda sendiri", - "generate_playlist": "Hasilkan Daftar Putar", - "track_exists": "Lagu {track} sudah ada", - "replace_downloaded_tracks": "Ganti semua trek yang diunduh", - "skip_download_tracks": "Lewati pengunduhan semua trek yang diunduh", - "do_you_want_to_replace": "Apakah Anda ingin mengganti track yang ada?", - "replace": "Ganti", - "skip": "Lewati", - "select_up_to_count_type": "Pilih hingga {count} {type}", - "select_genres": "Pilih Genre", - "add_genres": "Tambah Genre", - "country": "Negara", - "number_of_tracks_generate": "Jumlah trek yang akan dihasilkan", - "acousticness": "Akustik", - "danceability": "Menari", - "energy": "Energi", - "instrumentalness": "Instrumentalitas", - "liveness": "Kehidupan", - "loudness": "Kekerasan", - "speechiness": "Berbicara", - "valence": "Valensi", - "popularity": "Popularitas", - "key": "Kunci", - "duration": "Durasi (s)", - "tempo": "Tempo (BPM)", - "mode": "Mode", - "time_signature": "Tanda Tangan Waktu", - "short": "Pendek", - "medium": "Sedang", - "long": "Panjang", - "min": "Minimal", - "max": "Maksimal", - "target": "Target", - "moderate": "Sedang", - "deselect_all": "Batalkan Semua", - "select_all": "Pilih Semua", - "are_you_sure": "Anda yakin?", - "generating_playlist": "Menghasilkan daftar putar khusus Anda...", - "selected_count_tracks": "{count} lagu yang dipilih", - "download_warning": "Jika Anda mengunduh semua Lagu secara massal, Anda jelas membajak Musik & menyebabkan kerusakan pada masyarakat kreatif Musik. Saya harap Anda menyadari hal ini. Selalu berusaha menghormati & mendukung kerja keras Artis", - "download_ip_ban_warning": "BTW, IP Anda bisa diblokir di YouTube karena permintaan unduhan yang berlebihan dari biasanya. Blokir IP berarti Anda tidak dapat menggunakan YouTube (meskipun Anda masuk) setidaknya selama 2-3 bulan dari perangkat IP tersebut. Dan Spotube tidak bertanggung jawab jika hal ini terjadi", - "by_clicking_accept_terms": "Dengan mengklik 'terima' Anda menyetujui ketentuan berikut:", - "download_agreement_1": "Saya tahu saya membajak Musik. Saya buruk", - "download_agreement_2": "Saya akan mendukung Artis di mana pun saya bisa dan saya melakukan ini hanya karena saya tidak punya uang untuk membeli karya seni mereka", - "download_agreement_3": "Saya sepenuhnya menyadari bahwa IP saya dapat diblokir di YouTube & saya tidak menganggap Spotube atau pemilik/kontributornya bertanggung jawab atas kecelakaan apa pun yang disebabkan oleh tindakan saya saat ini", - "decline": "Menolak", - "accept": "Setuju", - "details": "Detail", - "youtube": "YouTube", - "channel": "Channel", - "likes": "Suka", - "dislikes": "Tidak Suka", - "views": "Dilihat", - "streamUrl": "URL Stream", - "stop": "Berhenti", - "sort_newest": "Urutkan yang baru ditambah", - "sort_oldest": "Urutkan yang paling lama ditambah", - "sleep_timer": "Pengatur Waktu Tidur", - "mins": "{minutes} Menit", - "hours": "{hours} Jam", - "hour": "{hours} Jam", - "custom_hours": "Jam Kostum", - "logs": "Log", - "developers": "Pengembang", - "not_logged_in": "Anda belum masuk", - "search_mode": "Mode Pencarian", - "audio_source": "Sumber Suara", - "ok": "OK", - "failed_to_encrypt": "Gagal mengenkripsi", - "encryption_failed_warning": "Spotube menggunakan enkripsi untuk menyimpan data Anda dengan aman. Namun gagal melakukannya. Jadi itu akan kembali ke penyimpanan yang tidak aman\nJika Anda menggunakan linux, pastikan Anda telah menginstal layanan rahasia (gnome-keyring, kde-wallet, keepassxc, dll)", - "querying_info": "Mencari informasi...", - "piped_api_down": "Piped API tidak aktif", - "piped_down_error_instructions": "Piped Instance {pipedInstance} saat ini tidak aktif\n\nUbah instance atau ubah 'jenis API' menjadi API YouTube resmi\n\nPastikan untuk memulai ulang aplikasi setelah perubahan", - "you_are_offline": "Anda sedang offline", - "connection_restored": "Koneksi internet Anda telah pulih", - "use_system_title_bar": "Gunakan bilah judul sistem", - "crunching_results": "Mengolah hasil...", - "search_to_get_results": "Cari untuk mendapatkan hasil", - "use_amoled_mode": "Tema gelap gulita", - "pitch_dark_theme": "Mode AMOLED", - "normalize_audio": "Normalisasi audio", - "change_cover": "Ganti sampul", - "add_cover": "Tambah sampul", - "restore_defaults": "Kembalikan semula", - "download_music_codec": "Unduh codec musik", - "streaming_music_codec": "Streaming codec musik", - "login_with_lastfm": "Masuk dengan Last.fm", - "connect": "Hubungkan", - "disconnect_lastfm": "Memutuskan Last.fm", - "disconnect": "Memutuskan", - "username": "Username", - "password": "Password", - "login": "Masuk", - "login_with_your_lastfm": "Masuk dengan Last.fm Anda", - "scrobble_to_lastfm": "Scrobble ke Last.fm", - "go_to_album": "Pergi ke Album", - "discord_rich_presence": "Discord Rich Presence", - "browse_all": "Lihat Semua", - "genres": "Genre", - "explore_genres": "Jelajahi Genre", - "friends": "Daftar Teman", - "no_lyrics_available": "Maaf, tidak dapat menemukan lirik untuk lagu ini", - "start_a_radio": "Putar Radio", - "how_to_start_radio": "Bagaimana Anda ingin memutar radio?", - "replace_queue_question": "Apakah Anda ingin mengganti antrean saat ini atau menambahkannya?", - "endless_playback": "Pemutaran Tanpa Akhir", - "delete_playlist": "Hapus Daftar Putar", - "delete_playlist_confirmation": "Anda yakin ingin menghapus daftar putar ini?", - "local_tracks": "Trek Lokal", - "song_link": "Tautan Lagu", - "skip_this_nonsense": "Lewati omong kosong ini", - "freedom_of_music": "“Kebebasan Musik”", - "freedom_of_music_palm": "“Kebebasan Musik di telapak tangan Anda”", - "get_started": "Mari kita mulai", - "youtube_source_description": "Direkomendasikan dan berfungsi paling baik.", - "piped_source_description": "Merasa bebas? Sama seperti YouTube tetapi banyak yang gratis.", - "jiosaavn_source_description": "Terbaik untuk wilayah Asia Selatan.", - "highest_quality": "Kualitas Terbaik: {quality}", - "select_audio_source": "Pilih Sumber Suara", - "endless_playback_description": "Tambahkan lagu baru secara otomatis\nke akhir antrean", - "choose_your_region": "Pilih wilayah Anda", - "choose_your_region_description": "Ini akan membantu Spotube menampilkan konten yang tepat\nuntuk lokasi Anda.", - "choose_your_language": "Pilih bahasa Anda", - "help_project_grow": "Bantu proyek ini berkembang", - "help_project_grow_description": "Spotube adalah proyek sumber terbuka. Anda dapat membantu proyek ini berkembang dengan berkontribusi pada proyek, melaporkan bug, atau menyarankan fitur baru.", - "contribute_on_github": "Berkontribusi di GitHub", - "donate_on_open_collective": "Donasi di Open Collective", - "browse_anonymously": "Jelajahi Secara Anonim", - "enable_connect": "Aktifkan Hubungkan", - "enable_connect_description": "Kontrol Spotube dari perangkat lain", - "devices": "Perangkat", - "select": "Pilih", - "connect_client_alert": "Anda dikendalikan oleh {client}", - "this_device": "Perangkat Ini", - "remote": "Remot", - "local_library": "Perpustakaan lokal", - "add_library_location": "Tambahkan ke perpustakaan", - "remove_library_location": "Hapus dari perpustakaan", - "local_tab": "Lokal", - "stats": "Statistik", - "and_n_more": "dan {count} lainnya", - "recently_played": "Baru saja diputar", - "browse_more": "Telusuri lebih banyak", - "no_title": "Tanpa judul", - "not_playing": "Tidak diputar", - "epic_failure": "Kegagalan epik!", - "added_num_tracks_to_queue": "Menambahkan {tracks_length} trek ke antrean", - "spotube_has_an_update": "Spotube memiliki pembaruan", - "download_now": "Unduh sekarang", - "nightly_version": "Spotube Nightly {nightlyBuildNum} telah dirilis", - "release_version": "Spotube v{version} telah dirilis", - "read_the_latest": "Baca yang terbaru ", - "release_notes": "catatan rilis", - "pick_color_scheme": "Pilih skema warna", - "save": "Simpan", - "choose_the_device": "Pilih perangkat:", - "multiple_device_connected": "Beberapa perangkat terhubung.\nPilih perangkat tempat Anda ingin melakukan tindakan ini", - "nothing_found": "Tidak ditemukan apa pun", - "the_box_is_empty": "Kotak kosong", - "top_artists": "Artis Teratas", - "top_albums": "Album Teratas", - "this_week": "Minggu ini", - "this_month": "Bulan ini", - "last_6_months": "6 bulan terakhir", - "this_year": "Tahun ini", - "last_2_years": "2 tahun terakhir", - "all_time": "Sepanjang waktu", - "powered_by_provider": "Didukung oleh {providerName}", - "email": "Email", - "profile_followers": "Pengikut", - "birthday": "Ulang Tahun", - "subscription": "Langganan", - "not_born": "Belum lahir", - "hacker": "Hacker", - "profile": "Profil", - "no_name": "Tanpa nama", - "edit": "Edit", - "user_profile": "Profil pengguna", - "count_plays": "{count} pemutaran", - "streaming_fees_hypothetical": "Biaya streaming (hipotetis)", - "minutes_listened": "Menit didengarkan", - "streamed_songs": "Lagu yang disiarkan", - "count_streams": "{count} streams", - "owned_by_you": "Dimiliki oleh Anda", - "copied_shareurl_to_clipboard": "{shareUrl} disalin ke clipboard", - "spotify_hipotetical_calculation": "*Ini dihitung berdasarkan pembayaran\nper stream Spotify dari $0,003 hingga $0,005.\nIni adalah perhitungan hipotetis untuk memberi\npengguna gambaran tentang berapa banyak\nmereka akan membayar kepada artis jika\nmereka mendengarkan lagu mereka di Spotify.", - "count_mins": "{minutes} menit", - "summary_minutes": "menit", - "summary_listened_to_music": "Mendengarkan musik", - "summary_songs": "lagu", - "summary_streamed_overall": "Disiarkan secara keseluruhan", - "summary_owed_to_artists": "Terhutang kepada artis\nBulan ini", - "summary_artists": "artis", - "summary_music_reached_you": "Musik mencapai Anda", - "summary_full_albums": "album lengkap", - "summary_got_your_love": "Mendapatkan cinta Anda", - "summary_playlists": "daftar putar", - "summary_were_on_repeat": "Sedang diulang", - "total_money": "Total {money}", - "webview_not_found": "Webview tidak ditemukan", - "webview_not_found_description": "Tidak ada runtime Webview yang diinstal di perangkat Anda.\nJika sudah diinstal, pastikan itu ada di environment PATH\n\nSetelah diinstal, restart aplikasi", - "unsupported_platform": "Platform tidak didukung", - "invidious_instance": "Invidious Server Instance", - "invidious_description": "The Invidious server instance to use for track matching", - "invidious_warning": "Some of them might not work well. So use at your own risk", - "invidious_source_description": "Similar to Piped but with higher availability.", - "cache_music": "Cache music", - "open": "Open", - "cache_folder": "Cache folder", - "export": "Export", - "clear_cache": "Clear cache", - "clear_cache_confirmation": "Do you want to clear the cache?", - "export_cache_files": "Export Cached Files", - "found_n_files": "Found {count} files", - "export_cache_confirmation": "Do you want to export these files to", - "exported_n_out_of_m_files": "Exported {filesExported} out of {files} files", - "playlist": "Playlist", - "no_loop": "No loop", - "generate": "Generate", - "undo": "Undo", - "download_all": "Download all", - "add_all_to_playlist": "Add all to playlist", - "add_all_to_queue": "Add all to queue", - "play_all_next": "Play all next", - "pause": "Pause", - "view_all": "View all", - "no_tracks_added_yet": "Looks like you haven't added any tracks yet", - "no_tracks": "Looks like there are no tracks here", - "no_tracks_listened_yet": "Looks like you haven't listened to anything yet", - "not_following_artists": "You're not following any artists", - "no_favorite_albums_yet": "Looks like you haven't added any albums to your favorites yet", - "no_logs_found": "No logs found", - "youtube_engine": "YouTube Engine", - "youtube_engine_not_installed_title": "{engine} is not installed", - "youtube_engine_not_installed_message": "{engine} is not installed in your system.", - "youtube_engine_set_path": "Make sure it's available in the PATH variable or\nset the absolute path to the {engine} executable below", - "youtube_engine_unix_issue_message": "In macOS/Linux/unix like OS's, setting path on .zshrc/.bashrc/.bash_profile etc. won't work.\nYou need to set the path in the shell configuration file", - "download": "Download", - "file_not_found": "File not found", - "custom": "Custom", - "add_custom_url": "Add custom URL", - "edit_port": "Edit port", - "port_helper_msg": "Default adalah -1 yang menunjukkan angka acak. Jika Anda telah mengonfigurasi firewall, disarankan untuk mengatur ini.", - "connect_request": "Izinkan {client} untuk terhubung?", - "connection_request_denied": "Koneksi ditolak. Pengguna menolak akses.", - "hipotetical_calculation": "*Ini dihitung berdasarkan pembayaran rata-rata per streaming dari platform streaming musik online sebesar $0,003 hingga $0,005. Ini adalah perhitungan hipotetis untuk memberikan wawasan kepada pengguna tentang seberapa banyak yang akan mereka bayarkan kepada artis jika mereka mendengarkan lagu mereka di platform streaming musik yang berbeda.", - "an_error_occurred": "Terjadi kesalahan", - "copy_to_clipboard": "Salin ke papan klip", - "view_logs": "Lihat log", - "retry": "Coba lagi", - "no_default_metadata_provider_selected": "Anda belum mengatur penyedia metadata default", - "manage_metadata_providers": "Kelola penyedia metadata", - "open_link_in_browser": "Buka Tautan di Peramban?", - "do_you_want_to_open_the_following_link": "Apakah Anda ingin membuka tautan berikut", - "unsafe_url_warning": "Tidak aman untuk membuka tautan dari sumber yang tidak tepercaya. Berhati-hatilah!\nAnda juga dapat menyalin tautan ke papan klip Anda.", - "copy_link": "Salin Tautan", - "building_your_timeline": "Membangun garis waktu Anda berdasarkan riwayat mendengarkan Anda...", - "official": "Resmi", - "author_name": "Penulis: {author}", - "third_party": "Pihak ketiga", - "plugin_requires_authentication": "Plugin memerlukan otentikasi", - "update_available": "Pembaruan tersedia", - "supports_scrobbling": "Mendukung scrobbling", - "plugin_scrobbling_info": "Plugin ini scrobble musik Anda untuk menghasilkan riwayat mendengarkan Anda.", - "default_plugin": "Bawaan", - "set_default": "Atur sebagai bawaan", - "support": "Dukungan", - "support_plugin_development": "Dukung pengembangan plugin", - "can_access_name_api": "- Dapat mengakses API **{name}**", - "do_you_want_to_install_this_plugin": "Apakah Anda ingin menginstal plugin ini?", - "third_party_plugin_warning": "Plugin ini berasal dari repositori pihak ketiga. Pastikan Anda memercayai sumbernya sebelum menginstal.", - "author": "Penulis", - "this_plugin_can_do_following": "Plugin ini dapat melakukan hal berikut", - "install": "Instal", - "install_a_metadata_provider": "Instal Penyedia Metadata", - "no_tracks_playing": "Tidak ada Lagu yang sedang diputar saat ini", - "synced_lyrics_not_available": "Lirik tersinkronisasi tidak tersedia untuk lagu ini. Silakan gunakan tab", - "plain_lyrics": "Lirik Polos", - "tab_instead": "sebagai gantinya.", - "disclaimer": "Penafian", - "third_party_plugin_dmca_notice": "Tim Spotube tidak bertanggung jawab (termasuk hukum) atas plugin \"Pihak ketiga\" mana pun.\nSilakan gunakan dengan risiko Anda sendiri. Untuk bug/masalah apa pun, silakan laporkan ke repositori plugin.\n\nJika ada plugin \"Pihak ketiga\" yang melanggar ToS/DMCA dari layanan/entitas hukum mana pun, silakan minta penulis plugin \"Pihak ketiga\" atau platform hosting, mis. GitHub/Codeberg, untuk mengambil tindakan. Yang tercantum di atas (berlabel \"Pihak ketiga\") adalah semua plugin publik/yang dikelola oleh komunitas. Kami tidak mengkurasi mereka, jadi kami tidak dapat mengambil tindakan apa pun terhadap mereka.\n\n", - "input_does_not_match_format": "Masukan tidak cocok dengan format yang diperlukan", - "metadata_provider_plugins": "Plugin Penyedia Metadata", - "paste_plugin_download_url": "Tempel url unduhan atau url repo GitHub/Codeberg atau tautan langsung ke file .smplug", - "download_and_install_plugin_from_url": "Unduh dan instal plugin dari url", - "failed_to_add_plugin_error": "Gagal menambahkan plugin: {error}", - "upload_plugin_from_file": "Unggah plugin dari file", - "installed": "Terinstal", - "available_plugins": "Plugin yang tersedia", - "configure_your_own_metadata_plugin": "Konfigurasi penyedia metadata playlist/album/artis/feed Anda sendiri", - "audio_scrobblers": "Scrobblers Audio", - "scrobbling": "Scrobbling", - "download_music_format": "Format unduh musik", - "streaming_music_format": "Format streaming musik", - "download_music_quality": "Kualitas unduh musik", - "streaming_music_quality": "Kualitas streaming musik", - "default_metadata_source": "Sumber metadata default", - "set_default_metadata_source": "Atur sumber metadata default", - "default_audio_source": "Sumber audio default", - "set_default_audio_source": "Atur sumber audio default", - "plugins": "Plugin", - "configure_plugins": "Konfigurasi plugin penyedia metadata dan sumber audio Anda sendiri", - "source": "Sumber: ", - "uncompressed": "Tidak terkompresi", - "dab_music_source_description": "Untuk audiophile. Menyediakan aliran audio berkualitas tinggi/tanpa kehilangan. Pencocokkan trek yang akurat berdasarkan ISRC." -} \ No newline at end of file diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb deleted file mode 100644 index c544dbf3..00000000 --- a/lib/l10n/app_it.arb +++ /dev/null @@ -1,495 +0,0 @@ -{ - "guest": "Ospite", - "browse": "Sfoglia", - "search": "Cerca", - "library": "Libreria", - "lyrics": "Testi", - "settings": "Impostazioni", - "genre_categories_filter": "Filtra categorie e generi...", - "genre": "Genere", - "personalized": "Personalizzato", - "featured": "In evidenza", - "new_releases": "Novità", - "songs": "Canzoni", - "playing_track": "Riproduzione {track}", - "queue_clear_alert": "Questo cancellerà la coda corrente. {track_length} tracce saranno rimosse\nVuoi continuare?", - "load_more": "Carica altro", - "playlists": "Playlist", - "artists": "Artisti", - "albums": "Album", - "tracks": "Tracce", - "downloads": "Downloads", - "filter_playlists": "Filtra le tue playlist...", - "liked_tracks": "Tracce piaciute", - "liked_tracks_description": "Tutte le tracce piaciute", - "create_playlist": "Crea Playlist", - "create_a_playlist": "Crea una playlist", - "update_playlist": "Aggiorna playlist", - "create": "Crea", - "cancel": "Annulla", - "update": "Aggiorna", - "playlist_name": "Nome Playlist", - "name_of_playlist": "Nome della playlist", - "description": "Descrizione", - "public": "Pubblico", - "collaborative": "Collaborativo", - "search_local_tracks": "Cerca tracce locali...", - "play": "Riproduci", - "delete": "Cancella", - "none": "Nessuno", - "sort_a_z": "Ordina dalla A-Z", - "sort_z_a": "Ordina dalla Z-A", - "sort_artist": "Ordina per Artista", - "sort_album": "Ordina per Album", - "sort_tracks": "Ordina tracce", - "currently_downloading": "Attualmente in Download ({tracks_length})", - "cancel_all": "Annulla Tutto", - "filter_artist": "Filtra artisti...", - "followers": "{followers} Seguaci", - "add_artist_to_blacklist": "Aggiungi artista alla lista nera", - "top_tracks": "Tracce Top", - "fans_also_like": "Ai fan piace anche", - "loading": "Caricamento...", - "artist": "Artista", - "blacklisted": "In lista nera", - "following": "Seguendo", - "follow": "Segui", - "artist_url_copied": "URL artista copiato negli appunti", - "added_to_queue": "Aggiunto {tracks} tracce alla coda", - "filter_albums": "Filtra album...", - "synced": "Sincronizzato", - "plain": "Semplice", - "shuffle": "Casuale", - "search_tracks": "Cerca tracce...", - "released": "Rilasciato", - "error": "Errore {error}", - "title": "Titolo", - "time": "Durata", - "more_actions": "Più azioni", - "download_count": "Scaricato ({count})", - "add_count_to_playlist": "Aggiungi ({count}) alla playlist", - "add_count_to_queue": "Aggiungi ({count}) alla Coda", - "play_count_next": "Riproduci ({count}) prossime", - "album": "Album", - "copied_to_clipboard": "Copiato {data} negli appunti", - "add_to_following_playlists": "Aggiungi {track} nelle seguenti Playlist", - "add": "Aggiungi", - "added_track_to_queue": "Aggiunto {track} alla coda", - "add_to_queue": "Aggiungi alla coda", - "track_will_play_next": "in seguito sarà riprodotta {track}", - "play_next": "Riproduci prossimo", - "removed_track_from_queue": "Rimosso {track} dalla coda", - "remove_from_queue": "Rimuovi dalla coda", - "remove_from_favorites": "Rimuovi dai preferiti", - "save_as_favorite": "Salva come preferito", - "add_to_playlist": "Aggiungi alla playlist", - "remove_from_playlist": "Rimuovi dalla playlist", - "add_to_blacklist": "Aggiungi alla blacklist", - "remove_from_blacklist": "Rimuovi dalla blacklist", - "share": "Condividi", - "mini_player": "Mini Riproduttore", - "slide_to_seek": "Scorri per cercare avanti o indietro", - "shuffle_playlist": "Playlist casuale", - "unshuffle_playlist": "Ordina playlist", - "previous_track": "Traccia precedente", - "next_track": "Traccia successiva", - "pause_playback": "Pausa Playback", - "resume_playback": "Riprendi Playback", - "loop_track": "Cicla traccia", - "repeat_playlist": "Ripeti playlist", - "queue": "Coda", - "alternative_track_sources": "Sorgenti traccia alternative", - "download_track": "Scarica traccia", - "tracks_in_queue": "{tracks} tracce in coda", - "clear_all": "Cancella tutto", - "show_hide_ui_on_hover": "Mostra/Nascondi UI al passaggio", - "always_on_top": "Sempre in cima", - "exit_mini_player": "Esci da Mini player", - "download_location": "Cartella di scarico", - "account": "Account", - "login_with_spotify": "Login con il tuo account Spotify", - "connect_with_spotify": "Connetti con Spotify", - "logout": "Esci", - "logout_of_this_account": "Esci da questo account", - "language_region": "Lingua & Regione", - "language": "Lingua", - "system_default": "Default sistema", - "market_place_region": "Regione del mercato", - "recommendation_country": "Paese Raccomandato", - "appearance": "Aspetto", - "layout_mode": "Modalità Layout", - "override_layout_settings": "Sovrascrivi le impostazioni del layout responsivo", - "adaptive": "Adattiva", - "compact": "Compatta", - "extended": "Estesa", - "theme": "Tema", - "dark": "Scuro", - "light": "Chiaro", - "system": "Sistema", - "accent_color": "Colore accento", - "sync_album_color": "Syncronizza colore album", - "sync_album_color_description": "Usa il colore dominante della copertina dell'album come colore accento", - "playback": "Riproduzione", - "audio_quality": "Qualità Audio", - "high": "Alta", - "low": "Bassa", - "pre_download_play": "Pre-scarica e riproduci", - "pre_download_play_description": "Anzi che effettuare lo stream dell'audio, scarica invece i byte e li riproduce (raccomandato per gli utenti con banda più alta)", - "skip_non_music": "Salta i segmenti non di musica (SponsorBlock)", - "blacklist_description": "Tracce e artisti in blacklist", - "wait_for_download_to_finish": "Prego attendere che lo scaricamento corrente finisca", - "desktop": "Desktop", - "close_behavior": "Comportamento Chiusura", - "close": "Chiudi", - "minimize_to_tray": "Minimizza in tray", - "show_tray_icon": "Mostra icona in tray di sistema", - "about": "A proposito di", - "u_love_spotube": "Sappiamo che ami Spotube", - "check_for_updates": "Controlla aggiornamenti", - "about_spotube": "A proposito di Spotube", - "blacklist": "Blacklist", - "please_sponsor": "Per favore sponsorizza/dona", - "spotube_description": "Spotube, un client spotify gratis per tutti, multipiattaforma e leggero", - "version": "Versione", - "build_number": "Numero Build", - "founder": "Fondatore", - "repository": "Repository", - "bug_issues": "Bug+Problemi", - "made_with": "Fatto con ❤️ in Bangladesh🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Licenza", - "add_spotify_credentials": "Aggiungi le tue credenziali spotify per iniziare", - "credentials_will_not_be_shared_disclaimer": "Non ti preoccupare, le tue credenziali non saranno inviate o condivise con nessuno", - "know_how_to_login": "Non sai come farlo?", - "follow_step_by_step_guide": "Segui la guida passo-passo", - "spotify_cookie": "Cookie Spotify {name}", - "cookie_name_cookie": "Cookie {name}", - "fill_in_all_fields": "Inserire tutti i campi", - "submit": "Invia", - "exit": "Esci", - "previous": "Precedente", - "next": "Prossimo", - "done": "Finito", - "step_1": "Passo 1", - "first_go_to": "Prim, vai a", - "login_if_not_logged_in": "ed effettua il login o iscrizione se non sei già acceduto", - "step_2": "Passo 2", - "step_2_steps": "1. Quando sei acceduto premi F12 o premi il tasto destro del Mouse > Ispeziona per aprire gli strumenti di sviluppo del browser.\n2. Vai quindi nel tab \"Applicazione\" (Chrome, Edge, Brave etc..) o tab \"Archiviazione\" (Firefox, Palemoon etc..)\n3. Vai nella sezione \"Cookies\" quindi nella sezione \"https://accounts.spotify.com\"", - "step_3": "Passo 3", - "success_emoji": "Successo🥳", - "success_message": "Ora hai correttamente effettuato il login al tuo account Spotify. Bel lavoro, amico!", - "step_4": "Passo 4", - "something_went_wrong": "Qualcosa è andato storto", - "piped_instance": "Istanza Server Piped", - "piped_description": "L'istanza server Piped da usare per il match della tracccia", - "piped_warning": "Alcune di queste non funzioneranno benen. Usa quindi a tuo rischio", - "generate_playlist": "Genera Playlist", - "track_exists": "La traccia {track} esiste già", - "replace_downloaded_tracks": "Sostituisci tutte le tracce scaricate", - "skip_download_tracks": "Salta lo scaricamento di tutte le tracce scaricate", - "do_you_want_to_replace": "Vuoi sovrascrivere la traccia esistente??", - "replace": "Sovrascrivi", - "skip": "Salta", - "select_up_to_count_type": "Seleziona fino a {count} {type}", - "select_genres": "Seleziona Generi", - "add_genres": "Aggiungi Generi", - "country": "Paese", - "number_of_tracks_generate": "Nnumero di tracce da generare", - "acousticness": "Acustica", - "danceability": "Ballabilità", - "energy": "Energia", - "instrumentalness": "Strumentalità", - "liveness": "Vitalità", - "loudness": "Sonorità", - "speechiness": "Loquacità", - "valence": "Valenza", - "popularity": "Popolarità", - "key": "Chiave", - "duration": "Durata (s)", - "tempo": "Tempo (BPM)", - "mode": "Modo", - "time_signature": "Indicazione di tempo", - "short": "Corta", - "medium": "Media", - "long": "Lunga", - "min": "Min", - "max": "Max", - "target": "Obiettivo", - "moderate": "Moderato", - "deselect_all": "Deseleziona Tutto", - "select_all": "Seleziona Tutto", - "are_you_sure": "Sei certo?", - "generating_playlist": "Generazione delle tue playlist custom...", - "selected_count_tracks": "{count} tracce selezionate", - "download_warning": "Se scarichi tutte le Tracce in massa stai chiaramente piratando Musica e causando un danno alla società creativa della Musica. Spero che tu sia cosciente di questo. Cerca di rispettare e supportare sempre il duro lavoro degli Artisti", - "download_ip_ban_warning": "A proposito, il tuo IP può essere bloccato da YouTube per il numero di richieste di download eccessive rispetto la norma. Il blocco IP significa che non puoi usare YoutTube (anche hai effettuato l'accesso) per almeno 2-3 mesi dal dispositivo con questo IP. Spotube non ha responsabilità se questo dovesse accadere", - "by_clicking_accept_terms": "Cliccando su 'accetta' concordi con i seguenti termini:", - "download_agreement_1": "So che sto piratando Musica. Sono cattivo", - "download_agreement_2": "Supporterò l'Artista come potrò e sto facendo questo solo perchè non ho denaro per acquistare il suo prodotto dell'ingegno", - "download_agreement_3": "Sono completamente cosciente che il mio IP può essere bloccato da YouTube & non riterrò responsabili Spotube o i suoi autori/contributori per ogni inconveniente causato dalla mia azione corrente", - "decline": "Declino", - "accept": "Accetto", - "details": "Dettagli", - "youtube": "YouTube", - "channel": "Canale", - "likes": "Mi Piace", - "dislikes": "Non Mi Piace", - "views": "Viste", - "streamUrl": "URL dello streaming", - "stop": "Stop", - "sort_newest": "Ordina per nuovi aggiunti", - "sort_oldest": "Ordina per aggiunta più vecchia", - "sleep_timer": "Timer Dormire", - "mins": "{minutes} Minuti", - "hours": "{hours} Ore", - "hour": "{hours} Ora", - "custom_hours": "Orari Personalizzati", - "logs": "Log", - "developers": "Sviluppatori", - "not_logged_in": "Non hai effettuato l'accesso", - "search_mode": "Modalità Ricerca", - "youtube_api_type": "Tipo API", - "ok": "Ok", - "failed_to_encrypt": "Criptazione fallita", - "encryption_failed_warning": "Spotube usa la criptazione per memorizzare in modo sicuro i dati. Ma ha fallito a farlo. Passerà quindi in ripiego alla memorizzazione non siscura\nSe stai usando Linux assicurati di avere un servizio di segretezza installato (gnome-keyring, kde-wallet, keepassxc etc)", - "querying_info": "Richiesta informazioni...", - "piped_api_down": "Le Piped API non funzionano", - "piped_down_error_instructions": "L'istanza di Piped {pipedInstance} è correntemente offline\n\nCambia istanza o cambia 'Tipo API' alle API ufficiali YouTube\n\nAssicurati di riavviare l'app dopo il cambio", - "you_are_offline": "Sei correntemente offline", - "connection_restored": "Connessione ad internet ripristinata", - "use_system_title_bar": "Usa la barra del titolo di sistema", - "crunching_results": "Elaborazione risultati...", - "search_to_get_results": "Cerca per ottenere risultati", - "use_amoled_mode": "Usa modalità AMOLED", - "pitch_dark_theme": "Tema nero profondo", - "normalize_audio": "Normalizza audio", - "change_cover": "Cambia copertina", - "add_cover": "Aggiungi copertina", - "restore_defaults": "Ripristina default", - "download_music_codec": "Codec musicale scaricamento", - "streaming_music_codec": "Codec musicale streaming", - "login_with_lastfm": "Accesso a Last.fm", - "connect": "Connetti", - "disconnect_lastfm": "Disconnetti Last.fm", - "disconnect": "Disconnetti", - "username": "Nome utente", - "password": "Password", - "login": "Accesso", - "login_with_your_lastfm": "Accedi con il tuo account Last.fm", - "scrobble_to_lastfm": "Invia a Last.fm", - "audio_source": "Fonte audio", - "go_to_album": "Vai all'album", - "discord_rich_presence": "Presenza ricca di Discord", - "browse_all": "Esplora tutto", - "genres": "Generi", - "explore_genres": "Esplora generi", - "step_3_steps": "Copia il valore del cookie \"sp_dc\"", - "step_4_steps": "Incolla il valore copiato di \"sp_dc\"", - "friends": "Amici", - "no_lyrics_available": "Spiacente, impossibile trovare il testo di questa traccia", - "sort_duration": "Ordina per Durata", - "start_a_radio": "Avvia una Radio", - "how_to_start_radio": "Come vuoi avviare la radio?", - "replace_queue_question": "Vuoi sostituire la coda attuale o aggiungerla?", - "endless_playback": "Riproduzione Infinita", - "delete_playlist": "Elimina Playlist", - "delete_playlist_confirmation": "Sei sicuro di voler eliminare questa playlist?", - "local_tracks": "Tracce Locali", - "song_link": "Link della Canzone", - "skip_this_nonsense": "Salta questa sciocchezza", - "freedom_of_music": "“Libertà della Musica”", - "freedom_of_music_palm": "“Libertà della Musica nel palmo della tua mano”", - "get_started": "Cominciamo", - "youtube_source_description": "Consigliato e funziona meglio.", - "piped_source_description": "Ti senti libero? Come YouTube ma molto più gratuito.", - "jiosaavn_source_description": "Il migliore per la regione dell'Asia meridionale.", - "highest_quality": "Massima Qualità: {quality}", - "select_audio_source": "Seleziona Sorgente Audio", - "endless_playback_description": "Aggiungi automaticamente nuove canzoni alla fine della coda", - "choose_your_region": "Scegli la tua regione", - "choose_your_region_description": "Questo aiuterà Spotube a mostrarti il contenuto giusto per la tua posizione.", - "choose_your_language": "Scegli la tua lingua", - "help_project_grow": "Aiuta questo progetto a crescere", - "help_project_grow_description": "Spotube è un progetto open-source. Puoi aiutare questo progetto a crescere contribuendo al progetto, segnalando bug o suggerendo nuove funzionalità.", - "contribute_on_github": "Contribuisci su GitHub", - "donate_on_open_collective": "Dona su Open Collective", - "browse_anonymously": "Naviga in modo anonimo", - "enable_connect": "Abilita connessione", - "enable_connect_description": "Controlla Spotube da altri dispositivi", - "devices": "Dispositivi", - "select": "Seleziona", - "connect_client_alert": "Stai venendo controllato da {client}", - "this_device": "Questo dispositivo", - "remote": "Remoto", - "local_library": "Biblioteca locale", - "add_library_location": "Aggiungi alla biblioteca", - "remove_library_location": "Rimuovi dalla biblioteca", - "local_tab": "Locale", - "stats": "Statistiche", - "and_n_more": "e {count} in più", - "recently_played": "Riprodotti di recente", - "browse_more": "Esplora di più", - "no_title": "Nessun titolo", - "not_playing": "Non in riproduzione", - "epic_failure": "Fallimento epico!", - "added_num_tracks_to_queue": "Aggiunti {tracks_length} brani alla coda", - "spotube_has_an_update": "Spotube ha un aggiornamento", - "download_now": "Scarica ora", - "nightly_version": "Spotube Nightly {nightlyBuildNum} è stato rilasciato", - "release_version": "Spotube v{version} è stato rilasciato", - "read_the_latest": "Leggi l'ultimo ", - "release_notes": "note di rilascio", - "pick_color_scheme": "Scegli uno schema di colori", - "save": "Salva", - "choose_the_device": "Scegli il dispositivo:", - "multiple_device_connected": "Sono collegati più dispositivi.\nScegli il dispositivo su cui vuoi che venga eseguita questa azione", - "nothing_found": "Nessun risultato", - "the_box_is_empty": "La scatola è vuota", - "top_artists": "Artisti Top", - "top_albums": "Album Top", - "this_week": "Questa settimana", - "this_month": "Questo mese", - "last_6_months": "Ultimi 6 mesi", - "this_year": "Quest'anno", - "last_2_years": "Ultimi 2 anni", - "all_time": "Di tutti i tempi", - "powered_by_provider": "Sostenuto da {providerName}", - "email": "Email", - "profile_followers": "Follower", - "birthday": "Compleanno", - "subscription": "Abbonamento", - "not_born": "Non nato", - "hacker": "Hacker", - "profile": "Profilo", - "no_name": "Nessun nome", - "edit": "Modifica", - "user_profile": "Profilo utente", - "count_plays": "{count} riproduzioni", - "streaming_fees_hypothetical": "Spese di streaming (ipotetico)", - "minutes_listened": "Minuti ascoltati", - "streamed_songs": "Brani in streaming", - "count_streams": "{count} streaming", - "owned_by_you": "Di tua proprietà", - "copied_shareurl_to_clipboard": "Copiato {shareUrl} negli appunti", - "spotify_hipotetical_calculation": "*Questo è calcolato in base al pagamento per streaming di Spotify\nche va da $0.003 a $0.005. Questo è un calcolo ipotetico\nper dare all'utente un'idea di quanto avrebbe pagato agli artisti se avesse ascoltato\ne loro canzoni su Spotify.", - "count_mins": "{minutes} min", - "summary_minutes": "minuti", - "summary_listened_to_music": "Musica ascoltata", - "summary_songs": "brani", - "summary_streamed_overall": "Streaming complessivo", - "summary_owed_to_artists": "Dovuto agli artisti\nquesto mese", - "summary_artists": "dell'artista", - "summary_music_reached_you": "La musica ti ha raggiunto", - "summary_full_albums": "album completi", - "summary_got_your_love": "Ha ricevuto il tuo amore", - "summary_playlists": "playlist", - "summary_were_on_repeat": "Erano in ripetizione", - "total_money": "Totale {money}", - "webview_not_found": "Webview non trovato", - "webview_not_found_description": "Nessun runtime Webview installato nel tuo dispositivo.\nSe è installato, assicurati che sia nel environment PATH\n\nDopo l'installazione, riavvia l'app", - "unsupported_platform": "Piattaforma non supportata", - "invidious_instance": "Istanza del server Invidious", - "invidious_description": "L'istanza del server Invidious da utilizzare per il matching delle tracce", - "invidious_warning": "Alcuni potrebbero non funzionare bene. Usali a tuo rischio", - "invidious_source_description": "Simile a Piped ma con maggiore disponibilità.", - "cache_music": "Cache musica", - "open": "Apri", - "cache_folder": "Cartella cache", - "export": "Esporta", - "clear_cache": "Cancella cache", - "clear_cache_confirmation": "Vuoi cancellare la cache?", - "export_cache_files": "Esporta file nella cache", - "found_n_files": "Trovati {count} file", - "export_cache_confirmation": "Vuoi esportare questi file su", - "exported_n_out_of_m_files": "Esportati {filesExported} su {files} file", - "playlist": "Playlist", - "no_loop": "Nessun ciclo", - "generate": "Genera", - "undo": "Annulla", - "download_all": "Scarica tutto", - "add_all_to_playlist": "Aggiungi tutto alla playlist", - "add_all_to_queue": "Aggiungi tutto alla coda", - "play_all_next": "Riproduci tutto dopo", - "pause": "Pausa", - "view_all": "Vedi tutto", - "no_tracks_added_yet": "Sembra che non hai ancora aggiunto nessun brano", - "no_tracks": "Sembra che non ci siano brani qui", - "no_tracks_listened_yet": "Sembra che non hai ascoltato nulla ancora", - "not_following_artists": "Non stai seguendo alcun artista", - "no_favorite_albums_yet": "Sembra che non hai ancora aggiunto album ai tuoi preferiti", - "no_logs_found": "Nessun registro trovato", - "youtube_engine": "Motore YouTube", - "youtube_engine_not_installed_title": "{engine} non è installato", - "youtube_engine_not_installed_message": "{engine} non è installato nel tuo sistema.", - "youtube_engine_set_path": "Assicurati che sia disponibile nella variabile PATH o\nimposta il percorso assoluto all'eseguibile {engine} qui sotto", - "youtube_engine_unix_issue_message": "In macOS/Linux/os simili a unix, impostare il percorso su .zshrc/.bashrc/.bash_profile ecc. non funzionerà.\nDevi impostare il percorso nel file di configurazione della shell", - "download": "Scarica", - "file_not_found": "File non trovato", - "custom": "Personalizzato", - "add_custom_url": "Aggiungi URL personalizzato", - "edit_port": "Modifica porta", - "port_helper_msg": "Il valore predefinito è -1, che indica un numero casuale. Se hai configurato un firewall, si consiglia di impostarlo.", - "connect_request": "Consentire a {client} di connettersi?", - "connection_request_denied": "Connessione negata. L'utente ha negato l'accesso.", - "hipotetical_calculation": "*Questo è calcolato in base al pagamento medio per stream delle piattaforme di streaming musicale online, che va da $0.003 a $0.005. Si tratta di un calcolo ipotetico per dare all'utente un'idea di quanto avrebbe pagato agli artisti se avesse ascoltato la loro canzone su diverse piattaforme di streaming musicale.", - "an_error_occurred": "Si è verificato un errore", - "copy_to_clipboard": "Copia negli appunti", - "view_logs": "Visualizza log", - "retry": "Riprova", - "no_default_metadata_provider_selected": "Non hai impostato alcun provider di metadati predefinito", - "manage_metadata_providers": "Gestisci provider di metadati", - "open_link_in_browser": "Aprire il link nel browser?", - "do_you_want_to_open_the_following_link": "Vuoi aprire il seguente link", - "unsafe_url_warning": "Potrebbe essere pericoloso aprire link da fonti non attendibili. Sii cauto!\nPuoi anche copiare il link negli appunti.", - "copy_link": "Copia link", - "building_your_timeline": "Creazione della tua cronologia in base ai tuoi ascolti...", - "official": "Ufficiale", - "author_name": "Autore: {author}", - "third_party": "Terze parti", - "plugin_requires_authentication": "Il plugin richiede l'autenticazione", - "update_available": "Aggiornamento disponibile", - "supports_scrobbling": "Supporta lo scrobbling", - "plugin_scrobbling_info": "Questo plugin scrobbla la tua musica per generare la tua cronologia di ascolti.", - "default_plugin": "Predefinito", - "set_default": "Imposta come predefinito", - "support": "Supporto", - "support_plugin_development": "Sostieni lo sviluppo del plugin", - "can_access_name_api": "- Può accedere all'API **{name}**", - "do_you_want_to_install_this_plugin": "Vuoi installare questo plugin?", - "third_party_plugin_warning": "Questo plugin proviene da un repository di terze parti. Assicurati di fidarti della fonte prima di installarlo.", - "author": "Autore", - "this_plugin_can_do_following": "Questo plugin può fare quanto segue", - "install": "Installa", - "install_a_metadata_provider": "Installa un provider di metadati", - "no_tracks_playing": "Nessun brano in riproduzione al momento", - "synced_lyrics_not_available": "Testi sincronizzati non disponibili per questa canzone. Si prega di utilizzare la scheda", - "plain_lyrics": "Testi semplici", - "tab_instead": "invece.", - "disclaimer": "Disclaimer", - "third_party_plugin_dmca_notice": "Il team di Spotube non si assume alcuna responsabilità (anche legale) per i plugin di \"terze parti\".\nUsali a tuo rischio e pericolo. Per eventuali bug/problemi, segnalali al repository del plugin.\n\nSe un plugin di \"terze parti\" sta violando i ToS/DMCA di un servizio/entità legale, per favore chiedi all'autore del plugin \"terzo\" o alla piattaforma di hosting, ad esempio GitHub/Codeberg, di agire. Quelli elencati sopra (etichettati come \"terze parti\") sono tutti plugin pubblici/mantenuti dalla comunità. Non li curiamo, quindi non possiamo intraprendere alcuna azione su di essi.\n\n", - "input_does_not_match_format": "L'input non corrisponde al formato richiesto", - "metadata_provider_plugins": "Plugin del provider di metadati", - "paste_plugin_download_url": "Incolla l'URL di download o l'URL del repository GitHub/Codeberg o il link diretto al file .smplug", - "download_and_install_plugin_from_url": "Scarica e installa il plugin da URL", - "failed_to_add_plugin_error": "Impossibile aggiungere il plugin: {error}", - "upload_plugin_from_file": "Carica plugin da file", - "installed": "Installato", - "available_plugins": "Plugin disponibili", - "configure_your_own_metadata_plugin": "Configura il tuo provider di metadati per playlist/album/artista/feed", - "audio_scrobblers": "Scrobbler audio", - "scrobbling": "Scrobbling", - "download_music_format": "Formato download musica", - "streaming_music_format": "Formato streaming musica", - "download_music_quality": "Qualità download musica", - "streaming_music_quality": "Qualità streaming musica", - "default_metadata_source": "Fonte metadati predefinita", - "set_default_metadata_source": "Imposta fonte metadati predefinita", - "default_audio_source": "Fonte audio predefinita", - "set_default_audio_source": "Imposta fonte audio predefinita", - "plugins": "Plugin", - "configure_plugins": "Configura i tuoi plugin per fornitore metadati e fonte audio", - "source": "Fonte: ", - "uncompressed": "Non compresso", - "dab_music_source_description": "Per audiophile. Fornisce flussi audio di alta qualità/senza perdita. Abbinamento traccia accurato basato su ISRC." -} \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb deleted file mode 100644 index 991f56be..00000000 --- a/lib/l10n/app_ja.arb +++ /dev/null @@ -1,493 +0,0 @@ -{ - "guest": "ゲスト", - "browse": "閲覧", - "search": "検索", - "library": "ライブラリ", - "lyrics": "歌詞", - "settings": "設定", - "genre_categories_filter": "カテゴリーやジャンルを絞り込み...", - "genre": "ジャンル", - "personalized": "あなたにおすすめ", - "featured": "注目", - "new_releases": "新着", - "songs": "曲", - "playing_track": "{track} を再生", - "queue_clear_alert": "現在のキューを消去します。{track_length} 曲を消去します。\n続行しますか?", - "load_more": "もっと読み込む", - "playlists": "再生リスト", - "artists": "アーティスト", - "albums": "アルバム", - "tracks": "曲", - "downloads": "ダウンロード", - "filter_playlists": "あなたの再生リストを絞り込み...", - "liked_tracks": "いいねした曲", - "liked_tracks_description": "いいねしたすべての曲", - "playlist": "再生リスト", - "create_a_playlist": "再生リストの作成", - "create": "作成", - "cancel": "キャンセル", - "playlist_name": "再生リスト名", - "name_of_playlist": "再生リストの名前", - "description": "説明", - "public": "公開", - "collaborative": "コラボ", - "search_local_tracks": "端末内の曲を検索...", - "play": "再生", - "delete": "削除", - "none": "なし", - "sort_a_z": "A-Z 順に並び替え", - "sort_z_a": "Z-A 順に並び替え", - "sort_artist": "アーティスト順に並び替え", - "sort_album": "アルバム順に並び替え", - "sort_duration": "長さ順に並べ替え", - "sort_tracks": "曲の並び替え", - "currently_downloading": "ダウンロード中 ({tracks_length}) 曲", - "cancel_all": "すべてキャンセル", - "filter_artist": "アーティストを絞り込み...", - "followers": "{followers} フォロワー", - "add_artist_to_blacklist": "このアーティストをブラックリストに追加", - "top_tracks": "人気の曲", - "fans_also_like": "ファンの間で人気", - "loading": "読み込み中...", - "artist": "アーティスト", - "blacklisted": "ブラックリスト", - "following": "フォロー中", - "follow": "フォローする", - "artist_url_copied": "アーティストの URL をクリップボードにコピーしました", - "added_to_queue": "{tracks} をキューに追加しました", - "filter_albums": "アルバムを絞り込み...", - "synced": "同期する", - "plain": "そのまま", - "shuffle": "シャッフル", - "search_tracks": "曲を検索...", - "released": "リリース日", - "error": "エラー {error}", - "title": "タイトル", - "time": "長さ", - "more_actions": "ほかの操作", - "download_count": "ダウンロード ({count}) 曲", - "add_count_to_playlist": "再生リストに ({count}) 曲を追加", - "add_count_to_queue": "キューに ({count}) 曲を追加", - "play_count_next": "次に ({count}) 曲を再生", - "album": "アルバム", - "copied_to_clipboard": "{data} をクリップボードにコピーしました", - "add_to_following_playlists": "{track} をこの再生リストに追加", - "add": "追加", - "added_track_to_queue": "キューに {track} を追加しました", - "add_to_queue": "キューに追加", - "track_will_play_next": "{track} を次に再生", - "play_next": "次に再生", - "removed_track_from_queue": "キューから {track} を除去しました", - "remove_from_queue": "キューから除去", - "remove_from_favorites": "お気に入りから除去", - "save_as_favorite": "お気に入りに保存", - "add_to_playlist": "再生リストに追加", - "remove_from_playlist": "再生リストから除去", - "add_to_blacklist": "ブラックリストに追加", - "remove_from_blacklist": "ブラックリストから除去", - "share": "共有", - "mini_player": "ミニプレイヤー", - "slide_to_seek": "前後にスライドしてシーク", - "shuffle_playlist": "再生リストをシャッフル", - "unshuffle_playlist": "再生リストのシャッフル解除", - "previous_track": "前の曲", - "next_track": "次の曲", - "pause_playback": "再生を停止", - "resume_playback": "再生を再開", - "loop_track": "曲をループ", - "no_loop": "ループなし", - "repeat_playlist": "再生リストをリピート", - "queue": "再生キュー", - "alternative_track_sources": "この曲の別の音源を選ぶ", - "download_track": "曲のダウンロード", - "tracks_in_queue": "{tracks}曲の再生キュー", - "clear_all": "すべて消去l", - "show_hide_ui_on_hover": "マウスを乗せてUIを表示/隠す", - "always_on_top": "常に手前に表示", - "exit_mini_player": "ミニプレイヤーを終了", - "download_location": "ダウンロード先", - "account": "アカウント", - "login_with_spotify": "Spotify アカウントでログイン", - "connect_with_spotify": "Spotify に接続", - "logout": "ログアウト", - "logout_of_this_account": "このアカウントからログアウト", - "language_region": "言語 & 地域", - "language": "言語", - "system_default": "システムの既定値", - "market_place_region": "音楽市場の地域", - "recommendation_country": "おすすめの国", - "appearance": "外観", - "layout_mode": "レイアウトの種類", - "override_layout_settings": "レスポンシブなレイアウトの種類の設定を上書きする", - "adaptive": "適応的", - "compact": "コンパクト", - "extended": "幅広", - "theme": "テーマ", - "dark": "ダーク", - "light": "ライト", - "system": "システムに従う", - "accent_color": "アクセントカラー", - "sync_album_color": "アルバムの色に合わせる", - "sync_album_color_description": "アルバムアートの主張色をアクセントカラーとして使用", - "playback": "再生", - "audio_quality": "音声品質", - "high": "高", - "low": "低", - "pre_download_play": "事前ダウンロードと再生", - "pre_download_play_description": "音声をストリーミングする代わりに、データをバイト単位でダウンロードして再生 (回線速度が早いユーザーにおすすめ)", - "skip_non_music": "音楽でない部分をスキップ (SponsorBlock)", - "blacklist_description": "曲とアーティストのブラックリスト", - "wait_for_download_to_finish": "現在のダウンロードが完了するまでお待ちください", - "desktop": "デスクトップ", - "close_behavior": "閉じた時の動作", - "close": "閉じる", - "minimize_to_tray": "トレイに最小化", - "show_tray_icon": "システムトレイにアイコンを表示", - "about": "このアプリについて", - "u_love_spotube": "Spotube が好きだと知っていますよ", - "check_for_updates": "アップデートの確認", - "about_spotube": "Spotube について", - "blacklist": "ブラックリスト", - "please_sponsor": "出資/寄付もお待ちします", - "spotube_description": "Spotube は、軽量でクロスプラットフォームな、すべて無料の spotify クライアント", - "version": "バージョン", - "build_number": "ビルド番号", - "founder": "創始者", - "repository": "リポジトリ", - "bug_issues": "バグや問題", - "made_with": "❤️ を込めてバングラディシュ🇧🇩で開発", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "ライセンス", - "add_spotify_credentials": "Spotify のログイン情報を追加してはじめましょう", - "credentials_will_not_be_shared_disclaimer": "心配ありません。個人情報を収集したり、共有されることはありません", - "know_how_to_login": "やり方が分からないですか?", - "follow_step_by_step_guide": "やり方の説明を見る", - "spotify_cookie": "Spotify {name} Cookies", - "cookie_name_cookie": "{name} Cookies", - "fill_in_all_fields": "すべての欄に入力してください", - "submit": "送信", - "exit": "終了", - "previous": "前へ", - "next": "次へ", - "done": "完了", - "step_1": "ステップ 1", - "first_go_to": "最初にここを開き", - "login_if_not_logged_in": "、ログインしてないならログインまたは登録します", - "step_2": "ステップ 2", - "step_2_steps": "1. ログインしたら、F12を押すか、マウス右クリック > 調査(検証)でブラウザの開発者ツール (devtools) を開きます。\n2. アプリケーション (Application) タブ (Chrome, Edge, Brave など) またはストレージタブ (Firefox, Palemoon など)\n3. Cookies 欄を選択し、https://accounts.spotify.com の枝を選びます", - "step_3": "ステップ 3", - "step_3_steps": "\"sp_dc\" Cookieの値をコピー", - "success_emoji": "成功🥳", - "success_message": "アカウントへのログインに成功しました。よくできました!", - "step_4": "ステップ 4", - "step_4_steps": "コピーした\"sp_dc\"の値を貼り付け", - "something_went_wrong": "何か誤りがあります", - "piped_instance": "Piped サーバーのインスタンス", - "piped_description": "曲の一致に使う Piped サーバーのインスタンス", - "piped_warning": "それらの一部ではうまく動作しないこともあります。自己責任で使用してください", - "invidious_instance": "Invidiousサーバーインスタンス", - "invidious_description": "曲の一致に使用するInvidiousサーバーインスタンス", - "invidious_warning": "一部はうまく機能しない可能性があります。自己責任で使用してください", - "generate_playlist": "再生リストの生成", - "track_exists": "曲 {track} は既に存在します", - "replace_downloaded_tracks": "すべてのダウンロード済みの曲を置換", - "skip_download_tracks": "すべてのダウンロード済みの曲をスキップ", - "do_you_want_to_replace": "既存の曲と置換しますか?", - "replace": "置換する", - "skip": "スキップ", - "select_up_to_count_type": "{type}を最大{count} 個まで選択", - "select_genres": "ジャンルを選択", - "add_genres": "ジャンルを追加", - "country": "国", - "number_of_tracks_generate": "生成する曲数", - "acousticness": "アコースティック感", - "danceability": "ダンス感", - "energy": "エネルギー", - "instrumentalness": "インストゥルメンタル", - "liveness": "ライブ感", - "loudness": "ラウドネス", - "speechiness": "会話感", - "valence": "多幸性", - "popularity": "人気度", - "key": "キー", - "duration": "長さ (秒)", - "tempo": "テンポ (BPM)", - "mode": "長調", - "time_signature": "拍子記号", - "short": "短", - "medium": "中", - "long": "長", - "min": "最小", - "max": "最大", - "target": "目標", - "moderate": "中", - "deselect_all": "すべて選択解除", - "select_all": "すべて選択", - "are_you_sure": "よろしいですか?", - "generating_playlist": "カスタムの再生リストを生成中...", - "selected_count_tracks": "{count} 曲が選ばれました", - "download_warning": "全曲の一括ダウンロードは明らかに音楽への海賊行為であり、音楽を生み出す共同体に損害を与えるでしょう。気づいてほしい。アーティストの多大な努力に敬意を払い、支援するようにしてください", - "download_ip_ban_warning": "また、通常よりも過剰なダウンロード要求があれば、YouTubeはあなたのIPをブロックします。つまりそのIPの端末からは、少なくとも2-3か月の間、(ログインしても)YouTubeを利用できなくなりす。そうなっても Spotube は一切の責任を負いません", - "by_clicking_accept_terms": "「同意する」のクリックにより、以下への同意となります:", - "download_agreement_1": "ええ、音楽への海賊行為だ。私はよくない", - "download_agreement_2": "芸術作品を買うお金がないのでそうするしかないが、アーティストをできる限り支援する", - "download_agreement_3": "私のIPがYouTubeにブロックされることがあると完全に把握した。私のこの行動により起きたどんな事故も、Spotube やその所有者/貢献者に責任はありません。", - "decline": "同意しない", - "accept": "同意する", - "details": "詳細", - "youtube": "YouTube", - "channel": "チャンネル", - "likes": "高評価", - "dislikes": "低評価", - "views": "視聴回数", - "streamUrl": "動画の URL", - "stop": "中止", - "sort_newest": "追加日の新しい順に並び替え", - "sort_oldest": "追加日の古い順に並び替え", - "sleep_timer": "スリープタイマー", - "mins": "{minutes} 分", - "hours": "{hours} 時間", - "hour": "{hours} 時間", - "custom_hours": "時間を指定", - "logs": "ログ", - "developers": "開発", - "not_logged_in": "ログインしていません", - "search_mode": "検索モード", - "audio_source": "音声の提供元", - "ok": "OK", - "failed_to_encrypt": "暗号化に失敗しました", - "encryption_failed_warning": "SpoTubeはデータを安全に保存するために暗号化を用いますが、暗号化に失敗しました。このため、安全でない保存領域への保存に切り替えます\nOSがLinuxなら、gnome-keyring、kde-wallet、keepassxcなどの管理ツールがインストールされていることを確認してください", - "querying_info": "情報を取得中...", - "piped_api_down": "Piped APIがダウンしています", - "piped_down_error_instructions": "Pipedインスタンス {pipedInstance} は現在ダウンしています\n\nインスタンスを変更するか、「APIの種類」を公式のYouTube APIに変更してください\n\n変更後にアプリを再起動してください", - "you_are_offline": "現在、オフラインです", - "connection_restored": "インターネット接続が復旧しました", - "use_system_title_bar": "システムのタイトルバーを使う", - "update_playlist": "再生リストを更新", - "update": "更新", - "local_library": "端末内ライブラリ", - "add_library_location": "ライブラリに追加", - "remove_library_location": "ライブラリから削除", - "crunching_results": "結果を処理中...", - "search_to_get_results": "結果を取得するために検索", - "use_amoled_mode": "AMOLEDモードを使用", - "pitch_dark_theme": "ピッチブラック ダークテーマ", - "normalize_audio": "音声を正規化", - "change_cover": "カバーを変更", - "add_cover": "カバーを追加", - "restore_defaults": "設定を初期化", - "download_music_codec": "ダウンロード用の音声コーデック", - "streaming_music_codec": "ストリーミング用の音声コーデック", - "login_with_lastfm": "Last.fmでログイン", - "connect": "接続", - "disconnect_lastfm": "Last.fmから切断", - "disconnect": "切断", - "username": "ユーザー名", - "password": "パスワード", - "login": "ログイン", - "login_with_your_lastfm": "Last.fmアカウントでログイン", - "scrobble_to_lastfm": "Last.fmにスクロブルする", - "go_to_album": "アルバムに移動", - "discord_rich_presence": "Discord リッチプレゼンス", - "browse_all": "すべてを閲覧", - "genres": "ジャンル", - "explore_genres": "ジャンルを探索", - "friends": "友達", - "no_lyrics_available": "すみません、この曲の歌詞が見つかりません", - "start_a_radio": "ラジオを開始", - "how_to_start_radio": "ラジオをどのように開始しますか?", - "replace_queue_question": "現在のキューを置き換えるか、追加しますか?", - "endless_playback": "エンドレス再生", - "delete_playlist": "再生リストを削除", - "delete_playlist_confirmation": "この再生リストを削除しますか?", - "local_tracks": "端末内の曲", - "local_tab": "端末内", - "song_link": "曲のリンク", - "skip_this_nonsense": "こんなことはスキップ", - "freedom_of_music": "“音楽の自由”", - "freedom_of_music_palm": "“音楽の自由を思いのままに”", - "get_started": "さあ始めましょう", - "youtube_source_description": "推奨され、最適に機能します。", - "piped_source_description": "自由を感じる?YouTubeと同じだけど、はるかに自由です。", - "jiosaavn_source_description": "南アジア地域では最適です。", - "invidious_source_description": "Pipedに似ていますが、より利用性があります。", - "highest_quality": "最高品質:{quality}", - "select_audio_source": "音声の提供元を選択", - "endless_playback_description": "キューの最後に新しい曲を自動で追加", - "choose_your_region": "地域を選択", - "choose_your_region_description": "Spotubeがあなたの地域に適したコンテンツを表示します。", - "choose_your_language": "言語を選択してください", - "help_project_grow": "プロジェクトの成長を支援する", - "help_project_grow_description": "SpoTubeはオープンソースプロジェクトです。貢献したり、バグ報告したり、新機能を提案することで、プロジェクトの成長に貢献できます。", - "contribute_on_github": "GitHubで貢献", - "donate_on_open_collective": "Open Collectiveで寄付", - "browse_anonymously": "匿名で閲覧する", - "enable_connect": "接続する", - "enable_connect_description": "他の端末からSpotubeを制御する", - "devices": "機器", - "select": "選択", - "connect_client_alert": "{client} から操作されています", - "this_device": "この端末", - "remote": "リモート", - "stats": "統計", - "and_n_more": "さらに {count} 項目", - "recently_played": "最近聴いた曲", - "browse_more": "もっと表示", - "no_title": "タイトルなし", - "not_playing": "再生なし", - "epic_failure": "壮大なエラー!", - "added_num_tracks_to_queue": "{tracks_length} 曲をキューに追加しました", - "spotube_has_an_update": "Spotube の最新版あり", - "download_now": "今すぐダウンロード", - "nightly_version": "Spotube Nightly {nightlyBuildNum} がリリースされました", - "release_version": "Spotube v{version} がリリースされました", - "read_the_latest": "最新の ", - "release_notes": "更新情報を読む", - "pick_color_scheme": "カラーテーマを選択", - "save": "保存", - "choose_the_device": "端末を選択:", - "multiple_device_connected": "複数の端末が接続されています。\nこの操作を実行する端末を選択", - "nothing_found": "何も見つかりませんでした", - "the_box_is_empty": "ボックスは空です", - "top_artists": "トップアーティスト", - "top_albums": "トップアルバム", - "this_week": "今週", - "this_month": "今月", - "last_6_months": "過去6か月", - "this_year": "今年", - "last_2_years": "過去2年間", - "all_time": "全期間", - "powered_by_provider": "{providerName} 提供", - "email": "メール", - "profile_followers": "フォロワー", - "birthday": "誕生日", - "subscription": "登録", - "not_born": "未出生", - "hacker": "ハッカー", - "profile": "プロフィール", - "no_name": "名前なし", - "edit": "編集", - "user_profile": "ユーザープロフィール", - "count_plays": "{count} 回再生", - "streaming_fees_hypothetical": "ストリーミング料金 (概算)", - "minutes_listened": "視聴時間", - "streamed_songs": "ストリーミングされた曲", - "count_streams": "{count} 回のストリーム", - "owned_by_you": "あなたが所有", - "copied_shareurl_to_clipboard": "{shareUrl} をクリップボードにコピーしました", - "spotify_hipotetical_calculation": "*これは、Spotifyのストリームあたり\n$0.003 から $0.005 として計算されています。\n概算であり、Spotify で曲を聴いていたら、アーティストに\nどれくらい支払われたかを示すものです。", - "count_mins": "{minutes} 分", - "summary_minutes": "分", - "summary_listened_to_music": "音楽を聴いた", - "summary_songs": "曲", - "summary_streamed_overall": "まるごと聴いた", - "summary_owed_to_artists": "今月アーティストに払う\nべき額", - "summary_artists": "アーティスト", - "summary_music_reached_you": "の音楽が届いた", - "summary_full_albums": "フルアルバム", - "summary_got_your_love": "があなたの愛を受け取った", - "summary_playlists": "再生リスト", - "summary_were_on_repeat": "をリピートしました", - "total_money": "計 {money}", - "webview_not_found": "Webviewが見つかりません", - "webview_not_found_description": "端末にWebviewランタイムがインストールされていません。\nインストールされている場合は、環境変数のパスにあるか確認してください\n\nインストール後、アプリを再起動してください", - "unsupported_platform": "未対応のプラットフォーム", - "cache_music": "音楽をキャッシュ", - "open": "開く", - "cache_folder": "キャッシュフォルダー", - "export": "エクスポート", - "clear_cache": "キャッシュをクリア", - "clear_cache_confirmation": "キャッシュをクリアしますか?", - "export_cache_files": "キャッシュされたファイルをエクスポート", - "found_n_files": "{count}ファイルが見つかりました", - "export_cache_confirmation": "これらのファイルをエクスポートしますか", - "exported_n_out_of_m_files": "{filesExported} / {files}ファイルがエクスポートされました", - "generate": "生成", - "undo": "元に戻す", - "download_all": "すべてダウンロード", - "add_all_to_playlist": "すべて再生リストに追加", - "add_all_to_queue": "すべてキューに追加", - "play_all_next": "すべてを次に再生", - "pause": "一時停止", - "view_all": "すべて表示", - "no_tracks_added_yet": "まだ曲を追加していないようです", - "no_tracks": "ここには曲がないようです", - "no_tracks_listened_yet": "まだ何も聞いていないようです", - "not_following_artists": "アーティストをフォローしていません", - "no_favorite_albums_yet": "まだお気に入りのアルバムを追加していないようです", - "no_logs_found": "ログなし", - "youtube_engine": "YouTubeエンジン", - "youtube_engine_not_installed_title": "{engine}はインストールされていません", - "youtube_engine_not_installed_message": "{engine}はシステムにインストールされていません。", - "youtube_engine_set_path": "PATH変数に設定されていることを確認するか\n{engine}実行ファイルの絶対パスを下記に設定してください", - "youtube_engine_unix_issue_message": "macOS/Linux/Unix系OSでは、.zshrc/.bashrc/.bash_profileなどでパスを設定しても動作しません。\nシェルの設定ファイルにパスを設定する必要があります", - "download": "ダウンロード", - "file_not_found": "ファイルが見つかりません", - "custom": "独自", - "add_custom_url": "独自にURLを追加", - "edit_port": "ポートを編集", - "port_helper_msg": "初期設定は-1で、ランダムな番号を示します。ファイアウォールを設定している場合に設定することを推奨します。", - "connect_request": "{client}の接続を許可しますか?", - "connection_request_denied": "接続が拒否されました。ユーザーがアクセスを拒否しました。", - "hipotetical_calculation": "*これは、オンライン音楽ストリーミングプラットフォームの1ストリームあたりの平均支払い額である$0.003〜$0.005に基づいて計算されています。これは、ユーザーが異なる音楽ストリーミングプラットフォームで曲を聴いた場合に、アーティストにどれだけ支払ったかを把握するための仮説的な計算です。", - "an_error_occurred": "エラーが発生しました", - "copy_to_clipboard": "クリップボードにコピー", - "view_logs": "ログを表示", - "retry": "再試行", - "no_default_metadata_provider_selected": "デフォルトのメタデータプロバイダーが設定されていません", - "manage_metadata_providers": "メタデータプロバイダーを管理", - "open_link_in_browser": "リンクをブラウザで開きますか?", - "do_you_want_to_open_the_following_link": "次のリンクを開きますか", - "unsafe_url_warning": "信頼できないソースからのリンクを開くのは安全ではない場合があります。注意してください!\nリンクをクリップボードにコピーすることもできます。", - "copy_link": "リンクをコピー", - "building_your_timeline": "あなたの視聴履歴に基づいてタイムラインを作成しています...", - "official": "公式", - "author_name": "作者: {author}", - "third_party": "サードパーティ", - "plugin_requires_authentication": "プラグインには認証が必要です", - "update_available": "アップデートが利用可能です", - "supports_scrobbling": "scrobblingに対応", - "plugin_scrobbling_info": "このプラグインは、あなたの音楽をscrobbleして視聴履歴を生成します。", - "default_plugin": "デフォルト", - "set_default": "デフォルトに設定", - "support": "サポート", - "support_plugin_development": "プラグイン開発をサポート", - "can_access_name_api": "- **{name}** APIにアクセスできます", - "do_you_want_to_install_this_plugin": "このプラグインをインストールしますか?", - "third_party_plugin_warning": "このプラグインはサードパーティのリポジトリからのものです。インストールする前にソースを信頼できるか確認してください。", - "author": "作者", - "this_plugin_can_do_following": "このプラグインは以下のことができます", - "install": "インストール", - "install_a_metadata_provider": "メタデータプロバイダーをインストール", - "no_tracks_playing": "現在再生中のトラックはありません", - "synced_lyrics_not_available": "この曲の同期歌詞は利用できません。代わりに", - "plain_lyrics": "シンプルな歌詞", - "tab_instead": "タブを使用してください。", - "disclaimer": "免責事項", - "third_party_plugin_dmca_notice": "Spotubeチームは、いかなる「サードパーティ」プラグインについても責任(法的責任を含む)を負いません。\nご自身の責任でご使用ください。バグや問題については、プラグインリポジトリに報告してください。\n\n「サードパーティ」プラグインが何らかのサービス/法人のToS/DMCAを侵害している場合、その「サードパーティ」プラグインの作者またはホスティングプラットフォーム(例:GitHub/Codeberg)に措置を講じるよう依頼してください。上記に記載されている(「サードパーティ」とラベル付けされた)ものはすべて、パブリック/コミュニティによって維持されているプラグインです。私たちはそれらをキュレーションしていないため、それらに対して措置を講じることはできません。\n\n", - "input_does_not_match_format": "入力が必須フォーマットと一致しません", - "metadata_provider_plugins": "メタデータプロバイダープラグイン", - "paste_plugin_download_url": "ダウンロードURL、GitHub/CodebergリポジトリURL、または.smplugファイルへの直接リンクを貼り付けます", - "download_and_install_plugin_from_url": "URLからプラグインをダウンロードしてインストール", - "failed_to_add_plugin_error": "プラグインの追加に失敗しました: {error}", - "upload_plugin_from_file": "ファイルからプラグインをアップロード", - "installed": "インストール済み", - "available_plugins": "利用可能なプラグイン", - "configure_your_own_metadata_plugin": "独自のプレイリスト/アルバム/アーティスト/フィードのメタデータプロバイダーを構成", - "audio_scrobblers": "オーディオスクロッブラー", - "scrobbling": "Scrobbling", - "download_music_format": "音楽ダウンロード形式", - "streaming_music_format": "音楽ストリーミング形式", - "download_music_quality": "音楽ダウンロード品質", - "streaming_music_quality": "音楽ストリーミング品質", - "default_metadata_source": "デフォルトメタデータソース", - "set_default_metadata_source": "デフォルトメタデータソースを設定", - "default_audio_source": "デフォルトオーディオソース", - "set_default_audio_source": "デフォルトオーディオソースを設定", - "plugins": "プラグイン", - "configure_plugins": "独自のメタデータプロバイダーとオーディオソースプラグインを設定", - "source": "ソース: ", - "uncompressed": "非圧縮", - "dab_music_source_description": "オーディオファイル向け。高品質/ロスレスオーディオストリームを提供。正確なISRCベースのトラックマッチング。" -} \ No newline at end of file diff --git a/lib/l10n/app_ka.arb b/lib/l10n/app_ka.arb deleted file mode 100644 index 6a0cb06c..00000000 --- a/lib/l10n/app_ka.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "სტუმარი", - "browse": "ნახვა", - "search": "ძებნა", - "library": "ბიბლიოთეკა", - "lyrics": "ტექსტები", - "settings": "კონფიგურაციები", - "genre_categories_filter": "კატეგორიების ან ჟანრების ფილტრი...", - "genre": "ჟანრი", - "personalized": "პეერსონალიზებული", - "featured": "გამორჩეული", - "new_releases": "ახალი გამოცემები", - "songs": "სიმღერები", - "playing_track": "უკრავს {track}", - "queue_clear_alert": "ეს გაასუფთავებს მიმდინარე რიგს. {track_length} ტრეკი წაიშლება\nᲒინდა გააგრძელო?", - "load_more": "მეტის ჩატვირთვა", - "playlists": "ფლეილისტები", - "artists": "არტისტები", - "albums": "ალბომები", - "tracks": "ტრეკები", - "downloads": "ჩამოტვირთვები", - "filter_playlists": "ფლეილისტების გაფილტვრა...", - "liked_tracks": "მოწონებული ტრეკები", - "liked_tracks_description": "ყველა შენი მოწონებული ტრეკი", - "create_playlist": "ფლეილისტის შექმნა", - "create_a_playlist": "ფლეილისტის შექმნა", - "update_playlist": "ფლეილისტის განახლება", - "create": "შექმნა", - "cancel": "გაუქმება", - "update": "განახლება", - "playlist_name": "ფლეილისტის სახელი", - "name_of_playlist": "ფლეილისტის სახელი", - "description": "აღწერა", - "public": "საჯარო", - "collaborative": "კოლაბორაციული", - "search_local_tracks": "ლოცალური ტრეკების ძებნა...", - "play": "დაკვრა", - "delete": "წაშლა", - "none": "არცერთი", - "sort_a_z": "დალაგება A-Z-ს მიხედვით", - "sort_z_a": "დალაგება Z-A-ს მიხედვით", - "sort_artist": "დალაგება არტისტის მიხედვით", - "sort_album": "დალაგება ალბომის მიხედვით", - "sort_duration": "დალაგება ხანგრძლივობის მიხედვით", - "sort_tracks": "ტრეკების დალაგება", - "currently_downloading": "მიმდინარეობს ჩამოტვირთვა ({tracks_length})", - "cancel_all": "ყველას გაუქმება", - "filter_artist": "არტისტების ფილტრი...", - "followers": "{followers} ფოლოვერები", - "add_artist_to_blacklist": "არტისტის შავ სიაში დამატება", - "top_tracks": "ტოპ ტრეკები", - "fans_also_like": "ფანებს ასევე მოსწონთ", - "loading": "იტვირთება...", - "artist": "არტისტი", - "blacklisted": "შავ სიაში მყოფი", - "following": "ფოლოვინგი", - "follow": "დაფოლოვება", - "artist_url_copied": "არტისტის ლინკი დაკოპირებულია", - "added_to_queue": "{tracks} ტრეკი დაემატა რიგში", - "filter_albums": "ალბომების გაფილტვრა...", - "synced": "სინქრონიზებული", - "plain": "Plain", - "shuffle": "რიგის არევა", - "search_tracks": "ტრეკების ძებნა...", - "released": "გამოშვებული", - "error": "შეცდომა {error}", - "title": "სათაური", - "time": "დრო", - "more_actions": "მეტი მოქმედებები", - "download_count": "გადმოწერა ({count})", - "add_count_to_playlist": "ფლეილისტში ({count})-ის დამატება", - "add_count_to_queue": "რიგში ({count})-ის დამატება", - "play_count_next": "შემდეგი ({count})-ის დაკვრა", - "album": "ალბომი", - "copied_to_clipboard": "{data} დაკოპირებულია", - "add_to_following_playlists": "დაამატე {track} ამ ფლეილისტებში", - "add": "დამატება", - "added_track_to_queue": "რიგში დაემატა {track}", - "add_to_queue": "რიგში დამატება", - "track_will_play_next": "{track} დაუკრავს შემდეგს", - "play_next": "შემდეგის დაკვრა", - "removed_track_from_queue": "რიგიდან წაიშალა {track}", - "remove_from_queue": "რიგიდან წაშლა", - "remove_from_favorites": "ფავორიტებიდან წაშლა", - "save_as_favorite": "ფავორიტებში დამატება", - "add_to_playlist": "ფლეილისტში დამატება", - "remove_from_playlist": "ფლეილისტიდან წაშლა", - "add_to_blacklist": "შავ სიაში დამატება", - "remove_from_blacklist": "შავი სიიდან წაშლა", - "share": "გაზიარება", - "mini_player": "მინი დამკვრელი", - "slide_to_seek": "გადახვევისთვის გაასრიალეთ წინ ან უკან", - "shuffle_playlist": "ფლეილისტის არევა", - "unshuffle_playlist": "ფლეილისტის დალაგება", - "previous_track": "წინა ტრეკი", - "next_track": "შემდეგი ტრეკი", - "pause_playback": "დაკვრის გაჩერება", - "resume_playback": "დაკვრის გაგრძელება", - "loop_track": "ტრეკის ლუპზე დაკვრა", - "repeat_playlist": "ფლეილისტის გამეორება", - "queue": "რიგი", - "alternative_track_sources": "ალტერნატიული ტრეკების წყაროები", - "download_track": "გადმოწერე ტრეკი", - "tracks_in_queue": "{tracks} ტრეკი რიგში", - "clear_all": "ყველას წაშლა", - "show_hide_ui_on_hover": "UI-ის ჩვენება/დამალვა ჰოვერზე", - "always_on_top": "ტოველთვის ზემოდან", - "exit_mini_player": "მინი დამკვრელიდან გამოსვლა", - "download_location": "ჩამოტვირთვის მდებარეობა", - "account": "ანგარიში", - "login_with_spotify": "შედით თქვენი Spotify ანგარიშით", - "connect_with_spotify": "დაუკავშირდით Spotify-ს", - "logout": "გასვლა", - "logout_of_this_account": "ანგარიშიდან გასვლა", - "language_region": "ენა და რეგიონი", - "language": "ენა", - "system_default": "სისტემის ნაგულისხმევი", - "market_place_region": "მარკეტფლეისის რეგიონი", - "recommendation_country": "რეკომენდირებული ქვეყანა", - "appearance": "გარეგნობა", - "layout_mode": "განლაგების რეჟიმი", - "override_layout_settings": "რესფონსივ განლაგების რეჟიმის კონფიგურაციაზე გადაწერა", - "adaptive": "ადაპტირებული", - "compact": "კომპაქტური", - "extended": "გაფართოებული", - "theme": "თემა", - "dark": "ბნელი", - "light": "ღია", - "system": "სისტემის", - "accent_color": "აქცენტის ფერი", - "sync_album_color": "ალბომის ფერის სინქრონიზაცია", - "sync_album_color_description": "დომინანტური ალბომის ფერის აქცენტის ფერად გამოყენება", - "playback": "დაკვრა", - "audio_quality": "აუდიოს ხარისხი", - "high": "მაღალი", - "low": "დაბალი", - "pre_download_play": "წინასწარ ჩამოტვირთვა და დაკვრა", - "pre_download_play_description": "აუდიოს სტრიმინგის ნაცვლად, ბაიტების ჩამოტვირთვა და დაკვრა (რეკომენდებულია უფრო მაღალი გამტარუნარიანობის მომხმარებლებისთვის)", - "skip_non_music": "არა მუსიკალური ნაწილის გამოტოვება (სპონსორის ბლოკი)", - "blacklist_description": "შავ სიაში მყოფი არტისტები და ტრეკები", - "wait_for_download_to_finish": "გთხოვთ, დაელოდოთ მიმდინარე ჩამოტვირთვის დასრულებას", - "desktop": "დესკტოპი", - "close_behavior": "დახურვის ქცევა", - "close": "დახურვა", - "minimize_to_tray": "მინიმიზაცია", - "show_tray_icon": "სისტემის აიკონის ჩვენება", - "about": "ჩვენს შესახებ", - "u_love_spotube": "We know you love Spotube", - "check_for_updates": "განახლებების შემოწმება", - "about_spotube": "Spotube-ს შესახებ", - "blacklist": "შავი სია", - "please_sponsor": "გთხოვთ დაგვასპონსოროთ", - "spotube_description": "Spotube, a lightweight, cross-platform, free-for-all spotify client", - "version": "ვერსია", - "build_number": "Build Number", - "founder": "დამფუძნებელი", - "repository": "რეპოზიტორია", - "bug_issues": "Bug+Issues", - "made_with": "Made with ❤️ in Bangladesh🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "ლიცენზია", - "add_spotify_credentials": "დასაწყებად დაამატეთ თქვენი Spotify მონაცემები", - "credentials_will_not_be_shared_disclaimer": "არ ინერვიულოთ, თქვენი მონაცემები არ იქნება შეგროვებული ან გაზიარებული ვინმესთან", - "know_how_to_login": "არ იცით როგორ გააკეთოთ ეს?", - "follow_step_by_step_guide": "მიჰყევით ნაბიჯ-ნაბიჯ სახელმძღვანელოს", - "spotify_cookie": "Spotify {name} ქუქი", - "cookie_name_cookie": "{name} ქუქი", - "fill_in_all_fields": "გთხოვთ შეავსოთ ყველა ველი", - "submit": "გაგზავნა", - "exit": "გამოსვლა", - "previous": "წინა", - "next": "შემდეგი", - "done": "მზადაა", - "step_1": "ნაბიჯი 1", - "first_go_to": "პირველი, გადადით", - "login_if_not_logged_in": "და შესვლა/რეგისტრაცია, თუ არ ხართ შესული", - "step_2": "ნაბიჯი 2", - "step_2_steps": "1. როცა შეხვალთ, დააჭირეთ F12-ს ან მაუსის მარჯვენა ღილაკს > Inspect to Open the Browser devtools.\n2. შემდეგ გახსენით \"Application\" განყოფილება (Chrome, Edge, Brave etc..) ან \"Storage\" განყოფილება (Firefox, Palemoon etc..)\n3. შედით \"Cookies\" სექციაში და შემდეგ \"https://accounts.spotify.com\" სუბსექციაში", - "step_3": "ნაბიჯი 3", - "step_3_steps": "დააკოპირეთ \"sp_dc\" ქუქი-ფაილის მნიშვნელობა", - "success_emoji": "წარმატება🥳", - "success_message": "თქვენ წარმატებით შეხვედით თქვენი Spotify ანგარიშით.", - "step_4": "ნაბიჯი 4", - "step_4_steps": "ჩასვით კოპირებული \"sp_dc\" მნიშვნელობა", - "something_went_wrong": "Რაღაც არასწორად წავიდა", - "piped_instance": "Piped Server Instance", - "piped_description": "The Piped server instance to use for track matching", - "piped_warning": "ზოგიერთი მათგანმა შეიძლება კარგად არ იმუშაოს. ", - "generate_playlist": "ფლეილისტის დაგენერირება", - "track_exists": "ტრეკი {track} უკვე არსებობს", - "replace_downloaded_tracks": "ყველა ჩამოტვირთული ტრეკის შეცვლა", - "skip_download_tracks": "ყველა ჩამოტვირთული ტრეკის გამოტოვება", - "do_you_want_to_replace": "გსურთ შეცვალოთ არსებული ტრეკი??", - "replace": "შეცვლა", - "skip": "გამოტოვება", - "select_up_to_count_type": "აირჩიე {count}-მდე {type}", - "select_genres": "ჟანრების არჩევა", - "add_genres": "ჟანრების დამატება", - "country": "ქვეყანა", - "number_of_tracks_generate": "დასაგენერირებელი ტრეკების რაოდენობა", - "acousticness": "Acousticness", - "danceability": "Danceability", - "energy": "Energy", - "instrumentalness": "Instrumentalness", - "liveness": "Liveness", - "loudness": "Loudness", - "speechiness": "Speechiness", - "valence": "Valence", - "popularity": "Popularity", - "key": "Key", - "duration": "Duration (s)", - "tempo": "Tempo (BPM)", - "mode": "Mode", - "time_signature": "Time Signature", - "short": "Short", - "medium": "საშუალო", - "long": "გრძელი", - "min": "მინიმალური", - "max": "მაქსიმალური", - "target": "სამიზნე", - "moderate": "საშუალო", - "deselect_all": "ყველა მონიშვნის გაუქმება", - "select_all": "ყველას მონიშვნა", - "are_you_sure": "Დარწმუნებული ხართ?", - "generating_playlist": "მიმდინარეობს თქვენი მორგებული ფლეილისტის გენერირება...", - "selected_count_tracks": "არჩეულია {count} ტრეკი", - "download_warning": "If you download all Tracks at bulk you're clearly pirating Music & causing damage to the creative society of Music. I hope you are aware of this. Always, try respecting & supporting Artist's hard work", - "download_ip_ban_warning": "BTW, your IP can get blocked on YouTube due excessive download requests than usual. IP block means you can't use YouTube (even if you're logged in) for at least 2-3 months from that IP device. And Spotube doesn't hold any responsibility if this ever happens", - "by_clicking_accept_terms": "By clicking 'accept' you agree to following terms:", - "download_agreement_1": "I know I'm pirating Music. I'm bad", - "download_agreement_2": "I'll support the Artist wherever I can and I'm only doing this because I don't have money to buy their art", - "download_agreement_3": "I'm completely aware that my IP can get blocked on YouTube & I don't hold Spotube or his owners/contributors responsible for any accidents caused by my current action", - "decline": "უარყოფა", - "accept": "დათანხმება", - "details": "დეტალები", - "youtube": "YouTube", - "channel": "Channel", - "likes": "მოწონებები", - "dislikes": "არ მოწონებები", - "views": "ნახვები", - "streamUrl": "სტრიმის ლინკი", - "stop": "გაჩერება", - "sort_newest": "ფალაგება სიახლის მიხედიტ", - "sort_oldest": "დალაგება სიძველის მიხედვით", - "sleep_timer": "ძილის ტაიმერი", - "mins": "{minutes} წუთი", - "hours": "{hours} საათი", - "hour": "{hours} საათი", - "custom_hours": "მორგებული საათები", - "logs": "ლოგები", - "developers": "დეველოპერები", - "not_logged_in": "არ ხარ დალოგინებული", - "search_mode": "ძებნის რეჟიმი", - "audio_source": "აუდიოს წყარო", - "ok": "ოკ", - "failed_to_encrypt": "დაშიფვრა ვერ მოხერხდა", - "encryption_failed_warning": "Spotube uses encryption to securely store your data. But failed to do so. So it'll fallback to insecure storage\nIf you're using linux, please make sure you've any secret-service (gnome-keyring, kde-wallet, keepassxc etc) installed", - "querying_info": "Querying info...", - "piped_api_down": "Piped API is down", - "piped_down_error_instructions": "The Piped instance {pipedInstance} is currently down\n\nEither change the instance or change the 'API type' to official YouTube API\n\nMake sure to restart the app after change", - "you_are_offline": "ამჟამად ხაზგარეშე ხართ", - "connection_restored": "თქვენი ინტერნეტ კავშირი აღდგა", - "use_system_title_bar": "სისტემის სათაურის ზოლის გამოყენება", - "crunching_results": "იტვირთება შედეგები...", - "search_to_get_results": "მოძებნეთ შედეგების მისაღებად", - "use_amoled_mode": "Pitch black dark theme", - "pitch_dark_theme": "AMOLED Mode", - "normalize_audio": "აუდიოს ნორმალიზება", - "change_cover": "Ქავერის შეცვლა", - "add_cover": "Ქავერის ფოტოს დამატება", - "restore_defaults": "ნაგულისხმევი პარამეტრების აღდგენა", - "download_music_codec": "მუსიკის კოდეკის გადმოწერა", - "streaming_music_codec": "სტრიმინგ მუსიკის კოდეკი", - "login_with_lastfm": "Last.fm-ით შესვლა", - "connect": "დაკავშირება", - "disconnect_lastfm": "Last.fm-იდან გამოსვლა", - "disconnect": "გამოსვლა", - "username": "მომხმარებელი", - "password": "პაროლი", - "login": "შესვლა", - "login_with_your_lastfm": "Last.fm ანგარიშით შესვლა", - "scrobble_to_lastfm": "Scrobble to Last.fm", - "go_to_album": "ალბომზე გადასვლა", - "discord_rich_presence": "Discord Rich Presence", - "browse_all": "ყველას ნახვა", - "genres": "ჟანრები", - "explore_genres": "შეისწავლეთ ჟანრები", - "friends": "მეგობრები", - "no_lyrics_available": "უკაცრავად, ამ ტრეკისთვის ტექსტის პოვნა შეუძლებელია", - "start_a_radio": "რადიოს ჩართვა", - "how_to_start_radio": "როგორ გნებავთ რადიოს ჩართვა?", - "replace_queue_question": "გნებავთ ჩაანაცვლოთ არსებული რიგი თუ დაამატოთ მასზე?", - "endless_playback": "დაუსრულებელი დაკვრა", - "delete_playlist": "ფლეილისტის წაშლა", - "delete_playlist_confirmation": "დარწმუნებული ხართ რომ გნებავთ ფლეილისტის წაშლა?", - "local_tracks": "ლოკალური ტრეკები", - "song_link": "ტრეკის ლინკი", - "skip_this_nonsense": "ამ სისულელის გამოტოვება", - "freedom_of_music": "“მუსიკის თავისუფლება”", - "freedom_of_music_palm": "“მუსიკის თავისუფლება შენს ხელის გულზე”", - "get_started": "დავიწყოთ", - "youtube_source_description": "რეკომენდებულია და მუშაობს საუკეთესოდ.", - "piped_source_description": "თავისუფლად გრძნობთ თავს? იგივეა, რაც YouTube, მაგრამ ბევრი თავისუფალი.", - "jiosaavn_source_description": "საუკეთესოა სამხრეთ აზიის რეგიონისთვის.", - "highest_quality": "საუკეთესო ხარისხი: {quality}", - "select_audio_source": "აუდიოს წყაროს არჩევა", - "endless_playback_description": "ახალი სიმთერების ავტომატურად რიგის ბოლოში დამატება", - "choose_your_region": "აირჩიე შენი რეგიონი", - "choose_your_region_description": "This will help Spotube show you the right content\nfor your location.", - "choose_your_language": "აირჩიე ენა", - "help_project_grow": "დაეხმარეთ ამ პროექტს განვითარებაში", - "help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.", - "contribute_on_github": "GitHub-ზე კონტრიბუცია", - "donate_on_open_collective": "Open Collective-ზე დონაცია", - "browse_anonymously": "ანონიმურად ნახვა", - "enable_connect": "დაკავშირების ჩართვა", - "enable_connect_description": "აკონტროლე Spotube სხვა მოწყობილობებიდან", - "devices": "მოწყობილობები", - "select": "არჩევა", - "connect_client_alert": "თქვენ კონტროლირებული ხართ {client} მოწყობილობით", - "this_device": "ეს მოწყობილობა", - "remote": "დისტანციური", - "local_library": "ადგილობრივი ბიბლიოთეკა", - "add_library_location": "ბიბლიოთეკაში დამატება", - "remove_library_location": "ბიბლიოთეკიდან წაშლა", - "local_tab": "ადგილობრივი", - "stats": "სტატისტიკა", - "and_n_more": "და {count} მეტი", - "recently_played": "მიუწვდელი", - "browse_more": "დაიცალეთ მეტი", - "no_title": "არ აქვს სათაური", - "not_playing": "არ ერთვის", - "epic_failure": "ეპიკური მარცხი!", - "added_num_tracks_to_queue": "დამატებული {tracks_length} ტრეკი რიგში", - "spotube_has_an_update": "Spotube-ს აქვს განახლება", - "download_now": "ჩამოტვირთეთ ახლავე", - "nightly_version": "Spotube Nightly {nightlyBuildNum} გამოშვებულია", - "release_version": "Spotube v{version} გამოშვებულია", - "read_the_latest": "წაიკითხეთ უახლესი ", - "release_notes": "გამოშვების შენიშვნები", - "pick_color_scheme": "აირჩიეთ ფერის სქემა", - "save": "შეინახეთ", - "choose_the_device": "აირჩიეთ მოწყობილობა:", - "multiple_device_connected": "დაკავშირებულია რამდენიმე მოწყობილობა.\nაირჩიეთ მოწყობილობა, რომელზეც უნდა განხორციელდეს ეს მოქმედება", - "nothing_found": "არაფერი მოიძებნა", - "the_box_is_empty": "კვადრატია ცარიელი", - "top_artists": "ტოპ არტისტები", - "top_albums": "ტოპ ალბომები", - "this_week": "ამ კვირას", - "this_month": "ამ თვეში", - "last_6_months": "ბოლო 6 თვე", - "this_year": "ამ წელს", - "last_2_years": "ბოლო 2 წელი", - "all_time": "ყველა დრო", - "powered_by_provider": "{providerName}-ით გაწვდილი", - "email": "ელ. ფოსტა", - "profile_followers": "გამყვანები", - "birthday": "დაბადების დღე", - "subscription": "გამოწერა", - "not_born": "არ დაბადებულა", - "hacker": "ჰაკერი", - "profile": "პროფილი", - "no_name": "არ არის სახელი", - "edit": "რედაქტირება", - "user_profile": "მომხმარებლის პროფილი", - "count_plays": "{count} გაწვდვა", - "streaming_fees_hypothetical": "*ეს рассчитывается на основе выплат за поток от Spotify\nот $0.003 до $0.005. ეს ჰიპოთეტური გამოთვლა იძლევა მომხმარებელს წარმოდგენას იმაზე, რამდენად\nგადახდილი იქნებოდა არტისტებისთვის, თუ მათ მოუსმინოს Spotify-ს ტრეკებს.", - "count_mins": "{minutes} წუთი", - "summary_minutes": "წუთები", - "summary_listened_to_music": "მუსიკა გაწვდილი", - "summary_songs": "მელოდია", - "summary_streamed_overall": "გაწვდილი საერთო", - "summary_owed_to_artists": "გადასახადი არტისტებს\nამ თვეში", - "summary_artists": "არტისტების", - "summary_music_reached_you": "მუსიკა ჩაგივარდა", - "summary_full_albums": "სრული ალბომები", - "summary_got_your_love": "მოსულა თქვენი სიყვარული", - "summary_playlists": "პლეილისტები", - "summary_were_on_repeat": "გადაწვდილი იყო", - "total_money": "მთლიანი {money}", - "minutes_listened": "წუთები მოუსმინეს", - "streamed_songs": "სტრიმირებული სიმღერები", - "count_streams": "{count} სტრიმი", - "owned_by_you": "შენ მიერ საკუთრებული", - "copied_shareurl_to_clipboard": "{shareUrl} აიღო კლიპბორდზე", - "spotify_hipotetical_calculation": "*ეს გამოითვლება Spotify-ის თითოეულ სტრიმზე\nგადახდის შესაბამისად, რომელიც $0.003 დან $0.005-მდეა. ეს არის ჰიპოთეტური\nგამოთვლა, რომელიც აჩვენებს მომხმარებელს რამდენი გადაიხდიდა\nარტისტებს, თუკი ისინი უსმენდნენ მათ სიმღერებს Spotify-ზე.", - "webview_not_found": "ვებვიუ ვერ მოიძებნა", - "webview_not_found_description": "თქვენს მოწყობილობაზე ვებვიუის შესრულების დრო არ არის დაყენებული.\nთუ დაყენებულია, დარწმუნდით, რომ ის environment PATH-შია\n\nდაყენების შემდეგ, გადატვირთეთ აპი", - "unsupported_platform": "მოუხერხებელი პლატფორმა", - "invidious_instance": "Invidious სერვერის ინსტანცია", - "invidious_description": "Invidious სერვერის ინსტანცია, რომელიც გამოიყენება ტრეკის შესატყვისად", - "invidious_warning": "ზოგიერთი შეიძლება კარგად არ მუშაობდეს. გამოიყენეთ თქვენს პასუხისმგებლობაზე", - "invidious_source_description": "მსგავსია Piped-ის, მაგრამ მაღალი ხელმისაწვდომობით.", - "cache_music": "მუსიკის ქეში", - "open": "გახსენით", - "cache_folder": "ქეშის საქაღალდე", - "export": "ექსპორტი", - "clear_cache": "ქეშის გასუფთავება", - "clear_cache_confirmation": "გსურთ ქეშის გასუფთავება?", - "export_cache_files": "ქეშირებული ფაილების ექსპორტი", - "found_n_files": "ნაპოვნია {count} ფაილი", - "export_cache_confirmation": "გსურთ ამ ფაილების ექსპორტი", - "exported_n_out_of_m_files": "{filesExported} ფაილი {files}-დან ექსპორტირებულია", - "playlist": "პლეისთი", - "no_loop": "არ არის ციკლი", - "generate": "გააგენერირეთ", - "undo": "დაბრუნება", - "download_all": "ყველას ჩამოტვირთვა", - "add_all_to_playlist": "ყველა დაამატეთ პლეისთში", - "add_all_to_queue": "ყველა დაამატეთ რიგში", - "play_all_next": "ყველა შემდეგ ითამაშე", - "pause": "შეჩერება", - "view_all": "ყველა ნახვა", - "no_tracks_added_yet": "გაჩნდება რომ ჯერ არ გაქვთ დამატებული ტრეკები", - "no_tracks": "გავლებული არ ჩანს არ არსებობს ტრეკები", - "no_tracks_listened_yet": "გქონდეთ გრძნობა, რომ ჯერ არაფერი უსმენია", - "not_following_artists": "არ მიჰყვებით რომელიმე არტისტს", - "no_favorite_albums_yet": "გაჩნდება რომ ჯერ არ გაქვთ დამატებული ალბომები თქვენს ფავორიტებში", - "no_logs_found": "ჩაწერები ვერ მოიძებნა", - "youtube_engine": "YouTube ძრავა", - "youtube_engine_not_installed_title": "{engine} არ არის ინსტალირებული", - "youtube_engine_not_installed_message": "{engine} არ არის ინსტალირებული თქვენს სისტემაში.", - "youtube_engine_set_path": "დარწმუნდით, რომ ის ხელმისაწვდომია PATH ცვლადში ან\nდაუყავით {engine} პროგრამის ფაილის სრული გზა", - "youtube_engine_unix_issue_message": "macOS/Linux/Unix მსგავსი ოპერაციული სისტემებში, .zshrc/.bashrc/.bash_profile-ით პათის დაყენება ვერ იმუშავებს.\nთქვენ უნდა დააყენოთ პათი შელ ფაილში", - "download": "ჩამოტვირთვა", - "file_not_found": "ფაილი ვერ მოიძებნა", - "custom": "პერსონალიზირებული", - "add_custom_url": "დამატება პერსონალური URL", - "edit_port": "პორტის რედაქტირება", - "port_helper_msg": "ნაგულისხმევი არის -1, რაც შემთხვევითი ნომრის მითითებას ნიშნავს. თუ لديك firewall настроен, рекомендуется установить это.", - "connect_request": "{client}-ის დაკავშირების ნებართვა?", - "connection_request_denied": "კავშირი უარყოფილია. მომხმარებელმა უარყო წვდომა.", - "hipotetical_calculation": "*ეს გამოითვლება ონლაინ მუსიკალური სტრიმინგის პლატფორმების საშუალო ანაზღაურების საფუძველზე, რომელიც შეადგენს $0.003-დან $0.005-მდე. ეს არის ჰიპოთეტური გაანგარიშება, რომელიც მომხმარებელს აძლევს წარმოდგენას, თუ რამდენს გადაუხდიდნენ ისინი არტისტებს, თუ მათ სიმღერებს მოუსმენდნენ სხვადასხვა მუსიკალურ სტრიმინგ პლატფორმაზე.", - "an_error_occurred": "მოხდა შეცდომა", - "copy_to_clipboard": "კოპირება ბუფერში", - "view_logs": "იხილეთ ჟურნალები", - "retry": "ხელახლა ცდა", - "no_default_metadata_provider_selected": "თქვენ არ გაქვთ დაყენებული ნაგულისხმევი მეტამონაცემების პროვაიდერი", - "manage_metadata_providers": "მეტამონაცემების პროვაიდერების მართვა", - "open_link_in_browser": "ბმულის გახსნა ბრაუზერში?", - "do_you_want_to_open_the_following_link": "გსურთ გახსნათ შემდეგი ბმული", - "unsafe_url_warning": "შეიძლება სახიფათო იყოს ბმულების გახსნა უნდობელი წყაროებიდან. იყავით ფრთხილად!\nასევე შეგიძლიათ დააკოპიროთ ბმული თქვენს ბუფერში.", - "copy_link": "ბმულის კოპირება", - "building_your_timeline": "თქვენი დროის ხაზის აგება თქვენი მოსმენების საფუძველზე...", - "official": "ოფიციალური", - "author_name": "ავტორი: {author}", - "third_party": "მესამე მხარის", - "plugin_requires_authentication": "პლაგინი საჭიროებს ავთენტიფიკაციას", - "update_available": "განახლება ხელმისაწვდომია", - "supports_scrobbling": "მხარს უჭერს სქრობლინგს", - "plugin_scrobbling_info": "ეს პლაგინი აწარმოებს თქვენი მუსიკის სქრობლინგს, რათა შექმნას თქვენი მოსმენის ისტორია.", - "default_plugin": "ნაგულისხმევი", - "set_default": "ნაგულისხმევად დაყენება", - "support": "მხარდაჭერა", - "support_plugin_development": "პლაგინის განვითარების მხარდაჭერა", - "can_access_name_api": "- შეუძლია წვდომა **{name}** API-ზე", - "do_you_want_to_install_this_plugin": "გსურთ ამ პლაგინის დაყენება?", - "third_party_plugin_warning": "ეს პლაგინი არის მესამე მხარის საცავიდან. გთხოვთ, დარწმუნდეთ, რომ ენდობით წყაროს დაყენებამდე.", - "author": "ავტორი", - "this_plugin_can_do_following": "ამ პლაგინს შეუძლია შემდეგის გაკეთება", - "install": "დაყენება", - "install_a_metadata_provider": "დააყენეთ მეტამონაცემების პროვაიდერი", - "no_tracks_playing": "ამჟამად არ უკრავს არცერთი ტრეკი", - "synced_lyrics_not_available": "ამ სიმღერისთვის სინქრონიზებული ტექსტები არ არის ხელმისაწვდომი. გთხოვთ, გამოიყენოთ", - "plain_lyrics": "მარტივი ტექსტები", - "tab_instead": "ჩანართი, სანაცვლოდ.", - "disclaimer": "პასუხისმგებლობის უარყოფა", - "third_party_plugin_dmca_notice": "Spotube-ის გუნდი არ იღებს პასუხისმგებლობას (მათ შორის, იურიდიულს) არცერთ \"მესამე მხარის\" პლაგინზე.\nგთხოვთ, გამოიყენოთ ისინი თქვენი რისკის ქვეშ. ნებისმიერი ხარვეზის/პრობლემის შესახებ შეატყობინეთ პლაგინის საცავს.\n\nთუ რომელიმე \"მესამე მხარის\" პლაგინი არღვევს რაიმე სერვისის/იურიდიული პირის ToS/DMCA-ს, გთხოვთ, სთხოვეთ \"მესამე მხარის\" პლაგინის ავტორს ან ჰოსტინგის პლატფორმას, მაგალითად GitHub/Codeberg, მიიღოს ზომები. ზემოთ ჩამოთვლილი (\"მესამე მხარის\" ეტიკეტის მქონე) ყველა არის საჯარო/საზოგადოების მიერ შენარჩუნებული პლაგინები. ჩვენ მათ არ ვაკონტროლებთ, ამიტომ არ შეგვიძლია მათზე რაიმე ზომების მიღება.\n\n", - "input_does_not_match_format": "შეყვანა არ ემთხვევა საჭირო ფორმატს", - "metadata_provider_plugins": "მეტამონაცემების პროვაიდერების პლაგინები", - "paste_plugin_download_url": "ჩასვით ჩამოტვირთვის url ან GitHub/Codeberg-ის რეპოს url ან პირდაპირი ბმული .smplug ფაილზე", - "download_and_install_plugin_from_url": "პლაგინის ჩამოტვირთვა და დაყენება url-დან", - "failed_to_add_plugin_error": "პლაგინის დამატება ვერ მოხერხდა: {error}", - "upload_plugin_from_file": "პლაგინის ატვირთვა ფაილიდან", - "installed": "დაინსტალირებული", - "available_plugins": "ხელმისაწვდომი პლაგინები", - "configure_your_own_metadata_plugin": "დააყენეთ თქვენი საკუთარი პლეილისტის/ალბომის/არტისტის/ფიდის მეტამონაცემების პროვაიდერი", - "audio_scrobblers": "აუდიო სქრობლერები", - "scrobbling": "სქრობლინგი", - "download_music_format": "მუსიკის ჩამოტვირთვის ფორმატი", - "streaming_music_format": "სტრიმინგის მუსიკის ფორმატი", - "download_music_quality": "ჩამოტვირთვის ხარისხი", - "streaming_music_quality": "სტრიმინგის ხარისხი", - "default_metadata_source": "ნაგულისხმევი მეტამონაცემების წყარო", - "set_default_metadata_source": "ნაგულისხმევი მეტამონაცემების წყაროს დაყენება", - "default_audio_source": "ნაგულისხმევი აუდიო წყარო", - "set_default_audio_source": "ნაგულისხმევი აუდიო წყაროს დაყენება", - "plugins": "პლაგინები", - "configure_plugins": "თქვენი საკუთარი მეტამონაცემებისა და აუდიო წყაროს პლაგინების კონფიგურაცია", - "source": "წყარო: ", - "uncompressed": "შეუკუმშავი", - "dab_music_source_description": "აუდიოფილებისთვის. უზრუნველყოფს მაღალი ხარისხის/უკომპრესო აუდიო სტრიმებს. ზუსტი შესაბამისობა ISRC-ის მიხედვით." -} \ No newline at end of file diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb deleted file mode 100644 index 70a68f8b..00000000 --- a/lib/l10n/app_ko.arb +++ /dev/null @@ -1,495 +0,0 @@ -{ - "guest": "게스트", - "browse": "찾아보기", - "search": "검색", - "library": "라이브러리", - "lyrics": "가사", - "settings": "설정", - "genre_categories_filter": "카테고리 혹은 장르별로 불러오기", - "genre": "장르", - "personalized": "맞춤 추천", - "featured": "인기", - "new_releases": "신곡", - "songs": "노래", - "playing_track": "{track} 을 재생", - "queue_clear_alert": "현재 재생 대기열을 없앱니다。{track_length} 곡이 제거됩니다。\n계속 진행할까요?", - "load_more": "더 불러오기", - "playlists": "플레이리스트", - "artists": "아티스트", - "albums": "앨범", - "tracks": "곡", - "downloads": "다운로드한 곡", - "filter_playlists": "플레이리스트를 필터링", - "liked_tracks": "좋아하는 곡", - "liked_tracks_description": "좋아요를 남긴 곡들", - "create_playlist": "플레이리스트 생성", - "create_a_playlist": "플레이리스트를 생성", - "create": "생성", - "cancel": "취소", - "playlist_name": "플레이리스트명", - "name_of_playlist": "플레이리스트의 이름", - "description": "설명", - "public": "공개", - "collaborative": "공유 플레이리스트", - "search_local_tracks": "기기에 저장된 곡을 검색하기", - "play": "재생", - "delete": "삭제", - "none": "없음", - "sort_a_z": "A-Z 순으로 정렬", - "sort_z_a": "Z-A 순으로 정렬", - "sort_artist": "아티스트 순으로 정렬", - "sort_album": "앨범 순으로 정렬", - "sort_tracks": "곡명 순으로 정렬", - "currently_downloading": "현재 ({tracks_length}) 곡 다운로드 중", - "cancel_all": "모두 취소", - "filter_artist": "아티스트 필터링", - "followers": "{followers} 팔로워", - "add_artist_to_blacklist": "이 아티스트를 블랙리스트에 추가", - "top_tracks": "인기곡", - "fans_also_like": "애청자들이 좋아하는 곡", - "loading": "불러오는 중...", - "artist": "아티스트", - "blacklisted": "블랙리스트", - "following": "팔로우 중", - "follow": "팔로우하기", - "artist_url_copied": "아티스트의 URL 주소를 클립보드에 복사함", - "added_to_queue": "{tracks} 곡을 대기열에 추가함", - "filter_albums": "앨범 필터링", - "synced": "동기화됨", - "plain": "그대로", - "shuffle": "셔플", - "search_tracks": "곡 검색하기", - "released": "공개일", - "error": "에러", - "title": "타이틀", - "time": "길이", - "more_actions": "다른 작업", - "download_count": "({count}) 곡 다운로드", - "add_count_to_playlist": "플레이리스트에 ({count}) 곡을 추가", - "add_count_to_queue": "대기열에 ({count}) 곡을 추가", - "play_count_next": "이 다음에 ({count}) 곡을 재생", - "album": "앨범", - "copied_to_clipboard": "{data} 를 클립보드에 복사함", - "add_to_following_playlists": "{track} 을 이 플레이리스트에 추가", - "add": "추가", - "added_track_to_queue": "대기열에 {track} 을 추가함", - "add_to_queue": "대기열에 추가", - "track_will_play_next": "{track} 을 이 다음에 재생", - "play_next": "이 다음에 재생", - "removed_track_from_queue": "대기열에서 {track} 를 제거함", - "remove_from_queue": "대기열에서 제거", - "remove_from_favorites": "즐겨찾기에서 제거", - "save_as_favorite": "즐겨찾기에 추가", - "add_to_playlist": "플레이리스트에 추가", - "remove_from_playlist": "플레이리스트에서 제거", - "add_to_blacklist": "블랙리스트에 추가", - "remove_from_blacklist": "블랙리스트에서 제거", - "share": "공유", - "mini_player": "미니 플레이어", - "slide_to_seek": "앞뒤로 슬라이드하여 탐색", - "shuffle_playlist": "플레이리스트를 섞기", - "unshuffle_playlist": "플레이리스트를 섞지 않기", - "previous_track": "이전 곡", - "next_track": "다음 곡", - "pause_playback": "일시정지", - "resume_playback": "재개", - "loop_track": "반복 재생", - "repeat_playlist": "플레이리스트 반복", - "queue": "재생 대기열", - "alternative_track_sources": "대체가능한 음악 서버", - "download_track": "곡 다운로드", - "tracks_in_queue": "대기열에 {tracks} 곡이 있음", - "clear_all": "모두 제거", - "show_hide_ui_on_hover": "마우스를 올리면 UI를 표시/숨김", - "always_on_top": "항상 위에 표시", - "exit_mini_player": "미니 플레이어 닫기", - "download_location": "다운로드 경로", - "account": "계정", - "login_with_spotify": "Spotify 계정으로 로그인", - "connect_with_spotify": "Spotify에 연결", - "logout": "로그아웃", - "logout_of_this_account": "이 계정에서 로그아웃", - "language_region": "언어 & 지역", - "language": "언어", - "system_default": "시스템 기본설정", - "market_place_region": "마켓플레이스 지역", - "recommendation_country": "추천 국가", - "appearance": "디자인", - "layout_mode": "레이아웃 모드", - "override_layout_settings": "반응형 레이아웃 모드 설정 덮어씌우기", - "adaptive": "적응형", - "compact": "컴팩트", - "extended": "확장", - "theme": "테마", - "dark": "다크", - "light": "라이트", - "system": "시스템과 동일", - "accent_color": "보조색", - "sync_album_color": "앨범 색상", - "sync_album_color_description": "앨범아트의 주요 색상을 보조색으로 사용", - "playback": "재생", - "audio_quality": "음질", - "high": "높음", - "low": "낮음", - "pre_download_play": "재생할 곡을 미리 다운로드", - "pre_download_play_description": "스트리밍 방식을 쓰는 대신 파일 단위로 다운로드 받고 재생 (인터넷 대역폭이 높은 환경에서 추천)", - "skip_non_music": "음악이 아닌 부분을 스킵 (SponsorBlock)", - "blacklist_description": "블랙리스트에 추가된 곡과 아티스트", - "wait_for_download_to_finish": "현재 진행중인 다운로드가 끝날 때까지 기다려주세요", - "desktop": "데스크톱", - "close_behavior": "닫을 때의 동작", - "close": "닫기", - "minimize_to_tray": "트레이로 최소화", - "show_tray_icon": "시스템 트레이 아이콘 표시", - "about": "앱 정보", - "u_love_spotube": "Spotube... 사랑하시죠?", - "check_for_updates": "업데이트 확인", - "about_spotube": "Spotube에 관해", - "blacklist": "블랙리스트", - "please_sponsor": "후원해주시면 감사하겠습니다.", - "spotube_description": "Spotube는, 경량에 크로스플랫폼인데다 무료이기까지한 스포티파이 클라이언트입니다", - "version": "버전", - "build_number": "빌드 번호", - "founder": "창시자", - "repository": "리포지토리", - "bug_issues": "버그 및 이슈", - "made_with": "❤️을 담아 방글라데시에서 만듦", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "라이선스", - "add_spotify_credentials": "먼저 Spotify의 로그인정보를 추가하기", - "credentials_will_not_be_shared_disclaimer": "걱정마세요. 개인정보를 수집하거나 공유하지 않습니다.", - "know_how_to_login": "어떻게 하는건지 모르겠나요?", - "follow_step_by_step_guide": "사용법 확인하기", - "spotify_cookie": "Spotify {name} Cookies", - "cookie_name_cookie": "{name} Cookies", - "fill_in_all_fields": "모든 필드에 정보를 입력해주세요", - "submit": "제출", - "exit": "종료", - "previous": "이전으로", - "next": "다음으로", - "done": "완료", - "step_1": "1단계", - "first_go_to": "가장 먼저 먼저 들어갈 곳은 ", - "login_if_not_logged_in": "그리고 로그인을 하지 않았다면 로그인해주세요", - "step_2": "2단계", - "step_2_steps": "1. 로그인에 성공하면、F12나 마우스 우클릭 > 검사(Inspect)을 눌러 브라우저의 개발자 도구(devtools)를 열어주세요.\n2. 애플리케이션 (Application) 탭 (Chrome, Edge, Brave 등) 또는 스토리지 탭 (Firefox, Palemoon 등)을 열어주세요.\n3. 쿠키 (Cookies) 섹션으로 들어가서, https://accounts.spotify.com 서브섹션으로 들어가주세요.", - "step_3": "3단계", - "success_emoji": "성공🥳", - "success_message": "성공적으로 스포티파이 게정으로 로그인했습니다. 잘했어요!", - "step_4": "4단계", - "something_went_wrong": "알 수 없는 이유로 동작에 실패했습니다.", - "piped_instance": "Piped 서버의 인스턴스", - "piped_description": "곡 탐색에 사용할 Piped 서버 인스턴스", - "piped_warning": "몇몇 서버는 제대로 동작하지 않을 수 있습니다. 본인 책임 하에 이용해주세요.", - "generate_playlist": "플레이리스트 생성", - "track_exists": "곡 {track} 은 이미 리스트에 있습니다", - "replace_downloaded_tracks": "다운로드한 모든 곡을 교체", - "skip_download_tracks": "다운로드가 끝난 곡을 모두 건너뛰기", - "do_you_want_to_replace": "현재 곡을 교체하시겠습니까?", - "replace": "교체", - "skip": "건너뛰기", - "select_up_to_count_type": "{type}을 {count}개까지 선택", - "select_genres": "장르 선택", - "add_genres": "장르 추가", - "country": "국가", - "number_of_tracks_generate": "생성할 곡 수", - "acousticness": "반주 구간 (Acousticness)", - "danceability": "흥겨운 정도 (Danceability)", - "energy": "에너지 (Energy)", - "instrumentalness": "기악성 (Instrumentalness)", - "liveness": "생동감 (Liveness)", - "loudness": "라우드니스 (Loudness)", - "speechiness": "회화성 (Speechniss)", - "valence": "감정가 (Valence)", - "popularity": "인기도 (Popularity)", - "key": "조성 (키)", - "duration": "길이 (초)", - "tempo": "템포 (BPM)", - "mode": "장조", - "time_signature": "박자", - "short": "짧음", - "medium": "중간", - "long": "긺", - "min": "최소", - "max": "최대", - "target": "목표", - "moderate": "보통", - "deselect_all": "모두 선택해제", - "select_all": "모두 선택", - "are_you_sure": "괜찮겠습니까?", - "generating_playlist": "커스텀 플레이리스트를 생성하는 중...", - "selected_count_tracks": "{count} 곡이 선택되었습니다.", - "download_warning": "모든 트랙을 대량으로 다운로드하는 것은 명백한 불법 복제이며 음악 창작 사회에 피해를 입히는 행위입니다. 이 점을 알아주셨으면 합니다. 항상 아티스트의 노력을 존중하고 응원해 주세요.", - "download_ip_ban_warning": "참고로, 평소보다 과도한 다운로드 요청으로 인해 YouTube에서 IP가 차단될 수 있습니다. IP 차단은 해당 IP 기기에서 최소 2~3개월 동안 (로그인한 상태에서도) YouTube를 사용할 수 없음을 의미합니다. 그리고 이런 일이 발생하더라도 스포튜브는 어떠한 책임도 지지 않습니다.", - "by_clicking_accept_terms": "'동의'를 클릭하면 다음 약관에 동의하는 것입니다:", - "download_agreement_1": "알고 있습니다. 전 나쁜 사람입니다.", - "download_agreement_2": "제가 할 수 있는 모든 곳에서 아티스트를 지원할 것이며, 저는 그들의 작품을 살 돈이 없기 때문에 이렇게 하는 것뿐입니다.", - "download_agreement_3": "본인은 YouTube에서 내 IP가 차단될 수 있음을 완전히 알고 있으며, 현재 내 행동으로 인해 발생하는 사고에 대해 Spotube 또는 그 소유자/기여자에게 책임을 묻지 않습니다.", - "decline": "거절", - "accept": "동의", - "details": "상세", - "youtube": "YouTube", - "channel": "채널", - "likes": "좋아요", - "dislikes": "싫어요", - "views": "조회수", - "streamUrl": "스트림 URL", - "stop": "중지", - "sort_newest": "최근에 추가된 순으로 정렬", - "sort_oldest": "예전에 추가된 순으로 정렬", - "sleep_timer": "취침 타이머", - "mins": "{minutes} 분", - "hours": "{hours} 시간", - "hour": "{hours} 시간", - "custom_hours": "시간 설정", - "logs": "로그", - "developers": "개발", - "not_logged_in": "로그인하지 않았습니다", - "search_mode": "검색 모드", - "audio_source": "오디오 출처", - "ok": "알겠습니다", - "failed_to_encrypt": "암호화에 실패했습니다", - "encryption_failed_warning": "Spotube는 암호화를 사용하여 데이터를 안전하게 저장합니다. 하지만 그렇게 하지 못했습니다. 따라서 안전하지 않은 저장소로 대체됩니다.\n리눅스를 사용하는 경우, 비밀 서비스(gnome-keyring, kde-wallet, keepassxc 등)가 설치되어 있는지 확인하세요.", - "querying_info": "정보를 얻는 중...", - "piped_api_down": "Piped API가 응답하지 않습니다", - "piped_down_error_instructions": "Piped 인스턴스 {pipedInstance}가 현재 다운되었습니다.\n\n인스턴스를 변경하거나 'API 유형'을 공식 YouTube API로 변경하세요.\n\n변경 후 앱을 다시 시작해야 합니다.", - "you_are_offline": "현재 오프라인입니다", - "connection_restored": "인터넷에 다시 연결되었습니다", - "use_system_title_bar": "시스템 타이틀바를 사용", - "update_playlist": "플레이리스트를 업데이트", - "update": "업데이트", - "crunching_results": "결과를 처리하는 중...", - "search_to_get_results": "결과를 얻으려면 검색해주세요", - "use_amoled_mode": "AMOLED모드를 사용", - "pitch_dark_theme": "검정색 기반의 어두운 테마", - "normalize_audio": "오디오 노멀라이즈", - "change_cover": "커버 변경", - "add_cover": "커버 추가", - "restore_defaults": "기본값으로 복원", - "download_music_codec": "다운로드 음악 코덱", - "streaming_music_codec": "스트리밍 음악 코덱", - "login_with_lastfm": "Last.fm에 로그인", - "connect": "연결", - "disconnect_lastfm": "Last.fm에서 연결 해제", - "disconnect": "연결 해제", - "username": "사용자명", - "password": "비밀번호", - "login": "로그인", - "login_with_your_lastfm": "내 Last.fm 계정으로로그인", - "scrobble_to_lastfm": "Scrobble to Last.fm", - "go_to_album": "앨범으로 이동", - "discord_rich_presence": "Discord Rich Presence", - "browse_all": "모두 탐색", - "genres": "장르", - "explore_genres": "장르 탐색", - "step_3_steps": "\"sp_dc\" 쿠키의 값을 복사", - "step_4_steps": "복사한 \"sp_dc\"값을 붙여넣기", - "friends": "친구", - "no_lyrics_available": "죄송하지만 이 곡의 가사를 찾지 못했습니다", - "@@locale": "ko", - "sort_duration": "시간순 정렬", - "start_a_radio": "라디오 시작", - "how_to_start_radio": "라디오를 어떻게 시작하시겠습니까?", - "replace_queue_question": "현재 큐를 대체하시겠습니까 아니면 추가하시겠습니까?", - "endless_playback": "끝없는 재생", - "delete_playlist": "재생 목록 삭제", - "delete_playlist_confirmation": "이 재생 목록을 삭제하시겠습니까?", - "local_tracks": "로컬 트랙", - "song_link": "곡 링크", - "skip_this_nonsense": "이 허튼소리 건너뛰기", - "freedom_of_music": "“음악의 자유”", - "freedom_of_music_palm": "“손바닥 안의 음악의 자유”", - "get_started": "시작합시다", - "youtube_source_description": "추천되며 가장 잘 작동합니다.", - "piped_source_description": "자유로운 기분이 듭니까? YouTube와 같지만 훨씬 더 무료합니다.", - "jiosaavn_source_description": "남아시아 지역에 최적입니다.", - "highest_quality": "최고 품질: {quality}", - "select_audio_source": "오디오 소스 선택", - "endless_playback_description": "자동으로 새로운 노래를 대기열의 끝에 추가", - "choose_your_region": "지역 선택", - "choose_your_region_description": "이것은 Spotube가 위치에 맞는 콘텐츠를 표시하는 데 도움이 됩니다.", - "choose_your_language": "언어 선택", - "help_project_grow": "이 프로젝트 성장에 도움을 주세요", - "help_project_grow_description": "Spotube는 오픈 소스 프로젝트입니다. 프로젝트에 기여하거나 버그를 보고하거나 새로운 기능을 제안하여이 프로젝트의 성장에 도움을 줄 수 있습니다.", - "contribute_on_github": "GitHub에서 기여하기", - "donate_on_open_collective": "Open Collective에 기부하기", - "browse_anonymously": "익명으로 둘러보기", - "enable_connect": "연결 활성화", - "enable_connect_description": "다른 장치에서 Spotube 제어", - "devices": "장치", - "select": "선택", - "connect_client_alert": "{client}님에 의해 제어되고 있습니다", - "this_device": "이 장치", - "remote": "원격", - "local_library": "로컬 도서관", - "add_library_location": "도서관에 추가", - "remove_library_location": "도서관에서 제거", - "local_tab": "로컬", - "stats": "통계", - "and_n_more": "그리고 {count}개 더", - "recently_played": "최근 재생", - "browse_more": "더 보기", - "no_title": "제목 없음", - "not_playing": "재생 중이 아님", - "epic_failure": "서사적 실패!", - "added_num_tracks_to_queue": "{tracks_length} 곡을 대기열에 추가했습니다", - "spotube_has_an_update": "Spotube에 업데이트가 있습니다", - "download_now": "지금 다운로드", - "nightly_version": "Spotube Nightly {nightlyBuildNum}이 출시되었습니다", - "release_version": "Spotube v{version}이 출시되었습니다", - "read_the_latest": "최신 ", - "release_notes": "릴리스 노트", - "pick_color_scheme": "색상 테마 선택", - "save": "저장", - "choose_the_device": "디바이스 선택:", - "multiple_device_connected": "여러 디바이스가 연결되어 있습니다.\n이 작업을 실행할 디바이스를 선택하세요", - "nothing_found": "찾을 수 없음", - "the_box_is_empty": "상자가 비어 있습니다", - "top_artists": "톱 아티스트", - "top_albums": "톱 앨범", - "this_week": "이번 주", - "this_month": "이번 달", - "last_6_months": "지난 6개월", - "this_year": "올해", - "last_2_years": "지난 2년", - "all_time": "모든 시간", - "powered_by_provider": "{providerName} 제공", - "email": "이메일", - "profile_followers": "팔로워", - "birthday": "생일", - "subscription": "구독", - "not_born": "태어나지 않음", - "hacker": "해커", - "profile": "프로필", - "no_name": "이름 없음", - "edit": "편집", - "user_profile": "사용자 프로필", - "count_plays": "{count} 재생", - "streaming_fees_hypothetical": "*이것은 Spotify의 스트림당 지급액\n$0.003에서 $0.005를 기준으로 계산된 것입니다.\n이것은 사용자가 Spotify에서 곡을 들었을 때\n아티스트에게 지불했을 금액에 대한 통찰을 제공하기 위한\n가상의 계산입니다.", - "count_mins": "{minutes} 분", - "summary_minutes": "분", - "summary_listened_to_music": "듣는 음악", - "summary_songs": "곡", - "summary_streamed_overall": "전체 스트리밍", - "summary_owed_to_artists": "이번 달 아티스트에게 지급해야 할 금액", - "summary_artists": "아티스트의", - "summary_music_reached_you": "음악이 도달함", - "summary_full_albums": "전체 앨범", - "summary_got_your_love": "당신의 사랑을 받음", - "summary_playlists": "플레이리스트", - "summary_were_on_repeat": "반복 재생됨", - "total_money": "총 {money}", - "minutes_listened": "청취한 시간", - "streamed_songs": "스트리밍된 곡", - "count_streams": "{count} 스트림", - "owned_by_you": "당신이 소유", - "copied_shareurl_to_clipboard": "{shareUrl}를 클립보드에 복사했습니다", - "spotify_hipotetical_calculation": "*Spotify의 스트림당 지불금 $0.003에서 $0.005까지의\n기준으로 계산되었습니다. 이는 사용자가 Spotify에서\n곡을 들을 때 아티스트에게 얼마를 지불했을지를\n알려주기 위한 가상의 계산입니다.", - "webview_not_found": "웹뷰를 찾을 수 없음", - "webview_not_found_description": "기기에 웹뷰 런타임이 설치되지 않았습니다.\n설치되어 있으면 environment PATH에 있는지 확인하십시오\n\n설치 후 앱을 다시 시작하세요", - "unsupported_platform": "지원되지 않는 플랫폼", - "invidious_instance": "Invidious 서버 인스턴스", - "invidious_description": "트랙 매칭에 사용할 Invidious 서버 인스턴스", - "invidious_warning": "일부는 제대로 작동하지 않을 수 있습니다. 자신의 책임 하에 사용하세요", - "invidious_source_description": "Piped와 비슷하지만 가용성이 높습니다.", - "cache_music": "음악 캐시", - "open": "열기", - "cache_folder": "캐시 폴더", - "export": "내보내기", - "clear_cache": "캐시 지우기", - "clear_cache_confirmation": "캐시를 지우시겠습니까?", - "export_cache_files": "캐시된 파일 내보내기", - "found_n_files": "{count}개의 파일을 찾았습니다", - "export_cache_confirmation": "이 파일들을 내보내시겠습니까", - "exported_n_out_of_m_files": "{files}개 중 {filesExported}개 파일을 내보냈습니다", - "playlist": "재생 목록", - "no_loop": "반복 없음", - "generate": "생성", - "undo": "실행 취소", - "download_all": "모두 다운로드", - "add_all_to_playlist": "모두 재생 목록에 추가", - "add_all_to_queue": "모두 큐에 추가", - "play_all_next": "모두 다음에 재생", - "pause": "일시 정지", - "view_all": "모두 보기", - "no_tracks_added_yet": "아직 트랙을 추가하지 않은 것 같습니다", - "no_tracks": "여기에 트랙이 없는 것 같습니다", - "no_tracks_listened_yet": "아직 아무 것도 듣지 않은 것 같습니다", - "not_following_artists": "아티스트를 팔로우하지 않고 있습니다", - "no_favorite_albums_yet": "아직 즐겨찾기 앨범을 추가하지 않은 것 같습니다", - "no_logs_found": "로그를 찾을 수 없습니다", - "youtube_engine": "YouTube 엔진", - "youtube_engine_not_installed_title": "{engine}가 설치되지 않았습니다", - "youtube_engine_not_installed_message": "{engine}가 시스템에 설치되지 않았습니다.", - "youtube_engine_set_path": "PATH 변수에서 사용할 수 있는지 확인하거나\n아래에 {engine} 실행 파일의 절대 경로를 설정하세요", - "youtube_engine_unix_issue_message": "macOS/Linux/unix와 같은 운영 체제에서는 .zshrc/.bashrc/.bash_profile 등에 경로 설정이 작동하지 않습니다.\n셸 구성 파일에 경로를 설정해야 합니다", - "download": "다운로드", - "file_not_found": "파일을 찾을 수 없습니다", - "custom": "사용자 정의", - "add_custom_url": "사용자 정의 URL 추가", - "edit_port": "포트 편집", - "port_helper_msg": "기본값은 -1로 무작위 숫자를 나타냅니다. 방화벽이 구성된 경우 이를 설정하는 것이 좋습니다.", - "connect_request": "{client}의 연결을 허용하시겠습니까?", - "connection_request_denied": "연결이 거부되었습니다. 사용자가 액세스를 거부했습니다.", - "hipotetical_calculation": "*이것은 온라인 음악 스트리밍 플랫폼의 스트림당 평균 지불액인 $0.003에서 $0.005를 기준으로 계산됩니다. 이것은 사용자가 다른 음악 스트리밍 플랫폼에서 노래를 들었다면 아티스트에게 얼마를 지불했을지에 대한 통찰력을 제공하기 위한 가상 계산입니다.", - "an_error_occurred": "오류가 발생했습니다", - "copy_to_clipboard": "클립보드에 복사", - "view_logs": "로그 보기", - "retry": "다시 시도", - "no_default_metadata_provider_selected": "기본 메타데이터 제공자가 설정되지 않았습니다", - "manage_metadata_providers": "메타데이터 제공자 관리", - "open_link_in_browser": "브라우저에서 링크를 여시겠습니까?", - "do_you_want_to_open_the_following_link": "다음 링크를 여시겠습니까", - "unsafe_url_warning": "신뢰할 수 없는 출처의 링크를 여는 것은 안전하지 않을 수 있습니다. 주의하세요!\n링크를 클립보드에 복사할 수도 있습니다.", - "copy_link": "링크 복사", - "building_your_timeline": "청취 기록을 기반으로 타임라인을 구축하고 있습니다...", - "official": "공식", - "author_name": "저자: {author}", - "third_party": "타사", - "plugin_requires_authentication": "플러그인에 인증이 필요합니다", - "update_available": "업데이트 사용 가능", - "supports_scrobbling": "스크로블링 지원", - "plugin_scrobbling_info": "이 플러그인은 음악을 스크로블하여 청취 기록을 생성합니다.", - "default_plugin": "기본", - "set_default": "기본값으로 설정", - "support": "지원", - "support_plugin_development": "플러그인 개발 지원", - "can_access_name_api": "- **{name}** API에 액세스할 수 있습니다", - "do_you_want_to_install_this_plugin": "이 플러그인을 설치하시겠습니까?", - "third_party_plugin_warning": "이 플러그인은 타사 리포지토리에서 제공됩니다. 설치하기 전에 출처를 신뢰하는지 확인하세요.", - "author": "저자", - "this_plugin_can_do_following": "이 플러그인은 다음을 수행할 수 있습니다", - "install": "설치", - "install_a_metadata_provider": "메타데이터 제공자 설치", - "no_tracks_playing": "현재 재생 중인 트랙이 없습니다", - "synced_lyrics_not_available": "이 노래에 대한 동기화된 가사를 사용할 수 없습니다. 대신", - "plain_lyrics": "일반 가사", - "tab_instead": "탭을 사용하세요.", - "disclaimer": "면책 조항", - "third_party_plugin_dmca_notice": "Spotube 팀은 어떠한 \"타사\" 플러그인에 대해서도 (법적 포함) 어떠한 책임도 지지 않습니다.\n사용자 자신의 책임하에 사용하시기 바랍니다. 버그/문제에 대해서는 플러그인 리포지토리에 보고해 주세요.\n\n만약 \"타사\" 플러그인이 서비스/법인의 ToS/DMCA를 위반하는 경우, \"타사\" 플러그인 저자 또는 호스팅 플랫폼(예: GitHub/Codeberg)에 조치를 취하도록 요청해 주세요. 위에 나열된 (\"타사\"로 표시된) 플러그인은 모두 공개/커뮤니티에서 유지 관리하는 플러그인입니다. 저희는 이를 큐레이션하지 않으므로 어떠한 조치도 취할 수 없습니다.\n\n", - "input_does_not_match_format": "입력이 필요한 형식과 일치하지 않습니다", - "metadata_provider_plugins": "메타데이터 제공자 플러그인", - "paste_plugin_download_url": "다운로드 URL, GitHub/Codeberg 리포지토리 URL 또는 .smplug 파일에 대한 직접 링크를 붙여넣으세요", - "download_and_install_plugin_from_url": "URL에서 플러그인 다운로드 및 설치", - "failed_to_add_plugin_error": "플러그인 추가 실패: {error}", - "upload_plugin_from_file": "파일에서 플러그인 업로드", - "installed": "설치됨", - "available_plugins": "사용 가능한 플러그인", - "configure_your_own_metadata_plugin": "자신만의 플레이리스트/앨범/아티스트/피드 메타데이터 제공자 구성", - "audio_scrobblers": "오디오 스크로블러", - "scrobbling": "스크로블링", - "download_music_format": "다운로드 음악 포맷", - "streaming_music_format": "스트리밍 음악 포맷", - "download_music_quality": "다운로드 음질", - "streaming_music_quality": "스트리밍 음질", - "default_metadata_source": "기본 메타데이터 소스", - "set_default_metadata_source": "기본 메타데이터 소스 설정", - "default_audio_source": "기본 오디오 소스", - "set_default_audio_source": "기본 오디오 소스 설정", - "plugins": "플러그인", - "configure_plugins": "직접 메타데이터 제공자와 오디오 소스 플러그인을 구성하세요", - "source": "출처: ", - "uncompressed": "비압축", - "dab_music_source_description": "오디오파일을 위한 소스입니다. 고음질/무손실 오디오 스트림을 제공하며 ISRC 기반으로 정확한 트랙 매칭을 지원합니다." -} \ No newline at end of file diff --git a/lib/l10n/app_ne.arb b/lib/l10n/app_ne.arb deleted file mode 100644 index 874c28a5..00000000 --- a/lib/l10n/app_ne.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "अतिथि", - "browse": "ब्राउज़ गर्नुहोस्", - "search": "खोजी गर्नुहोस्", - "library": "पुस्तकालय", - "lyrics": "गीतको शब्द", - "settings": "सेटिङ", - "genre_categories_filter": "शैली वा शैलीहरू फिल्टर गर्नुहोस्...", - "genre": "शैली", - "personalized": "व्यक्तिगत", - "featured": "विशेष", - "new_releases": "नयाँ रिलिज", - "songs": "गीतहरू", - "playing_track": "{track} बज्यो", - "queue_clear_alert": "यो हालको कतारलाई हटाउँछ। {track_length} ट्र्याकहरू हटाईन्छ\nके तपाईं जारी राख्न चाहनुहुन्छ?", - "load_more": "थप लोड गर्नुहोस्", - "playlists": "प्लेलिस्टहरू", - "artists": "कलाकारहरू", - "albums": "आल्बमहरू", - "tracks": "ट्र्याकहरू", - "downloads": "डाउनलोडहरू", - "filter_playlists": "तपाईंको प्लेलिस्टहरू फिल्टर गर्नुहोस्...", - "liked_tracks": "मन परेका ट्र्याकहरू", - "liked_tracks_description": "तपाईंको मन परेका सबै ट्र्याकहरू", - "create_playlist": "प्लेलिस्ट बनाउनुहोस्", - "create_a_playlist": "प्लेलिस्ट बनाउनुहोस्", - "update_playlist": "प्लेलिस्ट अपडेट गर्नुहोस्", - "create": "बनाउनुहोस्", - "cancel": "रद्द गर्नुहोस्", - "update": "अपडेट गर्नुहोस्", - "playlist_name": "प्लेलिस्टको नाम", - "name_of_playlist": "प्लेलिस्टको नाम", - "description": "विवरण", - "public": "सार्वजनिक", - "collaborative": "सहकारी", - "search_local_tracks": "स्थानीय ट्र्याकहरू खोजी गर्नुहोस्...", - "play": "बजाउनुहोस्", - "delete": "मेटाउनुहोस्", - "none": "कुनै पनि होइन", - "sort_a_z": "A-Zमा क्रमबद्ध गर्नुहोस्", - "sort_z_a": "Z-Aमा क्रमबद्ध गर्नुहोस्", - "sort_artist": "कलाकारबाट क्रमबद्ध गर्नुहोस्", - "sort_album": "आल्बमबाट क्रमबद्ध गर्नुहोस्", - "sort_tracks": "ट्र्याकहरूलाई क्रमबद्ध गर्नुहोस्", - "currently_downloading": "हाल डाउनलोड गर्दैछ ({tracks_length})", - "cancel_all": "सब रद्द गर्नुहोस्", - "filter_artist": "कलाकारहरूलाई फिल्टर गर्नुहोस्...", - "followers": "{followers} अनुयायीहरू", - "add_artist_to_blacklist": "कलाकारलाई कालोसूचीमा थप्नुहोस्", - "top_tracks": "शीर्ष ट्र्याकहरू", - "fans_also_like": "अनुयायीहरू पनि लाइक गर्छन्", - "loading": "लोड हुँदैछ...", - "artist": "कलाकार", - "blacklisted": "कालोसूचीमा", - "following": "फल्लो गर्दै", - "follow": "फल्लो गर्नुहोस्", - "artist_url_copied": "कलाकार URL क्लिपबोर्डमा प्रतिलिपि गरिएको छ", - "added_to_queue": "{tracks} ट्र्याकहरूलाई कतारमा थपिएको छ", - "filter_albums": "आल्बमहरूलाई फिल्टर गर्नुहोस्...", - "synced": "सिङ्क गरिएको", - "plain": "साधा", - "shuffle": "शफल", - "search_tracks": "ट्र्याकहरू खोजी गर्नुहोस्...", - "released": "रिलिज गरिएको", - "error": "त्रुटि {error}", - "title": "शीर्षक", - "time": "समय", - "more_actions": "थप कार्यहरू", - "download_count": "डाउनलोड ({count})", - "add_count_to_playlist": "प्लेलिस्टमा थप्नुहोस् ({count})", - "add_count_to_queue": "कतारमा थप्नुहोस् ({count})", - "play_count_next": "प्लेगरी गर्नुहोस् ({count})", - "album": "आल्बम", - "copied_to_clipboard": "{data} क्लिपबोर्डमा प्रतिलिपि गरिएको छ", - "add_to_following_playlists": "{track} लाई तलका प्लेलिस्टमा थप्नुहोस्", - "add": "थप्नुहोस्", - "added_track_to_queue": "{track} लाई कतारमा थपिएको छ", - "add_to_queue": "कतारमा थप्नुहोस्", - "track_will_play_next": "{track} अरूलाई पहिलोमा बज्नेछ", - "play_next": "पछिबजाउनुहोस्", - "removed_track_from_queue": "{track} लाई कतारबाट हटाइएको छ", - "remove_from_queue": "कतारबाट हटाउनुहोस्", - "remove_from_favorites": "पसन्दीदामा बाट हटाउनुहोस्", - "save_as_favorite": "पसन्दीदा बनाउनुहोस्", - "add_to_playlist": "प्लेलिस्टमा थप्नुहोस्", - "remove_from_playlist": "प्लेलिस्टबाट हटाउनुहोस्", - "add_to_blacklist": "कालोसूचीमा थप्नुहोस्", - "remove_from_blacklist": "कालोसूचीबाट हटाउनुहोस्", - "share": "साझा गर्नुहोस्", - "mini_player": "मिनि प्लेयर", - "slide_to_seek": "अगाडि वा पछाडि खोजी गर्नका लागि स्लाइड गर्नुहोस्", - "shuffle_playlist": "प्लेलिस्ट शफल गर्नुहोस्", - "unshuffle_playlist": "प्लेलिस्ट शफल नगर्नुहोस्", - "previous_track": "पूर्व ट्र्याक", - "next_track": "अरू ट्र्याक", - "pause_playback": "प्लेब्याक रोक्नुहोस्", - "resume_playback": "प्लेब्याक पुनः सुरु गर्नुहोस्", - "loop_track": "ट्र्याकलाई दोहोरोपट्टी बजाउनुहोस्", - "repeat_playlist": "प्लेलिस्ट पुनः बजाउनुहोस्", - "queue": "कतार", - "alternative_track_sources": "वैकल्पिक ट्र्याक स्रोतहरू", - "download_track": "ट्र्याक डाउनलोड गर्नुहोस्", - "tracks_in_queue": "कतारमा {tracks} ट्र्याकहरू", - "clear_all": "सब मेटाउनुहोस्", - "show_hide_ui_on_hover": "हवर गरेपछि UI देखाउनुहोस्/लुकाउनुहोस्", - "always_on_top": "सधैं टपमा राख्नुहोस्", - "exit_mini_player": "मिनि प्लेयर बाट बाहिर निस्कनुहोस्", - "download_location": "डाउनलोड स्थान", - "account": "खाता", - "login_with_spotify": "तपाईंको Spotify खातासँग लगइन गर्नुहोस्", - "connect_with_spotify": "Spotify सँग जडान गर्नुहोस्", - "logout": "बाहिर निस्कनुहोस्", - "logout_of_this_account": "यो खाताबाट बाहिर निस्कनुहोस्", - "language_region": "भाषा र क्षेत्र", - "language": "भाषा", - "system_default": "सिस्टम पूर्वनिर्धारित", - "market_place_region": "बजार स्थान", - "recommendation_country": "सिफारिस गरिएको देश", - "appearance": "दृष्टिकोण", - "layout_mode": "लेआउट मोड", - "override_layout_settings": "अनुकूलित प्रतिकृयात्मक लेआउट मोड सेटिङ्गहरू", - "adaptive": "अनुकूलित", - "compact": "संकुचित", - "extended": "बढाइएको", - "theme": "थिम", - "dark": "गाढा", - "light": "प्रकाश", - "system": "सिस्टम", - "accent_color": "एक्सेन्ट रङ्ग", - "sync_album_color": "एल्बम रङ्ग सिङ्क गर्नुहोस्", - "sync_album_color_description": "एल्बम कला को प्रमुख रङ्गलाई एक्सेन्ट रङ्गको रूपमा प्रयोग गर्दछ", - "playback": "प्लेब्याक", - "audio_quality": "आडियो गुणस्तर", - "high": "उच्च", - "low": "न्यून", - "pre_download_play": "पूर्व-डाउनलोड र प्ले गर्नुहोस्", - "pre_download_play_description": "आडियो स्ट्रिम गर्नु नगरी बाइटहरू डाउनलोड गरी बजाउँछ (उच्च ब्यान्डविथ उपयोगकर्ताहरूको लागि सिफारिस गरिएको)", - "skip_non_music": "गीतहरू बाहेक कुनै अनुष्ठान छोड्नुहोस् (स्पन्सरब्लक)", - "blacklist_description": "कालोसूची गीत र कलाकारहरू", - "wait_for_download_to_finish": "कृपया हालको डाउनलोड समाप्त हुन लागि पर्खनुहोस्", - "desktop": "डेस्कटप", - "close_behavior": "बन्द व्यवहार", - "close": "बन्द गर्नुहोस्", - "minimize_to_tray": "ट्रेमा कम गर्नुहोस्", - "show_tray_icon": "सिस्टम ट्रे आइकन देखाउनुहोस्", - "about": "बारेमा", - "u_love_spotube": "हामीले थाहा पारेका छौं तपाईंलाई Spotube मन पर्छ", - "check_for_updates": "अपडेटहरूको लागि जाँच गर्नुहोस्", - "about_spotube": "Spotube को बारेमा", - "blacklist": "कालोसूची", - "please_sponsor": "कृपया स्पन्सर/डोनेट गर्नुहोस्", - "spotube_description": "Spotube, एक हल्का, समृद्ध, स्वतन्त्र Spotify क्लाइयन", - "version": "संस्करण", - "build_number": "निर्माण नम्बर", - "founder": "संस्थापक", - "repository": "पुनरावलोकन स्थल", - "bug_issues": "त्रुटि + समस्याहरू", - "made_with": "❤️ 2021-2024 बाट बनाइएको", - "kingkor_roy_tirtho": "किङ्कोर राय तिर्थो", - "copyright": "© 2021-{current_year} किङ्कोर राय तिर्थो", - "license": "लाइसेन्स", - "add_spotify_credentials": "सुरु हुनका लागि तपाईंको स्पटिफाई क्रेडेन्शियल थप्नुहोस्", - "credentials_will_not_be_shared_disclaimer": "चिन्ता नगर्नुहोस्, तपाईंको कुनै पनि क्रेडेन्शियलहरूले कसैले संग्रह वा साझा गर्नेछैन", - "know_how_to_login": "कसरी लगिन गर्ने भन्ने थाहा छैन?", - "follow_step_by_step_guide": "चरणबद्ध मार्गदर्शनमा साथी बनाउनुहोस्", - "spotify_cookie": "Spotify {name} कुकी", - "cookie_name_cookie": "{name} कुकी", - "fill_in_all_fields": "कृपया सबै क्षेत्रहरू भर्नुहोस्", - "submit": "पेश गर्नुहोस्", - "exit": "बाहिर निस्कनुहोस्", - "previous": "पूर्ववत", - "next": "अरू", - "done": "गरिएको", - "step_1": "कदम 1", - "first_go_to": "पहिलो, जानुहोस्", - "login_if_not_logged_in": "र लगइन/साइनअप गर्नुहोस् जुन तपाईंले लगइन गरेनन्", - "step_2": "कदम 2", - "step_2_steps": "1. एकबार तपाईं लगइन गरे पछि, F12 थिच्नुहोस् वा माउस राइट क्लिक गर्नुहोस् > इन्स्पेक्ट गर्नुहोस् भने ब्राउजर डेभटुलहरू खुलाउनका लागि।\n2. तपाईंको \"एप्लिकेसन\" ट्याबमा जानुहोस् (Chrome, Edge, Brave इत्यादि) वा \"स्टोरेज\" ट्याबमा जानुहोस् (Firefox, Palemoon इत्यादि)\n3. तपाईंको इन्सेक्ट गरेको ब्राउजर डेभटुलहरूमा \"कुकीहरू\" खण्डमा जानुहोस् अनि \"https://accounts.spotify.com\" उपकोणमा जानुहोस्", - "step_3": "कदम 3", - "step_3_steps": "\"sp_dc\" र \"sp_key\" (वा sp_gaid) कुकीहरूको मानहरू प्रतिलिपि गर्नुहोस्", - "success_emoji": "सफलता 🥳", - "success_message": "हाम्रो सानो भाइ, अब तपाईं सफलतापूर्वक आफ्नो Spotify खातामा लगइन गरेका छौं। राम्रो काम गरेको!", - "step_4": "कदम 4", - "step_4_steps": "प्रतिलिपि गरेको \"sp_dc\" र \"sp_key\" (वा sp_gaid) मानहरूलाई आफ्नो ठाउँमा पेस्ट गर्नुहोस्", - "something_went_wrong": "केहि गल्ति भएको छ", - "piped_instance": "पाइपड सर्भर इन्स्ट्यान्स", - "piped_description": "गीत मिलाउको लागि प्रयोग गर्ने पाइपड सर्भर इन्स्ट्यान्स", - "piped_warning": "तिनीहरूमध्ये केहि ठिक गर्न सक्छ। यसलाई आफ्नो जोखिममा प्रयोग गर्नुहोस्", - "generate_playlist": "प्लेलिस्ट बनाउनुहोस्", - "track_exists": "ट्र्याक {track} पहिले नै छ", - "replace_downloaded_tracks": "सबै डाउनलोड गरिएका ट्र्याकहरूलाई परिवर्तन गर्नुहोस्", - "skip_download_tracks": "सबै डाउनलोड गरिएका ट्र्याकहरूलाई छोड्नुहोस्", - "do_you_want_to_replace": "के तपाईंले वर्तमान ट्र्याकलाई परिवर्तन गर्न चाहनुहुन्छ?", - "replace": "परिवर्तन गर्नुहोस्", - "skip": "छोड्नुहोस्", - "select_up_to_count_type": "{count} {type} सम्म चयन गर्नुहोस्", - "select_genres": "जनरहरू चयन गर्नुहोस्", - "add_genres": "जनरहरू थप्नुहोस्", - "country": "देश", - "number_of_tracks_generate": "बनाउनका लागि ट्र्याकहरूको संख्या", - "acousticness": "एकोस्टिकनेस", - "danceability": "नृत्यक्षमता", - "energy": "ऊर्जा", - "instrumentalness": "साजा रहेकोता", - "liveness": "प्राणिकता", - "loudness": "शोर", - "speechiness": "भाषण", - "valence": "मानसिक स्वभाव", - "popularity": "लोकप्रियता", - "key": "कुञ्जी", - "duration": "अवधि (सेकेण्ड)", - "tempo": "गति (बीपीएम)", - "mode": "मोड", - "time_signature": "समय हस्ताक्षर", - "short": "सानो", - "medium": "मध्यम", - "long": "लामो", - "min": "न्यून", - "max": "अधिक", - "target": "लक्ष्य", - "moderate": "मध्यस्थ", - "deselect_all": "सबै छान्नुहोस्", - "select_all": "सबै चयन गर्नुहोस्", - "are_you_sure": "के तपाईं सुनिश्चित हुनुहुन्छ?", - "generating_playlist": "तपाईंको विशेष प्लेलिस्ट बनाइएको छ...", - "selected_count_tracks": "{count} ट्र्याकहरू छन् चयन गरिएका", - "download_warning": "यदि तपाईं सबै ट्र्याकहरूलाई बल्कमा डाउनलोड गर्छनु हो भने तपाईं स्पष्ट रूपमा साङ्गीत चोरी गरिरहेका छन् र यो साङ्गीतको रचनात्मक समाजलाई क्षति पनि पुर्याउँछ। उमेराइएको छ कि तपाईं यसको बारेमा जागरूक छिनुहुन्छ। सधैं, कला गर्दै र कलाकारको कडा परम्परा समर्थन गर्दै आइन्छ।", - "download_ip_ban_warning": "बितिएका डाउनलोड अनुरोधहरूका कारण तपाईंको आइपीले YouTube मा ब्लक हुन सक्छ। आइपी ब्लक भनेको कम्तीमा 2-3 महिनासम्म तपाईं त्यस आइपी यन्त्रबाट YouTube प्रयोग गर्न सक्नुहुन्छ। र यदि यो हुँदैछ भने स्पट्यूबले यसलाई कसैले गरेको बारेमा कुनै दायित्व लिन्छैन।", - "by_clicking_accept_terms": "'स्वीकृत' गरेर तपाईं निम्नलिखित निर्वाचन गर्दैछिन्:", - "download_agreement_1": "म मन्ने छु कि म साङ्गीत चोरी गरिरहेको छु। म बुरो हुँ", - "download_agreement_2": "म कहिल्यै कहिल्यै तिनीहरूलाई समर्थन गर्नेछु र म यो तिनीहरूको कला किन्ने पैसा छैन भने मा मात्र यो गरेको छु", - "download_agreement_3": "म पूरा रूपमा जान्छु कि मेरो आइपी YouTube मा ब्लक हुन सक्छ र म मन्छेहरूले मेरो चासोबाट भएको कुनै दुर्घटनामा स्पट्यूब वा तिनीहरूको मालिकहरू/सहयोगीहरूलाई दायित्वी ठान्छुँभन्ने पूर्ण जानकारी छैन", - "decline": "अस्वीकृत", - "accept": "स्वीकृत", - "details": "विवरण", - "youtube": "YouTube", - "channel": "च्यानल", - "likes": "लाइकहरू", - "dislikes": "असुनुहरू", - "views": "हेरिएको", - "streamUrl": "स्ट्रिम यूआरएल", - "stop": "रोक्नुहोस्", - "sort_newest": "नयाँ थपिएकोमा क्रमबद्ध गर्नुहोस्", - "sort_oldest": "पुरानो थपिएकोमा क्रमबद्ध गर्नुहोस्", - "sleep_timer": "सुत्ने टाइमर", - "mins": "{minutes} मिनेटहरू", - "hours": "{hours} घण्टाहरू", - "hour": "{hours} घण्टा", - "custom_hours": "कस्टम घण्टाहरू", - "logs": "लगहरू", - "developers": "डेभेलपर्स", - "not_logged_in": "तपाईंले लगइन गरेका छैनौं", - "search_mode": "खोज मोड", - "audio_source": "अडियो स्रोत", - "ok": "ठिक छ", - "failed_to_encrypt": "एन्क्रिप्ट गर्न सकिएन", - "encryption_failed_warning": "स्पट्यूबले तपाईंको डेटा सुरक्षित रूपमा स्टोर गर्नका लागि एन्क्रिप्ट गर्न खोजेको छ। तर यसले गरेको छैन। यसले असुरक्षित स्टोरेजमा फल्लब्याक गर्दछ\nयदि तपाईंले लिनक्स प्रयोग गरिरहेका छन् भने कृपया सुनिश्चित गर्नुहोस् कि तपाईंले कुनै सीक्रेट-सर्भिस (गोनोम-किरिङ, केडीइ-वालेट, किपासेक्ससि इत्यादि) इन्स्टल गरेका छौं", - "querying_info": "जानकारी हेर्दै...", - "piped_api_down": "पाइपड एपीआई डाउन छ", - "piped_down_error_instructions": "पाइपड इन्स्ट्यान्स {pipedInstance} हाल डाउन छ\n\nजीसनै इन्स्ट्यान्स परिवर्तन गर्नुहोस् वा 'एपीआई प्रकार' लाइ YouTube आफिसियल एपीआईमा परिवर्तन गर्नुहोस्\n\nपरिवर्तनपछि एप्लिकेसन पुन: सुरु गर्नुहोस्", - "you_are_offline": "तपाईं वर्तमान अफलाइन हुनुहुन्छ", - "connection_restored": "तपाईंको इन्टरनेट कनेक्सन पुन: स्थापित भएको छ", - "use_system_title_bar": "सिस्टम शीर्षक पट्टी प्रयोग गर्नुहोस्", - "crunching_results": "परिणामहरू कपालबाट पीस्दै...", - "search_to_get_results": "परिणामहरू प्राप्त गर्नका लागि खोज्नुहोस्", - "use_amoled_mode": "कृष्ण ब्ल्याक गाढा थिम प्रयोग गर्नुहोस्", - "pitch_dark_theme": "एमोलेड मोड", - "normalize_audio": "अडियो सामान्य गर्नुहोस्", - "change_cover": "कवर परिवर्तन गर्नुहोस्", - "add_cover": "कवर थप्नुहोस्", - "restore_defaults": "पूर्वनिर्धारितहरू पुनः स्थापित गर्नुहोस्", - "download_music_codec": "साङ्गीत कोडेक डाउनलोड गर्नुहोस्", - "streaming_music_codec": "स्ट्रिमिङ साङ्गीत कोडेक", - "login_with_lastfm": "लास्ट.एफ.एम सँग लगइन गर्नुहोस्", - "connect": "जडान गर्नुहोस्", - "disconnect_lastfm": "लास्ट.एफ.एम डिसकनेक्ट गर्नुहोस्", - "disconnect": "डिसकनेक्ट", - "username": "प्रयोगकर्ता नाम", - "password": "पासवर्ड", - "login": "लगइन", - "login_with_your_lastfm": "तपाईंको लास्ट.एफ.एम खातामा लगइन गर्नुहोस्", - "scrobble_to_lastfm": "लास्ट.एफ.एम मा स्क्रबल गर्नुहोस्", - "go_to_album": "आल्बममा जानुहोस्", - "discord_rich_presence": "डिस्कर्ड धनी उपस्थिति", - "browse_all": "सबै हेर्नुहोस्", - "genres": "शैलीहरू", - "explore_genres": "शैलीहरू अन्वेषण गर्नुहोस्", - "friends": "साथीहरू", - "no_lyrics_available": "क्षमा गर्दैछौं, यस ट्र्याकका लागि गीतका शब्दहरू फेला परेन", - "sort_duration": "अवधिको अनुसार क्रमबद्ध गर्नुहोस्", - "start_a_radio": "रेडियो सुरु गर्नुहोस्", - "how_to_start_radio": "तपाईं रेडियो कसरी सुरु गर्न चाहानुहुन्छ?", - "replace_queue_question": "के तपाईं वर्तमान कताक्ष कोट बदल्न चाहानुहुन्छ वा यसलाई थप्नुहुन्छ?", - "endless_playback": "अनन्त प्लेब्याक", - "delete_playlist": "प्लेलिस्ट मेटाउनुहोस्", - "delete_playlist_confirmation": "के तपाईं यो प्लेलिस्ट मेटाउन निश्चित हुनुहुन्छ?", - "local_tracks": "स्थानिय ट्र्याकहरू", - "song_link": "गीत लिंक", - "skip_this_nonsense": "यस अबश्यकता छोड्नुहोस्", - "freedom_of_music": "“संगीतको स्वतन्त्रता”", - "freedom_of_music_palm": "“तपाईंको हातमा संगीतको स्वतन्त्रता”", - "get_started": "आइयाँ प्रारम्भ गरौं", - "youtube_source_description": "सिफारिस गरिएको र बेस्ट काम गर्दछ।", - "piped_source_description": "मुक्त सुस्त? YouTube जस्तै तर धेरै मुक्त।", - "jiosaavn_source_description": "दक्षिण एशियाली क्षेत्रको लागि सर्वोत्तम।", - "highest_quality": "उच्चतम गुणस्तर: {quality}", - "select_audio_source": "आडियो स्रोत चयन गर्नुहोस्", - "endless_playback_description": "नयाँ गीतहरूलाई स्वचालित रूपमा कताक्षको अन्तमा जोड्नुहोस्", - "choose_your_region": "तपाईंको क्षेत्र छनौट गर्नुहोस्", - "choose_your_region_description": "यो Spotubeलाई तपाईंको स्थानका लागि सहि सामग्री देखाउने मद्दत गर्नेछ।", - "choose_your_language": "तपाईंको भाषा छनौट गर्नुहोस्", - "help_project_grow": "यस परियोजनामा वृद्धि गराउनुहोस्", - "help_project_grow_description": "Spotube एक खुला स्रोतको परियोजना हो। तपाईं परियोजनामा योगदान गरेर, त्रुटिहरू सूचिकै, वा नयाँ सुविधाहरू सुझाव दिएर यस परियोजनामा वृद्धि गर्न सक्नुहुन्छ।", - "contribute_on_github": "GitHubमा योगदान गर्नुहोस्", - "donate_on_open_collective": "खुला संगठनमा दान गर्नुहोस्", - "browse_anonymously": "अनामित रूपमा ब्राउज़ गर्नुहोस्", - "enable_connect": "कनेक्ट सक्रिय गर्नुहोस्", - "enable_connect_description": "अन्य उपकरणहरूबाट Spotube कन्ट्रोल गर्नुहोस्", - "devices": "उपकरणहरू", - "select": "चयन गर्नुहोस्", - "connect_client_alert": "तपाईंलाई {client} द्वारा नियन्त्रित गरिएको छ", - "this_device": "यो उपकरण", - "remote": "दूरसंचार", - "local_library": "स्थानिय पुस्तकालय", - "add_library_location": "पुस्तकालयमा थप्नुहोस्", - "remove_library_location": "पुस्तकालयबाट हटाउनुहोस्", - "local_tab": "स्थानिय", - "stats": "तथ्याङ्क", - "and_n_more": "राम्रो {count} थप", - "recently_played": "हालै खेलेको", - "browse_more": "थप हेर्नुहोस्", - "no_title": "शीर्षक छैन", - "not_playing": "खेलिरहेको छैन", - "epic_failure": "महाकवि असफलता!", - "added_num_tracks_to_queue": "{tracks_length} ट्र्याकहरू तालिकामा थपिएका छन्", - "spotube_has_an_update": "Spotube मा अपडेट छ", - "download_now": "अहिले डाउनलोड गर्नुहोस्", - "nightly_version": "Spotube Nightly {nightlyBuildNum} रिलिज गरिएको छ", - "release_version": "Spotube v{version} रिलिज गरिएको छ", - "read_the_latest": "अर्को ", - "release_notes": "रिलिज नोटहरू", - "pick_color_scheme": "रंग योजना चयन गर्नुहोस्", - "save": "सुरक्षित गर्नुहोस्", - "choose_the_device": "उपकरण चयन गर्नुहोस्:", - "multiple_device_connected": "धेरै उपकरण जडान गरिएको छ।\nयो क्रियाकलाप गर्ने उपकरण चयन गर्नुहोस्", - "nothing_found": "केही फेला परेन", - "the_box_is_empty": "बक्स खाली छ", - "top_artists": "शीर्ष कलाकारहरू", - "top_albums": "शीर्ष एल्बमहरू", - "this_week": "यो हप्ता", - "this_month": "यो महिना", - "last_6_months": "पछिल्लो ६ महिना", - "this_year": "यो वर्ष", - "last_2_years": "पछिल्लो २ वर्ष", - "all_time": "सबै समय", - "powered_by_provider": "{providerName} द्वारा शक्ति प्राप्त", - "email": "ईमेल", - "profile_followers": "अनुयायीहरू", - "birthday": "जन्मदिन", - "subscription": "सदस्यता", - "not_born": "जन्मिएको छैन", - "hacker": "ह्याकर", - "profile": "प्रोफाइल", - "no_name": "नाम छैन", - "edit": "सम्पादन गर्नुहोस्", - "user_profile": "प्रयोगकर्ता प्रोफाइल", - "count_plays": "{count} खेलाइन्छ", - "streaming_fees_hypothetical": "*यो Spotify को प्रति स्ट्रिमको आधारमा गणना गरिएको छ\n$0.003 देखि $0.005 बीचको भुक्तानी। यो एक काल्पनिक गणना हो\nउपयोगकर्तालाई यो थाहा दिनको लागि कि उनीहरूले अर्टिस्टहरूलाई\nSpotify मा गीत सुनेको भए कति भुक्तानी गर्ने थिए।", - "count_mins": "{minutes} मिनेट", - "summary_minutes": "मिनेट", - "summary_listened_to_music": "सङ्गीत सुन्नु", - "summary_songs": "गीतहरू", - "summary_streamed_overall": "सामान्य रूपले स्ट्रीम गरिएको", - "summary_owed_to_artists": "यस महिना कलाकारहरूलाई देन", - "summary_artists": "कलाकारको", - "summary_music_reached_you": "सङ्गीत तपाईंलाई पुग्यो", - "summary_full_albums": "पूर्ण एल्बमहरू", - "summary_got_your_love": "तपाईंको माया प्राप्त गरियो", - "summary_playlists": "प्लेइस्ट", - "summary_were_on_repeat": "पुनरावृत्ति गरियो", - "total_money": "कुल {money}", - "minutes_listened": "सुनिएका मिनेटहरू", - "streamed_songs": "स्ट्रीम गरिएका गीतहरू", - "count_streams": "{count} स्ट्रिम", - "owned_by_you": "तपाईंले स्वामित्व गरेको", - "copied_shareurl_to_clipboard": "{shareUrl} क्लिपबोर्डमा कपी गरियो", - "spotify_hipotetical_calculation": "*यो Spotify को प्रति स्ट्रीम भुगतानको आधारमा\n$0.003 देखि $0.005 को बीचमा गणना गरिएको हो। यो एक काल्पनिक\nगणना हो जसले प्रयोगकर्तालाई देखाउँछ कि उनीहरूले कति\nअर्टिस्टहरूलाई तिनीहरूका गीतहरू Spotify मा सुनेमा\nभुक्तान गर्नुपर्ने थियो।", - "webview_not_found": "वेबभ्यू फेला परेन", - "webview_not_found_description": "तपाईंको उपकरणमा कुनै वेबभ्यू रनटाइम स्थापना गरिएको छैन।\nयदि स्थापना गरिएको छ भने, environment PATH मा छ कि छैन भनेर सुनिश्चित गर्नुहोस्\n\nस्थापना पछि, अनुप्रयोग पुनः सुरु गर्नुहोस्", - "unsupported_platform": "असमर्थित प्लेटफार्म", - "invidious_instance": "Invidious सर्भर इन्स्टेन्स", - "invidious_description": "ट्र्याक मिलाउनका लागि प्रयोग हुने Invidious सर्भर इन्स्टेन्स", - "invidious_warning": "केहीले राम्रोसँग काम नगर्न सक्छ। आफ्नो जोखिममा प्रयोग गर्नुहोस्", - "invidious_source_description": "Piped जस्तै तर उच्च उपलब्धतासँग।", - "cache_music": "सङ्गीत क्यास गर्नुहोस्", - "open": "खोल्नुहोस्", - "cache_folder": "क्यास फोल्डर", - "export": "निर्यात गर्नुहोस्", - "clear_cache": "क्यास खाली गर्नुहोस्", - "clear_cache_confirmation": "के तपाई क्यास खाली गर्न चाहनुहुन्छ?", - "export_cache_files": "क्यास फाइलहरू निर्यात गर्नुहोस्", - "found_n_files": "{count} फाइलहरू फेला परे", - "export_cache_confirmation": "यी फाइलहरू निर्यात गर्न चाहनुहुन्छ", - "exported_n_out_of_m_files": "{filesExported} मध्ये {files} फाइलहरू निर्यात गरियो", - "playlist": "प्लेलिस्ट", - "no_loop": "कोई लूप नहीं", - "generate": "जनरेट", - "undo": "पूर्ववत", - "download_all": "सभी डाउनलोड करें", - "add_all_to_playlist": "सभी को प्लेलिस्ट में जोड़ें", - "add_all_to_queue": "सभी को कतार में जोड़ें", - "play_all_next": "सभी को अगला प्ले करें", - "pause": "विराम", - "view_all": "सभी देखें", - "no_tracks_added_yet": "लगता है आपने अभी तक कोई ट्रैक नहीं जोड़ा है", - "no_tracks": "यहाँ कोई ट्रैक नहीं दिख रहे हैं", - "no_tracks_listened_yet": "आपने अभी तक कुछ नहीं सुना है ऐसा लगता है", - "not_following_artists": "आप किसी कलाकार को फॉलो नहीं कर रहे हैं", - "no_favorite_albums_yet": "लगता है आपने अभी तक कोई एल्बम पसंदीदा में नहीं जोड़ा है", - "no_logs_found": "कोई लॉग नहीं मिला", - "youtube_engine": "YouTube इंजन", - "youtube_engine_not_installed_title": "{engine} इंस्टॉल नहीं है", - "youtube_engine_not_installed_message": "{engine} आपके सिस्टम में इंस्टॉल नहीं है।", - "youtube_engine_set_path": "सुनिश्चित करें कि यह PATH वेरिएबल में उपलब्ध है या\nनीचे {engine} एक्जीक्यूटेबल का पूर्ण पथ सेट करें", - "youtube_engine_unix_issue_message": "macOS/Linux/unix जैसे ऑपरेटिंग सिस्टम में, .zshrc/.bashrc/.bash_profile आदि में पथ सेट करना काम नहीं करेगा।\nआपको शेल कॉन्फ़िगरेशन फ़ाइल में पथ सेट करना होगा", - "download": "डाउनलोड", - "file_not_found": "फ़ाइल नहीं मिली", - "custom": "कस्टम", - "add_custom_url": "कस्टम URL जोड़ें", - "edit_port": "पोर्ट सम्पादन गर्नुहोस्", - "port_helper_msg": "डिफ़ॉल्ट -1 हो जुन यादृच्छिक संख्या जनाउँछ। यदि तपाईंले फायरवाल कन्फिगर गर्नुभएको छ भने, यसलाई सेट गर्न सिफारिस गरिन्छ।", - "connect_request": "{client} लाई जडान गर्न अनुमति दिनुहोस्?", - "connection_request_denied": "जडान अस्वीकृत। प्रयोगकर्ताले पहुँच अस्वीकृत गर्यो।", - "hipotetical_calculation": "*यो अनलाइन संगीत स्ट्रिमिङ प्लेटफर्मको प्रति स्ट्रिम भुक्तानी $0.003 देखि $0.005 को औसतमा आधारित छ। यो एक काल्पनिक गणना हो जुन प्रयोगकर्तालाई उनीहरूले विभिन्न संगीत स्ट्रिमिङ प्लेटफर्ममा आफ्ना गीतहरू सुनेमा कलाकारहरूलाई कति भुक्तानी गर्ने थिए भन्ने बारेमा अन्तरदृष्टि दिनको लागि हो।", - "an_error_occurred": "त्रुटि भयो", - "copy_to_clipboard": "क्लिपबोर्डमा प्रतिलिपि गर्नुहोस्", - "view_logs": "लगहरू हेर्नुहोस्", - "retry": "पुनः प्रयास गर्नुहोस्", - "no_default_metadata_provider_selected": "तपाईंले कुनै पूर्वनिर्धारित मेटाडेटा प्रदायक सेट गर्नुभएको छैन", - "manage_metadata_providers": "मेटाडेटा प्रदायकहरू प्रबन्ध गर्नुहोस्", - "open_link_in_browser": "ब्राउजरमा लिङ्क खोल्ने?", - "do_you_want_to_open_the_following_link": "के तपाईं निम्न लिङ्क खोल्न चाहनुहुन्छ", - "unsafe_url_warning": "अविश्वसनीय स्रोतहरूबाट लिङ्कहरू खोल्नु असुरक्षित हुन सक्छ। सावधान रहनुहोस्!\nतपाईं लिङ्कलाई आफ्नो क्लिपबोर्डमा पनि प्रतिलिपि गर्न सक्नुहुन्छ।", - "copy_link": "लिङ्क प्रतिलिपि गर्नुहोस्", - "building_your_timeline": "तपाईंको सुन्ने आधारमा तपाईंको समयरेखा निर्माण गर्दै...", - "official": "आधिकारिक", - "author_name": "लेखक: {author}", - "third_party": "तेस्रो-पक्ष", - "plugin_requires_authentication": "प्लगइनलाई प्रमाणीकरण चाहिन्छ", - "update_available": "अपडेट उपलब्ध छ", - "supports_scrobbling": "स्क्रब्बलिंगलाई समर्थन गर्दछ", - "plugin_scrobbling_info": "यो प्लगइनले तपाईंको सुन्ने इतिहास उत्पन्न गर्न तपाईंको संगीतलाई स्क्रब्बल गर्दछ।", - "default_plugin": "पूर्वनिर्धारित", - "set_default": "पूर्वनिर्धारित सेट गर्नुहोस्", - "support": "समर्थन", - "support_plugin_development": "प्लगइन विकासलाई समर्थन गर्नुहोस्", - "can_access_name_api": "- **{name}** API मा पहुँच गर्न सक्छ", - "do_you_want_to_install_this_plugin": "के तपाईं यो प्लगइन स्थापना गर्न चाहनुहुन्छ?", - "third_party_plugin_warning": "यो प्लगइन तेस्रो-पक्ष रिपोसिटरीबाट हो। कृपया स्थापना गर्नु अघि तपाईंले स्रोतमा विश्वास गर्नुहुन्छ भनी सुनिश्चित गर्नुहोस्।", - "author": "लेखक", - "this_plugin_can_do_following": "यो प्लगइनले निम्न गर्न सक्छ", - "install": "स्थापना गर्नुहोस्", - "install_a_metadata_provider": "मेटाडेटा प्रदायक स्थापना गर्नुहोस्", - "no_tracks_playing": "हाल कुनै ट्र्याक बजिरहेको छैन", - "synced_lyrics_not_available": "यो गीतको लागि सिङ्क गरिएका बोलहरू उपलब्ध छैनन्। कृपया यसको सट्टा", - "plain_lyrics": "सादा बोलहरू", - "tab_instead": "ट्याब प्रयोग गर्नुहोस्।", - "disclaimer": "अस्वीकरण", - "third_party_plugin_dmca_notice": "स्पोट्यूब टोलीले कुनै पनि \"तेस्रो-पक्ष\" प्लगइनहरूको लागि कुनै जिम्मेवारी (कानुनी सहित) लिँदैन।\nकृपया तिनीहरूलाई आफ्नो जोखिममा प्रयोग गर्नुहोस्। कुनै पनि बग/समस्याहरूको लागि, कृपया तिनीहरूलाई प्लगइन रिपोसिटरीमा रिपोर्ट गर्नुहोस्।\n\nयदि कुनै \"तेस्रो-पक्ष\" प्लगइनले कुनै सेवा/कानुनी संस्थाको ToS/DMCA तोडिरहेको छ भने, कृपया \"तेस्रो-पक्ष\" प्लगइन लेखक वा होस्टिङ प्लेटफर्म e.g. GitHub/Codeberg लाई कारबाही गर्न अनुरोध गर्नुहोस्। माथि सूचीबद्ध (\"तेस्रो-पक्ष\" लेबल गरिएका) सबै सार्वजनिक/सामुदायिक रूपमा राखिएका प्लगइनहरू हुन्। हामी तिनीहरूलाई क्युरेट गरिरहेका छैनौं, त्यसैले हामी तिनीहरूमा कुनै कारबाही गर्न सक्दैनौं।\n\n", - "input_does_not_match_format": "इनपुट आवश्यक ढाँचासँग मेल खाँदैन", - "metadata_provider_plugins": "मेटाडेटा प्रदायक प्लगइनहरू", - "paste_plugin_download_url": "डाउनलोड url वा GitHub/Codeberg repo url वा .smplug फाइलमा सिधा लिङ्क टाँस्नुहोस्", - "download_and_install_plugin_from_url": "url बाट प्लगइन डाउनलोड र स्थापना गर्नुहोस्", - "failed_to_add_plugin_error": "प्लगइन थप्न असफल: {error}", - "upload_plugin_from_file": "फाइलबाट प्लगइन अपलोड गर्नुहोस्", - "installed": "स्थापित", - "available_plugins": "उपलब्ध प्लगइनहरू", - "configure_your_own_metadata_plugin": "तपाईंको आफ्नै प्लेलिस्ट/एल्बम/कलाकार/फिड मेटाडेटा प्रदायक कन्फिगर गर्नुहोस्", - "audio_scrobblers": "अडियो स्क्रब्बलरहरू", - "scrobbling": "स्क्रब्बलिंग", - "download_music_format": "सङ्गीत डाउनलोड ढाँचा", - "streaming_music_format": "स्ट्रिमिङ सङ्गीत ढाँचा", - "download_music_quality": "डाउनलोड गुणस्तर", - "streaming_music_quality": "स्ट्रिमिङ गुणस्तर", - "default_metadata_source": "पूर्वनिर्धारित मेटाडाटा स्रोत", - "set_default_metadata_source": "पूर्वनिर्धारित मेटाडाटा स्रोत सेट गर्नुहोस्", - "default_audio_source": "पूर्वनिर्धारित अडियो स्रोत", - "set_default_audio_source": "पूर्वनिर्धारित अडियो स्रोत सेट गर्नुहोस्", - "plugins": "प्लगइनहरू", - "configure_plugins": "आफ्नै मेटाडाटा प्रदायक र अडियो स्रोत प्लगइनहरू कन्फिगर गर्नुहोस्", - "source": "स्रोत: ", - "uncompressed": "असंक्षिप्त", - "dab_music_source_description": "अडियोप्रेमीहरूका लागि। उच्च गुणस्तर/लसलेस अडियो स्ट्रिमहरू उपलब्ध गराउँछ। ISRC-मा आधारित सटीक ट्र्याक मिलान।" -} \ No newline at end of file diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb deleted file mode 100644 index 4d8deac1..00000000 --- a/lib/l10n/app_nl.arb +++ /dev/null @@ -1,495 +0,0 @@ -{ - "guest": "Gast", - "browse": "Bladeren", - "search": "Zoeken", - "library": "Bibliotheek", - "lyrics": "Teksten", - "settings": "Instellingen", - "genre_categories_filter": "Categorieën of genres filteren…", - "genre": "Genre", - "personalized": "Gepersonaliseerd", - "featured": "Aanbevolen", - "new_releases": "Nieuwe uitgaven", - "songs": "Liedjes", - "playing_track": "{track} afspelen", - "queue_clear_alert": "Dit zal de huidige wachtrij wissen. {track_length} nummers worden verwijderd\nWil je doorgaan?", - "load_more": "Meer laden", - "playlists": "Afspeellijsten", - "artists": "Artiesten", - "albums": "Albums", - "tracks": "Nummers", - "downloads": "Downloads", - "filter_playlists": "Afspeellijsten filteren…", - "liked_tracks": "Geliefde tracks", - "liked_tracks_description": "Al je favoriete nummers", - "create_playlist": "Afspeellijst aanmaken", - "create_a_playlist": "Een afspeellijst aanmaken", - "update_playlist": "Afspeellijst bijwerken", - "create": "Aanmaken", - "cancel": "Annuleren", - "update": "Bijwerken", - "playlist_name": "Naam afspeellijst", - "name_of_playlist": "Naam van de afspeellijst", - "description": "Beschrijving", - "public": "Openbaar", - "collaborative": "Samenwerkend", - "search_local_tracks": "Lokale nummers zoeken…", - "play": "Afspelen", - "delete": "Wissen", - "none": "Geen", - "sort_a_z": "Sorteren op A-Z", - "sort_z_a": "Sorteren op Z-A", - "sort_artist": "Sorteren op artiest", - "sort_album": "Sorteren op album", - "sort_duration": "Sorteren op lengte", - "sort_tracks": "Nummers sorteren", - "currently_downloading": "Momenteel aan het downloaden ({tracks_length})", - "cancel_all": "Alles annuleren", - "filter_artist": "Artiesten filteren…", - "followers": "{followers} volgers", - "add_artist_to_blacklist": "Artiest toevoegen aan zwarte lijst", - "top_tracks": "Topnummers", - "fans_also_like": "Fans luisteren ook", - "loading": "Laden…", - "artist": "Artiest", - "blacklisted": "Zwarte lijst", - "following": "Volgen", - "follow": "Volgen", - "artist_url_copied": "URL artiest gekopieerd naar klembord", - "added_to_queue": "{tracks} nummers toegevoegd aan wachtrij", - "filter_albums": "Albums filteren…", - "synced": "Gesynchroniseerd", - "plain": "Eenvoudig", - "shuffle": "Willekeurig", - "search_tracks": "Nummers zoeken…", - "released": "Uitgegeven", - "error": "Fout {error}", - "title": "Titel", - "time": "Tijd", - "more_actions": "Meer acties", - "download_count": "({count}) downloads", - "add_count_to_playlist": "({count}) aan afspeellijst toevoegen", - "add_count_to_queue": "({count}) aan wachtrij toevoegen", - "play_count_next": "Volgende ({count}) afspelen", - "album": "Album", - "copied_to_clipboard": "{data} naar klembord gekopieerd", - "add_to_following_playlists": "{track} aan volgende afspeellijsten toevoegen", - "add": "Toevoegen", - "added_track_to_queue": "{track} aan wachtrij toegevoegd", - "add_to_queue": "Toevoegen aan wachtrij", - "track_will_play_next": "{track} wordt hierna afgespeeld", - "play_next": "Volgende afspelen", - "removed_track_from_queue": "{track} van wachtrij verwijderd", - "remove_from_queue": "Van wachtrij verwijderen", - "remove_from_favorites": "Van favorieten verwijderen", - "save_as_favorite": "Opslaan als favoriet", - "add_to_playlist": "Aan afspeellijst toevoegen", - "remove_from_playlist": "Van afspeellijst verwijderen", - "add_to_blacklist": "Aan zwarte lijst toevoegen", - "remove_from_blacklist": "Van zwarte lijst verwijderen", - "share": "Delen", - "mini_player": "Minispeler", - "slide_to_seek": "Schuiven om vooruit of achteruit te zoeken", - "shuffle_playlist": "Afspeellijst willekeurig", - "unshuffle_playlist": "Afspeellijst op volgorde", - "previous_track": "Vorige nummer", - "next_track": "Volgende nummer", - "pause_playback": "Afspelen pauzeren", - "resume_playback": "Afspelen hervatten", - "loop_track": "Nummer herhalen", - "repeat_playlist": "Afspeellijst herhalen", - "queue": "Wachtrij", - "alternative_track_sources": "Alternatieve bronnen voor nummers", - "download_track": "Nummer downloaden", - "tracks_in_queue": "{tracks} nummers in wachtrij", - "clear_all": "Alles wissen", - "show_hide_ui_on_hover": "UI tonen/verbergen bij zweven", - "always_on_top": "Altijd bovenaan", - "exit_mini_player": "Minispeler afsluiten", - "download_location": "Downloadlocatie", - "account": "Account", - "login_with_spotify": "Inloggen met je Spotify-account", - "connect_with_spotify": "Verbinden met Spotify", - "logout": "Afmelden", - "logout_of_this_account": "Afmelden van dit account", - "language_region": "Taal & regio", - "language": "Taal", - "system_default": "Systeemstandaard", - "market_place_region": "Marktplaats-regio", - "recommendation_country": "Aanbeveling Land", - "appearance": "Uiterlijk", - "layout_mode": "Opmaakmodus", - "override_layout_settings": "Instellingen voor responsieve opmaakmodus opheffen", - "adaptive": "Adaptief", - "compact": "Compact", - "extended": "Uitgebreid", - "theme": "Thema", - "dark": "Donker", - "light": "Licht", - "system": "Systeem", - "accent_color": "Accentkleur", - "sync_album_color": "Albumkleur synchroniseren", - "sync_album_color_description": "Gebruikt de overheersende kleur van het album als accentkleur", - "playback": "Weergave", - "audio_quality": "Audiokwaliteit", - "high": "Hoog", - "low": "Laag", - "pre_download_play": "Vooraf downloaden en afspelen", - "pre_download_play_description": "In plaats van audio te streamen, kun je bytes downloaden en afspelen (aanbevolen voor gebruikers met een hogere bandbreedte)", - "skip_non_music": "Niet-muzieksegmenten overslaan (SponsorBlock)", - "blacklist_description": "Nummers en artiesten op de zwarte lijst", - "wait_for_download_to_finish": "Wacht tot de huidige download is voltooid", - "desktop": "Bureaublad", - "close_behavior": "Sluitgedrag", - "close": "Afsluiten", - "minimize_to_tray": "Minimaliseren naar systeemvak", - "show_tray_icon": "Systeemvakpictogram tonen", - "about": "Over", - "u_love_spotube": "We weten dat je van Spotube houd", - "check_for_updates": "Controleren op updates", - "about_spotube": "Over Spotube", - "blacklist": "Zwarte lijst", - "please_sponsor": "Sponsor/Doneer a.u.b.", - "spotube_description": "Spotube, een lichtgewicht, cross-platform, vrij-voor-alles Spotify-client", - "version": "Versie", - "build_number": "Bouwnummer", - "founder": "Grondlegger", - "repository": "Opslagplaats", - "bug_issues": "Bug+problemen", - "made_with": "Met ❤️ gemaakt in Bangladesh🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Licentie", - "add_spotify_credentials": "Voeg om te beginnen je spotify-aanmeldgegevens toe", - "credentials_will_not_be_shared_disclaimer": "Maak je geen zorgen, je gegevens worden niet verzameld of gedeeld met anderen.", - "know_how_to_login": "Weet je niet hoe je dit moet doen?", - "follow_step_by_step_guide": "Volg de stapsgewijze handleiding", - "spotify_cookie": "Spotify {name} Cookie", - "cookie_name_cookie": "{name} Cookie", - "fill_in_all_fields": "Vul alle velden in a.u.b.", - "submit": "Verzenden", - "exit": "Afronden", - "previous": "Vorige", - "next": "Volgende", - "done": "Klaar", - "step_1": "Stap 1", - "first_go_to": "Ga eerst naar", - "login_if_not_logged_in": "en Inloggen/Aanmelden als je niet bent ingelogd", - "step_2": "Stap 2", - "step_2_steps": "1. Zodra je bent aangemeld, druk je op F12 of klik je met de rechtermuisknop > Inspect om de Browser devtools te openen.\n2. Ga vervolgens naar het tabblad \"Toepassing\" (Chrome, Edge, Brave enz..) of naar het tabblad \"Opslag\" (Firefox, Palemoon enz..).\n3. Ga naar de sectie \"Cookies\" en vervolgens naar de subsectie \"https://accounts.spotify.com\".", - "step_3": "Stap 3", - "step_3_steps": "De waarde van cookie \"sp_dc\" kopiëren", - "success_emoji": "Succes🥳", - "success_message": "Je bent nu ingelogd met je Spotify account. Goed gedaan!", - "step_4": "Stap 4", - "step_4_steps": "De gekopieerde waarde \"sp_dc\" plakken", - "something_went_wrong": "Er ging iets mis", - "piped_instance": "Piped-serverinstantie", - "piped_description": "De Piped-serverinstantie die moet worden gebruikt voor overeenkomstige nummers", - "piped_warning": "Sommige werken misschien niet goed. Dus gebruik ze op eigen risico", - "generate_playlist": "Afspeellijst genereren", - "track_exists": "Nummer {track} bestaat al", - "replace_downloaded_tracks": "Alle gedownloade nummers vervangen", - "skip_download_tracks": "Downloaden van alle gedownloade nummers overslaan", - "do_you_want_to_replace": "Wil je het bestaande nummer vervangen?", - "replace": "Vervangen", - "skip": "Overslaan", - "select_up_to_count_type": "Selecteer tot {count} {type}", - "select_genres": "Genres selecteren", - "add_genres": "Genres toevoegen", - "country": "Land", - "number_of_tracks_generate": "Aantal nummers om te genereren", - "acousticness": "Akoestiek", - "danceability": "Dansbaarheid", - "energy": "Energie", - "instrumentalness": "Instrumentaliteit", - "liveness": "Levendigheid", - "loudness": "Luidheid", - "speechiness": "Spraak", - "valence": "Valentie", - "popularity": "Populariteit", - "key": "Sleutel", - "duration": "Tijdsduur (s)", - "tempo": "Tempo (SPM)", - "mode": "Modus", - "time_signature": "Tijdsnotatie", - "short": "Kort", - "medium": "Middel", - "long": "Lang", - "min": "Min", - "max": "Max", - "target": "Doel", - "moderate": "Matig", - "deselect_all": "Selectie opheffen", - "select_all": "Alles selecteren", - "are_you_sure": "Weet je het zeker?", - "generating_playlist": "Aangepaste afspeellijst genereren…", - "selected_count_tracks": "{count} nummers geselecteerd", - "download_warning": "Als je alle nummers in bulk downloadt, ben je duidelijk bezig met muziekpiraterij en breng je schade toe aan de creatieve muziekmaatschappij. Ik hoop dat je je hiervan bewust bent. Probeer altijd het harde werk van artiesten te respecteren en te steunen.", - "download_ip_ban_warning": "BTW, je IP-adres kan worden geblokkeerd op YouTube als gevolg van buitensporige downloadverzoeken. IP-blokkering betekent dat je YouTube niet kunt gebruiken (zelfs als je ingelogd bent) voor tenminste 2-3 maanden vanaf dat IP-apparaat. Spotube is niet verantwoordelijk als dit ooit gebeurt.", - "by_clicking_accept_terms": "Door op 'accepteren' te klikken ga je akkoord met de volgende voorwaarden:", - "download_agreement_1": "Ik weet dat ik muziek illegaal donload. Ik ben slecht.", - "download_agreement_2": "Ik steun de artiest waar ik kan en ik doe dit alleen omdat ik geen geld heb om hun kunst te kopen.", - "download_agreement_3": "Ik ben me er volledig van bewust dat mijn IP geblokkeerd kan worden op YouTube & ik houd Spotube of zijn eigenaars/contributeurs niet verantwoordelijk voor ongelukken die veroorzaakt worden door mijn huidige actie.", - "decline": "Weigeren", - "accept": "Accepteren", - "details": "Bijzonderheden", - "youtube": "YouTube", - "channel": "Kanaal", - "likes": "Liefs", - "dislikes": "Hekels", - "views": "Weergaven", - "streamUrl": "Stream-URL", - "stop": "Stoppen", - "sort_newest": "Sorteren op recent toegevoegd", - "sort_oldest": "Sorteren op langst toegevoegd", - "sleep_timer": "Slaaptimer", - "mins": "{minutes} minuten", - "hours": "{hours} uren", - "hour": "{hours} uur", - "custom_hours": "Aangepaste uren", - "logs": "Logboeken", - "developers": "Ontwikkelaars", - "not_logged_in": "Je bent niet aangemeld", - "search_mode": "Zoekmodus", - "youtube_api_type": "API-type", - "ok": "Oké", - "failed_to_encrypt": "Versleuteling mislukt", - "encryption_failed_warning": "Spotube gebruikt versleuteling om je gegevens veilig op te slaan. Maar dat is niet gelukt. Dus zal het terugvallen op onveilige opslag.\nAls je linux gebruikt, zorg er dan voor dat je een geheim-dienst (gnome-keyring, kde-wallet, keepassxc etc) hebt geïnstalleerd.", - "querying_info": "Info opvragen…", - "piped_api_down": "Piped API is uit", - "piped_down_error_instructions": "De Piped-instantie {pipedInstance} is momenteel uitgevallen\n\nVerander de instantie of verander het 'API-type' naar de officiële YouTube API.\n\nZorg ervoor dat u de app herstart na de wijziging", - "you_are_offline": "Je bent momenteel offline", - "connection_restored": "Je internetverbinding is hersteld", - "use_system_title_bar": "Systeemtitelbalk gebruiken", - "crunching_results": "Resultaten verwerken…", - "search_to_get_results": "Zoeken naar resultaten", - "use_amoled_mode": "Pikzwart donkerthema", - "pitch_dark_theme": "AMOLED-modus", - "normalize_audio": "Audio normaliseren", - "change_cover": "Hoes aanpassen", - "add_cover": "Hoes toevoegen", - "restore_defaults": "Standaardwaarden herstellen", - "download_music_codec": "Download-codec", - "streaming_music_codec": "Streaming-codec", - "login_with_lastfm": "Inloggen met Last.fm", - "connect": "Verbinden", - "disconnect_lastfm": "Last.fm verbreken", - "disconnect": "Verbeken", - "username": "Gebruikersnaam", - "password": "Wachtwoord", - "login": "Inloggen", - "login_with_your_lastfm": "Inloggen met je Last.fm account", - "scrobble_to_lastfm": "Scrobbelen naar Last.fm", - "go_to_album": "Ga naar album", - "discord_rich_presence": "Discord Rich Presence", - "browse_all": "Alles doorbladeren", - "genres": "Genres", - "explore_genres": "Genres verkennen", - "friends": "Vrienden", - "no_lyrics_available": "Sorry, geen teksten gevonden voor dit nummer", - "start_a_radio": "Een radio starten", - "how_to_start_radio": "Hoe wil je de radio starten?", - "replace_queue_question": "Wil je de huidige wachtrij vervangen of eraan toevoegen?", - "endless_playback": "Oneindig afspelen", - "delete_playlist": "Afspeellijst verwijderen", - "delete_playlist_confirmation": "Weet je zeker dat je deze afspeellijst wilt verwijderen?", - "local_tracks": "Lokale nummers", - "song_link": "Song-link", - "skip_this_nonsense": "Deze onzin overslaan", - "freedom_of_music": "“Vrijheid van muziek”", - "freedom_of_music_palm": "“Vrijheid van muziek in je hand”", - "get_started": "Laten we beginnen", - "youtube_source_description": "Aangeraden en werkt het best.", - "piped_source_description": "Voel je je vrij? Net als YouTube, maar meer vrij.", - "jiosaavn_source_description": "Het beste voor de regio Zuid-Azië.", - "highest_quality": "Hoogste kwaliteit: {quality}", - "select_audio_source": "Audiobron kiezen", - "endless_playback_description": "Nieuwe nummers automatisch achteraan de wachtrij toevoegen", - "choose_your_region": "Kies je regio", - "choose_your_region_description": "Dit helpt Spotube om de juiste inhoud\nvoor jouw locatie te tonen.", - "choose_your_language": "Kies je taal", - "help_project_grow": "Help dit project met groeien", - "help_project_grow_description": "Spotube is een open-source project. Je kunt dit project helpen groeien door eraan bij te dragen, problemen te melden of nieuwe functies voor te stellen.", - "contribute_on_github": "Bijdragen on GitHub", - "donate_on_open_collective": "Doneren on Open Collective", - "browse_anonymously": "Anoniem browsen", - "enable_connect": "Verbinding inschakelen", - "enable_connect_description": "Spotube bedienen vanaf andere apparaten", - "devices": "Apparaten", - "select": "Selecteren", - "connect_client_alert": "Je wordt gecontroleerd door {client}", - "this_device": "Dit apparaat", - "remote": "Afstandsbediening", - "local_library": "Lokale bibliotheek", - "add_library_location": "Toevoegen aan bibliotheek", - "remove_library_location": "Verwijderen uit bibliotheek", - "local_tab": "Lokaal", - "stats": "Statistieken", - "and_n_more": "en {count} meer", - "recently_played": "Onlangs afgespeeld", - "browse_more": "Meer bekijken", - "no_title": "Geen titel", - "not_playing": "Niet aan het afspelen", - "epic_failure": "Epische mislukking!", - "added_num_tracks_to_queue": "{tracks_length} nummers aan de wachtrij toegevoegd", - "spotube_has_an_update": "Spotube heeft een update", - "download_now": "Nu downloaden", - "nightly_version": "Spotube Nightly {nightlyBuildNum} is uitgebracht", - "release_version": "Spotube v{version} is uitgebracht", - "read_the_latest": "Lees de nieuwste ", - "release_notes": "release-opmerkingen", - "pick_color_scheme": "Kies kleurenschema", - "save": "Opslaan", - "choose_the_device": "Kies het apparaat:", - "multiple_device_connected": "Er zijn meerdere apparaten verbonden.\nKies het apparaat waarop je deze actie wilt uitvoeren", - "nothing_found": "Niets gevonden", - "the_box_is_empty": "De doos is leeg", - "top_artists": "Topartiesten", - "top_albums": "Topalbums", - "this_week": "Deze week", - "this_month": "Deze maand", - "last_6_months": "Laatste 6 maanden", - "this_year": "Dit jaar", - "last_2_years": "Laatste 2 jaar", - "all_time": "All time", - "powered_by_provider": "Aangedreven door {providerName}", - "email": "E-mail", - "profile_followers": "Volgers", - "birthday": "Verjaardag", - "subscription": "Abonnement", - "not_born": "Niet geboren", - "hacker": "Hacker", - "profile": "Profiel", - "no_name": "Geen naam", - "edit": "Bewerken", - "user_profile": "Gebruikersprofiel", - "count_plays": "{count} afspeelbeurten", - "streaming_fees_hypothetical": "*Dit is berekend op basis van Spotify's uitbetaling per stream\nvan $0.003 tot $0.005. Dit is een hypothetische\nberekening om gebruikers inzicht te geven in hoeveel ze\naan de artiesten zouden hebben betaald als ze hun lied op Spotify zouden hebben beluisterd.", - "count_mins": "{minutes} min", - "summary_minutes": "minuten", - "summary_listened_to_music": "Beluisterde muziek", - "summary_songs": "nummers", - "summary_streamed_overall": "Totaal gestreamd", - "summary_owed_to_artists": "Te betalen aan artiesten\ndeze maand", - "summary_artists": "van de artiest", - "summary_music_reached_you": "Muziek heeft je bereikt", - "summary_full_albums": "volledige albums", - "summary_got_your_love": "Kreeg je liefde", - "summary_playlists": "afspeellijsten", - "summary_were_on_repeat": "Was op herhaling", - "total_money": "Totaal {money}", - "minutes_listened": "Luistertijd", - "streamed_songs": "Gestreamde nummers", - "count_streams": "{count} streams", - "owned_by_you": "Bezit door jou", - "copied_shareurl_to_clipboard": "{shareUrl} gekopieerd naar klembord", - "spotify_hipotetical_calculation": "*Dit is berekend op basis van Spotify's betaling per stream\nvan $0.003 tot $0.005. Dit is een hypothetische\nberekening om de gebruiker inzicht te geven in hoeveel ze\naan de artiesten zouden hebben betaald als ze hun liedjes op Spotify\nzouden luisteren.", - "webview_not_found": "Webview niet gevonden", - "webview_not_found_description": "Er is geen Webview-runtime geïnstalleerd op uw apparaat.\nAls het is geïnstalleerd, zorg ervoor dat het in het environment PATH staat\n\nHerstart de app na installatie", - "unsupported_platform": "Niet ondersteund platform", - "invidious_instance": "Invidious-serverinstantie", - "invidious_description": "De Invidious-serverinstantie die gebruikt wordt voor trackmatching", - "invidious_warning": "Sommigen werken mogelijk niet goed. Gebruik op eigen risico", - "invidious_source_description": "Vergelijkbaar met Piped, maar met een hogere beschikbaarheid.", - "cache_music": "Cache muziek", - "open": "Open", - "cache_folder": "Cachemap", - "export": "Exporteren", - "clear_cache": "Cache wissen", - "clear_cache_confirmation": "Wilt u de cache wissen?", - "export_cache_files": "Gecacheerde bestanden exporteren", - "found_n_files": "{count} bestanden gevonden", - "export_cache_confirmation": "Wilt u deze bestanden exporteren naar", - "exported_n_out_of_m_files": "{filesExported} van de {files} bestanden geëxporteerd", - "playlist": "Afspeellijst", - "no_loop": "Geen herhaling", - "generate": "Genereren", - "undo": "Ongedaan maken", - "download_all": "Alles downloaden", - "add_all_to_playlist": "Voeg alles toe aan afspeellijst", - "add_all_to_queue": "Voeg alles toe aan wachtrij", - "play_all_next": "Speel alles volgende", - "pause": "Pauzeren", - "view_all": "Bekijk alles", - "no_tracks_added_yet": "Het lijkt erop dat je nog geen nummers hebt toegevoegd", - "no_tracks": "Het lijkt erop dat er hier geen nummers zijn", - "no_tracks_listened_yet": "Het lijkt erop dat je nog niets hebt beluisterd", - "not_following_artists": "Je volgt geen artiesten", - "no_favorite_albums_yet": "Het lijkt erop dat je nog geen albums aan je favorieten hebt toegevoegd", - "no_logs_found": "Geen logbestanden gevonden", - "youtube_engine": "YouTube Engine", - "youtube_engine_not_installed_title": "{engine} is niet geïnstalleerd", - "youtube_engine_not_installed_message": "{engine} is niet geïnstalleerd op je systeem.", - "youtube_engine_set_path": "Zorg ervoor dat het beschikbaar is in de PATH-variabele of\nstel het absolute pad naar de {engine} uitvoerbare bestanden in", - "youtube_engine_unix_issue_message": "Op macOS/Linux/unix-achtige besturingssystemen werkt het instellen van paden in .zshrc/.bashrc/.bash_profile enz. niet.\nJe moet het pad instellen in het shell-configuratiebestand", - "download": "Downloaden", - "file_not_found": "Bestand niet gevonden", - "custom": "Aangepast", - "add_custom_url": "Voeg aangepaste URL toe", - "edit_port": "Poort bewerken", - "port_helper_msg": "Standaard is -1, wat een willekeurig nummer aangeeft. Als je een firewall hebt geconfigureerd, wordt aanbevolen dit in te stellen.", - "connect_request": "Toestaan dat {client} verbinding maakt?", - "connection_request_denied": "Verbinding geweigerd. Gebruiker heeft toegang geweigerd.", - "hipotetical_calculation": "*Dit is berekend op basis van de gemiddelde uitbetaling per stream van online muziekstreamingplatforms van $0,003 tot $0,005. Dit is een hypothetische berekening om de gebruiker inzicht te geven in hoeveel ze aan de artiesten zouden hebben betaald als ze hun nummer op een ander muziekstreamingplatform zouden beluisteren.", - "an_error_occurred": "Er is een fout opgetreden", - "copy_to_clipboard": "Kopiëren naar klembord", - "view_logs": "Logboeken bekijken", - "retry": "Opnieuw proberen", - "no_default_metadata_provider_selected": "U heeft geen standaard metadata-aanbieder ingesteld", - "manage_metadata_providers": "Metadata-aanbieders beheren", - "open_link_in_browser": "Link openen in browser?", - "do_you_want_to_open_the_following_link": "Wilt u de volgende link openen", - "unsafe_url_warning": "Het kan onveilig zijn om links van onbetrouwbare bronnen te openen. Wees voorzichtig!\nU kunt de link ook naar uw klembord kopiëren.", - "copy_link": "Link kopiëren", - "building_your_timeline": "Uw tijdlijn wordt opgebouwd op basis van uw luistergedrag...", - "official": "Officieel", - "author_name": "Auteur: {author}", - "third_party": "Derden", - "plugin_requires_authentication": "Plugin vereist authenticatie", - "update_available": "Update beschikbaar", - "supports_scrobbling": "Ondersteunt scrobbling", - "plugin_scrobbling_info": "Deze plugin scrobblet uw muziek om uw luistergeschiedenis te genereren.", - "default_plugin": "Standaard", - "set_default": "Instellen als standaard", - "support": "Ondersteuning", - "support_plugin_development": "Ondersteun plugin-ontwikkeling", - "can_access_name_api": "- Kan de **{name}** API benaderen", - "do_you_want_to_install_this_plugin": "Wilt u deze plugin installeren?", - "third_party_plugin_warning": "Deze plugin is afkomstig van een repository van derden. Zorg ervoor dat u de bron vertrouwt voordat u installeert.", - "author": "Auteur", - "this_plugin_can_do_following": "Deze plugin kan het volgende doen", - "install": "Installeren", - "install_a_metadata_provider": "Een metadata-aanbieder installeren", - "no_tracks_playing": "Er wordt momenteel geen nummer afgespeeld", - "synced_lyrics_not_available": "Gesynchroniseerde songteksten zijn niet beschikbaar voor dit nummer. Gebruik in plaats daarvan het tabblad", - "plain_lyrics": "Eenvoudige songteksten", - "tab_instead": "in plaats daarvan.", - "disclaimer": "Disclaimer", - "third_party_plugin_dmca_notice": "Het Spotube-team draagt geen enkele verantwoordelijkheid (inclusief juridische) voor \"derden\" plugins.\nGebruik ze op eigen risico. Voor bugs/problemen kunt u deze melden bij de plugin-repository.\n\nAls een \"derden\" plugin de ToS/DMCA van een service/juridische entiteit schendt, vraag dan de auteur van de \"derden\" plugin of het hostingplatform, bijvoorbeeld GitHub/Codeberg, om actie te ondernemen. De hierboven vermelde (gelabelde \"derden\") plugins zijn allemaal openbare/door de gemeenschap onderhouden plugins. We beheren ze niet, dus we kunnen geen actie tegen ze ondernemen.\n\n", - "input_does_not_match_format": "Invoer komt niet overeen met het vereiste formaat", - "metadata_provider_plugins": "Metadata-aanbieder Plugins", - "paste_plugin_download_url": "Plak de download-URL of de URL van de GitHub/Codeberg-repository of een directe link naar het .smplug-bestand", - "download_and_install_plugin_from_url": "Download en installeer de plugin via URL", - "failed_to_add_plugin_error": "Kon de plugin niet toevoegen: {error}", - "upload_plugin_from_file": "Plugin uploaden vanuit bestand", - "installed": "Geïnstalleerd", - "available_plugins": "Beschikbare plugins", - "configure_your_own_metadata_plugin": "Configureer uw eigen metadata-aanbieder voor afspeellijst/album/artiest/feed", - "audio_scrobblers": "Audioscrobblers", - "scrobbling": "Scrobbling", - "download_music_format": "Download muziekformaat", - "streaming_music_format": "Streaming muziekformaat", - "download_music_quality": "Downloadkwaliteit", - "streaming_music_quality": "Streamingkwaliteit", - "default_metadata_source": "Standaard metadata-bron", - "set_default_metadata_source": "Standaard metadata-bron instellen", - "default_audio_source": "Standaard audiobron", - "set_default_audio_source": "Standaard audiobron instellen", - "plugins": "Plug-ins", - "configure_plugins": "Configureer je eigen metadata- en audiobron-plug-ins", - "source": "Bron: ", - "uncompressed": "Ongecomprimeerd", - "dab_music_source_description": "Voor audiofielen. Biedt hoge kwaliteit/lossless audiostreams. Nauwkeurige trackmatching op basis van ISRC.", - "audio_source": "Audiobron" -} \ No newline at end of file diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb deleted file mode 100644 index 80da1e89..00000000 --- a/lib/l10n/app_pl.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "Gość", - "browse": "Przeglądaj", - "search": "Szukaj", - "library": "Biblioteka", - "lyrics": "Tekst utworu", - "settings": "Ustawienia", - "genre_categories_filter": "Filtruj kategorie lub gatunki...", - "genre": "Gatunki", - "personalized": "Spersonalizowane", - "featured": "Wyróżnione", - "new_releases": "Nowo wydane", - "songs": "Utwory", - "playing_track": "Odtwarzanie {track}", - "queue_clear_alert": "To spowoduje wyczyszczenie całej kolejki! {track_length} pozycji zostanie usuniętych.\nCzy chcesz kontynuować?", - "load_more": "Załaduj więcej", - "playlists": "Playlisty", - "artists": "Artyści", - "albums": "Albumy", - "tracks": "Utwory", - "downloads": "Pobrane", - "filter_playlists": "Filtruj swoje playlisty...", - "liked_tracks": "Ulubione utwory", - "liked_tracks_description": "Wszystkie twoje ulubione utwory", - "create_playlist": "Utwórz playlistę", - "create_a_playlist": "Utwórz playlistę", - "create": "Utwórz", - "cancel": "Anuluj", - "playlist_name": "Nazwa playlisty", - "name_of_playlist": "Nazwa playlisty", - "description": "Opis", - "public": "Publiczny", - "collaborative": "Współpraca", - "search_local_tracks": "Szukanie lokalnych utworów...", - "play": "Odtwórz", - "delete": "Usuń", - "none": "Brak", - "sort_a_z": "Sortuj od A do Z", - "sort_z_a": "Sortuj od Z do A", - "sort_artist": "Sortuj po Artyście", - "sort_album": "Sortuj po Albumie", - "sort_tracks": "Sortuj Utwory", - "currently_downloading": "Obecnie pobieram {tracks_length} utworów.", - "cancel_all": "Anuluj wszystkie", - "filter_artist": "Filtruj artystów...", - "followers": "{followers} obserwujących", - "add_artist_to_blacklist": "Dodaj artystę do czarnej listy", - "top_tracks": "Popularne Utwory", - "fans_also_like": "Fani lubią także", - "loading": "Ładowanie...", - "artist": "Artysta", - "blacklisted": "Dodano do czarnej listy", - "following": "Obserwujesz", - "follow": "Zaobserwuj", - "artist_url_copied": "Skopiowano URL artysty do schowka", - "added_to_queue": "Dodano {tracks} utworów do kolejki", - "filter_albums": "Filtruj albumy...", - "synced": "Zsynchronizowano", - "plain": "Zwykły", - "shuffle": "Losowe odtwarzanie", - "search_tracks": "Szukam utworu...", - "released": "Wydano", - "error": "Błąd {error}", - "title": "Tytuł", - "time": "Czas", - "more_actions": "Więcej akcji", - "download_count": "Pobrane ({count})", - "add_count_to_playlist": "Dodaj ({count}) do Playlisty", - "add_count_to_queue": "Dodaj ({count}) do Kolejki", - "play_count_next": "Odtwórz ({count}) następne", - "album": "Album", - "copied_to_clipboard": "Skopiowano {data} do schowka", - "add_to_following_playlists": "Dodano {track} do danych Playlist", - "add": "Dodaj", - "added_track_to_queue": "Dodano {track} do kolejki", - "add_to_queue": "Dodano do kolejki", - "track_will_play_next": "{track} następny", - "play_next": "Odtwórz następny", - "removed_track_from_queue": "Usunięto {track} z kolejki", - "remove_from_queue": "Usunięto z kolejki", - "remove_from_favorites": "Usunięto z ulubionych", - "save_as_favorite": "Zapisz do ulubionych", - "add_to_playlist": "Dodaj do playlisty", - "remove_from_playlist": "Usuń z playlisty", - "add_to_blacklist": "Dodaj do czarnej listy", - "remove_from_blacklist": "Usuń z czarnej listy", - "share": "Udostępnij", - "mini_player": "Mały odwarzacz", - "slide_to_seek": "Przesuń, aby przewinąć do przodu lub do tyłu.", - "shuffle_playlist": "Odtwarzaj losowo z playlisty", - "unshuffle_playlist": "Nie odtwarzaj losowo z playlisty", - "previous_track": "Poprzedni utwór", - "next_track": "Następny utwór", - "pause_playback": "Zatrzymaj odwarzanie", - "resume_playback": "Wznów odwarzanie", - "loop_track": "Zapętl utwór", - "repeat_playlist": "Powtarzaj playlistę", - "queue": "Kolejka", - "alternative_track_sources": "Alternatywne źródła utworów", - "download_track": "Pobierz utwór", - "tracks_in_queue": "{tracks} utworów w kolejce", - "clear_all": "Wyczyść wszystko", - "show_hide_ui_on_hover": "Pokaż/Ukryj unoszący się interfejs", - "always_on_top": "Zawsze na wierzchu", - "exit_mini_player": "Opuść Mały odtwarzacz", - "download_location": "Zmień lokalizację", - "account": "Konto", - "login_with_spotify": "Zaloguj się używając konta Spotify", - "connect_with_spotify": "Połącz z Spotify", - "logout": "Wyloguj", - "logout_of_this_account": "Wyloguj z tego konta", - "language_region": "Język i Region", - "language": "Język", - "system_default": "Domyślny systemowy", - "market_place_region": "Region Rynku", - "recommendation_country": "Kraj rekomendacji", - "appearance": "Wygląd", - "layout_mode": "Tryb Układu", - "override_layout_settings": "Nadpisz responsywne ustawienia trybu układu", - "adaptive": "Adaptacyjny", - "compact": "Kompaktowy", - "extended": "Rozszerzony", - "theme": "Motyw", - "dark": "Ciemny", - "light": "Jasny", - "system": "Systemowy", - "accent_color": "Kolor Akcentu", - "sync_album_color": "Synchronizuj kolor albumu", - "sync_album_color_description": "Używa dominującego koloru okładki albumu jako koloru akcentującego", - "playback": "Odtwarzanie", - "audio_quality": "Jakość dźwięku", - "high": "Duża", - "low": "Mała", - "pre_download_play": "Wstępnie pobierz i odtwórz", - "pre_download_play_description": "Zamiast przesyłać strumieniowo dźwięk, pobiera odpowiedni bufor i odtwarza (zalecane dla użytkowników o większej przepustowości)", - "skip_non_music": "Pomiń nie-muzyczne segmenty (SponsorBlock)", - "blacklist_description": "Czarna lista utworów i artystów", - "wait_for_download_to_finish": "Proszę poczekać na zakończenie obecnego pobierania.", - "desktop": "Pulpit", - "close_behavior": "Zamknij", - "close": "Zamknij", - "minimize_to_tray": "Zminimalizuj do zasobnika", - "show_tray_icon": "Pokazuj ikonę w zasobniku", - "about": "O projekcie", - "u_love_spotube": "Wiemy jak kochacie Spotube", - "check_for_updates": "Sprawdź aktualizacje", - "about_spotube": "O Spotube", - "blacklist": "Czarna lista", - "please_sponsor": "Proszę wesprzyj projekt", - "spotube_description": "Spotube, lekki, wieloplatformowy, darmowy dla wszystkich klient Spotify", - "version": "Wersja", - "build_number": "Numer Build'a", - "founder": "Twórca Założyciel", - "repository": "Repozytorium", - "bug_issues": "Błędy i propozycje", - "made_with": "Stworzono z ❤️ w Bangladesh'u 🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Licencja", - "add_spotify_credentials": "Dodaj swoje dane logowania Spotify, aby zacząć", - "credentials_will_not_be_shared_disclaimer": "Nie martw się, żadne dane logowania nie są zbierane ani udostępniane nikomu", - "know_how_to_login": "Nie wiesz, jak się zalogować?", - "follow_step_by_step_guide": "Postępuj zgodnie z poradnikiem krok po kroku", - "spotify_cookie": "Spotify {name} Ciasteczko", - "cookie_name_cookie": "{name} Ciasteczko", - "fill_in_all_fields": "Proszę wypełnić wszystkie pola", - "submit": "Zatwierdź", - "exit": "Zamknij", - "previous": "Poprzedni", - "next": "Następny", - "done": "Gotowe 🙂", - "step_1": "Krok 1", - "first_go_to": "Po pierwsze przejdź do", - "login_if_not_logged_in": "i Zaloguj się/Zarejestruj jeśli nie jesteś zalogowany", - "step_2": "Krok 2", - "step_2_steps": "1. Jeśli jesteś zalogowany, naciśnij klawisz F12 lub Kliknij prawym przyciskiem myszy > Zbadaj, aby odtworzyć narzędzia developerskie.\n2. Następnie przejdź do zakładki \"Application\" (Chrome, Edge, Brave etc..) lub zakładki \"Storage\" (Firefox, Palemoon etc..)\n3. Przejdź do sekcji \"Cookies\" a następnie do pod-sekcji \"https://accounts.spotify.com\"", - "step_3": "Krok 3", - "success_emoji": "Sukces!🥳", - "success_message": "Udało ci się zalogować! Dobra robota, stary!", - "step_4": "Krok 4", - "something_went_wrong": "Coś poszło nie tak 🙁", - "piped_instance": "Instancja serwera Piped", - "piped_description": "Instancja serwera Piped używana jest do dopasowania utworów.", - "piped_warning": "Niektóre z nich mogą nie działać. Używasz na własną odpowiedzialność!", - "generate_playlist": "Wygeneruj playlistę", - "track_exists": "Utwór {track} już istnieje", - "replace_downloaded_tracks": "Zamień wszystkie pobrane utwory", - "skip_download_tracks": "Pomiń pobieranie wszystkich pobranych utworów", - "do_you_want_to_replace": "Chcesz zamienić istniejący utwór ??", - "replace": "Zamień", - "skip": "Pomiń", - "select_up_to_count_type": "Wybierz do {count} {type}", - "select_genres": "Wybierz Gatunki", - "add_genres": "Dodaj Gatunki", - "country": "Kraj", - "number_of_tracks_generate": "Liczba utworów do wygenerowania", - "acousticness": "Akustyczna", - "danceability": "Taneczna", - "energy": "Energiczna", - "instrumentalness": "Instrumentalna", - "liveness": "Żywa", - "loudness": "Głośna", - "speechiness": "Wymowna", - "valence": "Wartościowa", - "popularity": "Popularność", - "key": "Kluczowa", - "duration": "Długość (s)", - "tempo": "Tempo (BPM)", - "mode": "Tryb", - "time_signature": "Sygnatura Czasowa", - "short": "Krótka", - "medium": "Średnia", - "long": "Długa", - "min": "Minimalnie", - "max": "Maksymalnie", - "target": "Cel", - "moderate": "Umiarkowanie", - "deselect_all": "Odznacz wszystkie", - "select_all": "Zaznacz wszystkie", - "are_you_sure": "Jesteś pewny?", - "generating_playlist": "Generowanie twojej własnej playlisty...", - "selected_count_tracks": "Wybrano {count} utworów", - "download_warning": "Jeśli hurtowo pobierasz wszystkie utwory, wyraźnie piracisz muzykę i wyrządzasz szkody kreatywnej społeczności muzycznej. Mam nadzieję, że jesteś tego świadomy. Zawsze staraj się szanować i wspierać ciężką pracę Artysty", - "download_ip_ban_warning": "Przy okazji, Twój adres IP może zostać zablokowany w YouTube z powodu nadmiernych żądań pobierania niż zwykle. Blokada IP oznacza, że nie możesz korzystać z YouTube (nawet jeśli jesteś zalogowany) przez co najmniej 2-3 miesiące z IP tego urządzenia. Spotube nie ponosi żadnej odpowiedzialności, jeśli tak się stanie", - "by_clicking_accept_terms": "Klikając 'Akceptuj' zgadzasz się z następującymi warunkami:", - "download_agreement_1": "Wiem, że piracę muzykę. Jestem zły.", - "download_agreement_2": "Będę wspierał artystę i robię to tylko dlatego, że nie mam pieniędzy na albumy wykonawcy. ", - "download_agreement_3": "Jestem całkowicie świadomy, że moje IP może zostać zablokowane w YouTube i nie pociągam Spotube ani jego właścicieli/współtwórców do odpowiedzialności za jakiekolwiek wypadki spowodowane moimi obecnymi działaniami", - "decline": "Odrzuć", - "accept": "Akceptuj", - "details": "Szczegóły", - "youtube": "YouTube", - "channel": "Kanał", - "likes": "Polubienia", - "dislikes": "Nie lubi", - "views": "Wyświetlenia", - "streamUrl": "URL strumienia", - "stop": "Stop", - "sort_newest": "Sortuj według ostatnio dodanych", - "sort_oldest": "Sortuj według najstarszych dodanych", - "sleep_timer": "Minutnik", - "mins": "{minutes} Minuty", - "hours": "{hours} Godziny", - "hour": "{hours} Godzina", - "custom_hours": "Własne godziny", - "logs": "Logi", - "developers": "Developerzy", - "not_logged_in": "Nie jesteś zalogowany", - "search_mode": "Tryb szukania", - "audio_source": "Źródło dźwięku", - "ok": "Ok", - "failed_to_encrypt": "Nie można zaszyfrować :(", - "encryption_failed_warning": "Spotube używa szyfrowania do bezpiecznego przechowywania danych. Ale nie udało się tego zrobić. Więc powróci do niezabezpieczonego przechowywania\nJeśli używasz Linuksa, upewnij się, że masz zainstalowane jakieś usługi do szyfrowania (gnome-keyring, kde-wallet, keepassxc itp.)", - "querying_info": "Szukam informacji...", - "piped_api_down": "API Piped jest niedostępne", - "piped_down_error_instructions": "Instancja Piped {pipedInstance} jest obecnie niedostępna\n\nZmień instancję lub zmień 'Rodzaj API' na oficjalne API YouTube\n\nUpewnij się, że po zmianie zrestartujesz aplikację", - "you_are_offline": "Obecnie jesteś offline", - "connection_restored": "Twoje połączenie z internetem zostało przywrócone", - "use_system_title_bar": "Użyj paska tytułu systemu", - "update_playlist": "Zaktualizuj playlistę", - "update": "Aktualizuj", - "crunching_results": "Przetwarzanie wyników...", - "search_to_get_results": "Szukaj, aby uzyskać wyniki", - "use_amoled_mode": "Tryb AMOLED", - "pitch_dark_theme": "Ciemny motyw", - "normalize_audio": "Normalizuj dźwięk", - "change_cover": "Zmień okładkę", - "add_cover": "Dodaj okładkę", - "restore_defaults": "Przywróć domyślne", - "download_music_codec": "Pobierz kodek muzyczny", - "streaming_music_codec": "Kodek strumieniowy muzyki", - "login_with_lastfm": "Zaloguj się z Last.fm", - "connect": "Połącz", - "disconnect_lastfm": "Rozłącz z Last.fm", - "disconnect": "Rozłącz", - "username": "Nazwa użytkownika", - "password": "Hasło", - "login": "Zaloguj", - "login_with_your_lastfm": "Zaloguj się na swoje konto Last.fm", - "scrobble_to_lastfm": "Scrobbluj do Last.fm", - "go_to_album": "Przejdź do albumu", - "discord_rich_presence": "Obecność na Discordzie", - "browse_all": "Przeglądaj wszystko", - "genres": "Gatunki muzyczne", - "explore_genres": "Eksploruj gatunki", - "step_3_steps": "Skopiuj wartość ciasteczka \"sp_dc\"", - "step_4_steps": "Wklej skopiowaną wartość \"sp_dc\"", - "friends": "Przyjaciele", - "no_lyrics_available": "Przepraszamy, nie można znaleźć tekstu dla tego utworu", - "sort_duration": "Sortuj według Czasu Trwania", - "start_a_radio": "Uruchom radio", - "how_to_start_radio": "Jak chcesz uruchomić radio?", - "replace_queue_question": "Czy chcesz zastąpić bieżącą kolejkę czy dodać do niej?", - "endless_playback": "Nieskończona Odtwarzanie", - "delete_playlist": "Usuń Playlistę", - "delete_playlist_confirmation": "Czy na pewno chcesz usunąć tę listę odtwarzania?", - "local_tracks": "Lokalne Utwory", - "song_link": "Link do Utworu", - "skip_this_nonsense": "Pomiń tę bzdurę", - "freedom_of_music": "“Wolność Muzyki”", - "freedom_of_music_palm": "“Wolność Muzyki w Twojej dłoni”", - "get_started": "Zacznijmy", - "youtube_source_description": "Polecane i działa najlepiej.", - "piped_source_description": "Czujesz się wolny? To samo co YouTube, ale dużo za darmo.", - "jiosaavn_source_description": "Najlepszy dla regionu Azji Południowej.", - "highest_quality": "Najwyższa Jakość: {quality}", - "select_audio_source": "Wybierz Źródło Audio", - "endless_playback_description": "Automatycznie dodaj nowe utwory na koniec kolejki", - "choose_your_region": "Wybierz swoją region", - "choose_your_region_description": "To pomoże Spotube pokazać Ci odpowiednią treść dla Twojej lokalizacji.", - "choose_your_language": "Wybierz swój język", - "help_project_grow": "Pomóż temu projektowi rosnąć", - "help_project_grow_description": "Spotube to projekt open-source. Możesz pomóc temu projektowi rosnąć, przyczyniając się do projektu, zgłaszając błędy lub sugerując nowe funkcje.", - "contribute_on_github": "Przyczyniaj się na GitHubie", - "donate_on_open_collective": "Dotuj na Open Collective", - "browse_anonymously": "Przeglądaj Anonimowo", - "enable_connect": "Włącz połączenie", - "enable_connect_description": "Kontroluj Spotube z innych urządzeń", - "devices": "Urządzenia", - "select": "Wybierz", - "connect_client_alert": "Jesteś sterowany przez {client}", - "this_device": "To urządzenie", - "remote": "Zdalny", - "local_library": "Biblioteka lokalna", - "add_library_location": "Dodaj do biblioteki", - "remove_library_location": "Usuń z biblioteki", - "local_tab": "Lokalny", - "stats": "Statystyki", - "and_n_more": "i {count} więcej", - "recently_played": "Ostatnio odtwarzane", - "browse_more": "Zobacz więcej", - "no_title": "Brak tytułu", - "not_playing": "Nie odtwarzane", - "epic_failure": "Epicka porażka!", - "added_num_tracks_to_queue": "Dodano {tracks_length} utworów do kolejki", - "spotube_has_an_update": "Spotube ma aktualizację", - "download_now": "Pobierz teraz", - "nightly_version": "Spotube Nightly {nightlyBuildNum} został wydany", - "release_version": "Spotube v{version} został wydany", - "read_the_latest": "Przeczytaj najnowsze ", - "release_notes": "notatki o wersji", - "pick_color_scheme": "Wybierz schemat kolorów", - "save": "Zapisz", - "choose_the_device": "Wybierz urządzenie:", - "multiple_device_connected": "Jest wiele urządzeń podłączonych.\nWybierz urządzenie, na którym chcesz wykonać tę akcję", - "nothing_found": "Nic nie znaleziono", - "the_box_is_empty": "Pudełko jest puste", - "top_artists": "Najlepsi artyści", - "top_albums": "Najlepsze albumy", - "this_week": "W tym tygodniu", - "this_month": "W tym miesiącu", - "last_6_months": "Ostatnie 6 miesięcy", - "this_year": "W tym roku", - "last_2_years": "Ostatnie 2 lata", - "all_time": "Wszystkie czasy", - "powered_by_provider": "Napędzane przez {providerName}", - "email": "E-mail", - "profile_followers": "Obserwujący", - "birthday": "Data urodzenia", - "subscription": "Subskrypcja", - "not_born": "Nie urodzony", - "hacker": "Haker", - "profile": "Profil", - "no_name": "Brak nazwy", - "edit": "Edytuj", - "user_profile": "Profil użytkownika", - "count_plays": "{count} odtworzeń", - "streaming_fees_hypothetical": "*Obliczone na podstawie wypłaty Spotify za stream\nod $0.003 do $0.005. Jest to hipotetyczne\nobliczenie, które ma na celu pokazanie, ile\nużytkownik zapłaciłby artystom, gdyby odsłuchał\ntych utworów na Spotify.", - "count_mins": "{minutes} min", - "summary_minutes": "minuty", - "summary_listened_to_music": "Słuchana muzyka", - "summary_songs": "utwory", - "summary_streamed_overall": "Ogółem streamowane", - "summary_owed_to_artists": "Do zapłaty artystom\nw tym miesiącu", - "summary_artists": "artystów", - "summary_music_reached_you": "Muzyka dotarła do Ciebie", - "summary_full_albums": "pełne albumy", - "summary_got_your_love": "Otrzymał Twoją miłość", - "summary_playlists": "playlisty", - "summary_were_on_repeat": "Były na powtarzaniu", - "total_money": "Łącznie {money}", - "minutes_listened": "Minuty odsłuchane", - "streamed_songs": "Strumieniowane utwory", - "count_streams": "{count} strumieni", - "owned_by_you": "Własność Twoja", - "copied_shareurl_to_clipboard": "{shareUrl} skopiowano do schowka", - "spotify_hipotetical_calculation": "*Obliczone na podstawie płatności Spotify za strumień\nw zakresie od $0.003 do $0.005. Jest to hipotetyczne\nobliczenie mające na celu pokazanie użytkownikowi, ile\nzapłaciliby artystom, gdyby słuchali ich utworów na Spotify.", - "webview_not_found": "Nie znaleziono Webview", - "webview_not_found_description": "Na twoim urządzeniu nie zainstalowano środowiska uruchomieniowego Webview.\nJeśli jest zainstalowany, upewnij się, że jest w environment PATH\n\nPo instalacji uruchom ponownie aplikację", - "unsupported_platform": "Nieobsługiwana platforma", - "invidious_instance": "Instancja serwera Invidious", - "invidious_description": "Instancja serwera Invidious do dopasowywania utworów", - "invidious_warning": "Niektóre z nich mogą nie działać dobrze. Używaj na własne ryzyko", - "invidious_source_description": "Podobne do Piped, ale o wyższej dostępności.", - "cache_music": "Pamięć podręczna muzyki", - "open": "Otwórz", - "cache_folder": "Folder pamięci podręcznej", - "export": "Eksportuj", - "clear_cache": "Wyczyść pamięć podręczną", - "clear_cache_confirmation": "Czy chcesz wyczyścić pamięć podręczną?", - "export_cache_files": "Eksportuj pliki z pamięci podręcznej", - "found_n_files": "Znaleziono {count} plików", - "export_cache_confirmation": "Czy chcesz wyeksportować te pliki do", - "exported_n_out_of_m_files": "Wyeksportowano {filesExported} z {files} plików", - "playlist": "Playlista", - "no_loop": "Brak pętli", - "generate": "Generuj", - "undo": "Cofnij", - "download_all": "Pobierz wszystko", - "add_all_to_playlist": "Dodaj wszystko do playlisty", - "add_all_to_queue": "Dodaj wszystko do kolejki", - "play_all_next": "Odtwórz wszystko następnie", - "pause": "Pauza", - "view_all": "Zobacz wszystko", - "no_tracks_added_yet": "Wygląda na to, że jeszcze nie dodałeś żadnych utworów", - "no_tracks": "Wygląda na to, że tutaj nie ma żadnych utworów", - "no_tracks_listened_yet": "Wygląda na to, że jeszcze nic nie słuchałeś", - "not_following_artists": "Nie obserwujesz żadnych artystów", - "no_favorite_albums_yet": "Wygląda na to, że jeszcze nie dodałeś żadnych albumów do ulubionych", - "no_logs_found": "Nie znaleziono żadnych logów", - "youtube_engine": "Silnik YouTube", - "youtube_engine_not_installed_title": "{engine} nie jest zainstalowany", - "youtube_engine_not_installed_message": "{engine} nie jest zainstalowany w systemie.", - "youtube_engine_set_path": "Upewnij się, że jest dostępny w zmiennej PATH lub\nustaw absolutną ścieżkę do pliku wykonywalnego {engine} poniżej", - "youtube_engine_unix_issue_message": "W systemach macOS/Linux/unix, ustawianie ścieżki w .zshrc/.bashrc/.bash_profile itp. nie będzie działać.\nMusisz ustawić ścieżkę w pliku konfiguracyjnym powłoki", - "download": "Pobierz", - "file_not_found": "Plik nie znaleziony", - "custom": "Niestandardowy", - "add_custom_url": "Dodaj niestandardowy URL", - "edit_port": "Edytuj port", - "port_helper_msg": "Domyślna wartość to -1, co oznacza losową liczbę. Jeśli masz skonfigurowany zaporę, zaleca się jej ustawienie.", - "connect_request": "Zezwolić {client} na połączenie?", - "connection_request_denied": "Połączenie odrzucone. Użytkownik odmówił dostępu.", - "hipotetical_calculation": "*Jest to obliczone na podstawie średniej wypłaty z internetowych platform streamingowych za jeden stream w wysokości 0,003 do 0,005 USD. Jest to hipotetyczne obliczenie, które ma na celu dać użytkownikowi wgląd w to, ile zapłaciłby artystom, gdyby słuchał ich piosenek na różnych platformach streamingowych.", - "an_error_occurred": "Wystąpił błąd", - "copy_to_clipboard": "Kopiuj do schowka", - "view_logs": "Wyświetl logi", - "retry": "Ponów", - "no_default_metadata_provider_selected": "Nie masz ustawionego domyślnego dostawcy metadanych", - "manage_metadata_providers": "Zarządzaj dostawcami metadanych", - "open_link_in_browser": "Otworzyć link w przeglądarce?", - "do_you_want_to_open_the_following_link": "Czy chcesz otworzyć następujący link", - "unsafe_url_warning": "Otwieranie linków z niezaufanych źródeł może być niebezpieczne. Zachowaj ostrożność!\nMożesz również skopiować link do schowka.", - "copy_link": "Kopiuj link", - "building_your_timeline": "Budowanie Twojej osi czasu na podstawie Twoich odsłuchań...", - "official": "Oficjalny", - "author_name": "Autor: {author}", - "third_party": "Zewnętrzny", - "plugin_requires_authentication": "Wtyczka wymaga uwierzytelnienia", - "update_available": "Dostępna aktualizacja", - "supports_scrobbling": "Obsługuje scrobbling", - "plugin_scrobbling_info": "Ta wtyczka scrobbluje Twoją muzykę, aby wygenerować historię odsłuchań.", - "default_plugin": "Domyślna", - "set_default": "Ustaw jako domyślną", - "support": "Wsparcie", - "support_plugin_development": "Wspieraj rozwój wtyczki", - "can_access_name_api": "- Może uzyskać dostęp do API **{name}**", - "do_you_want_to_install_this_plugin": "Czy chcesz zainstalować tę wtyczkę?", - "third_party_plugin_warning": "Ta wtyczka pochodzi z zewnętrznego repozytorium. Upewnij się, że ufasz źródłu przed instalacją.", - "author": "Autor", - "this_plugin_can_do_following": "Ta wtyczka może wykonywać następujące czynności", - "install": "Instaluj", - "install_a_metadata_provider": "Zainstaluj dostawcę metadanych", - "no_tracks_playing": "Obecnie nie odtwarzany jest żaden utwór", - "synced_lyrics_not_available": "Zsynchronizowane teksty nie są dostępne dla tego utworu. Zamiast tego użyj zakładki", - "plain_lyrics": "Zwykłe teksty", - "tab_instead": "zamiast tego.", - "disclaimer": "Zastrzeżenie", - "third_party_plugin_dmca_notice": "Zespół Spotube nie ponosi żadnej odpowiedzialności (w tym prawnej) za żadne wtyczki \"zewnętrzne\".\nUżywaj ich na własne ryzyko. Wszelkie błędy/problemy prosimy zgłaszać w repozytorium wtyczki.\n\nJeśli jakakolwiek wtyczka \"zewnętrzna\" narusza ToS/DMCA jakiejkolwiek usługi/podmiotu prawnego, prosimy o kontakt z autorem wtyczki \"zewnętrznej\" lub platformą hostingową, np. GitHub/Codeberg, w celu podjęcia działań. Wymienione powyżej (oznaczone jako \"zewnętrzne\") są publicznymi wtyczkami utrzymywanymi przez społeczność. Nie kuratujemy ich, więc nie możemy podjąć żadnych działań w ich sprawie.\n\n", - "input_does_not_match_format": "Wprowadzony tekst nie pasuje do wymaganego formatu", - "metadata_provider_plugins": "Wtyczki dostawców metadanych", - "paste_plugin_download_url": "Wklej adres URL do pobrania lub adres URL repozytorium GitHub/Codeberg lub bezpośredni link do pliku .smplug", - "download_and_install_plugin_from_url": "Pobierz i zainstaluj wtyczkę z adresu URL", - "failed_to_add_plugin_error": "Nie udało się dodać wtyczki: {error}", - "upload_plugin_from_file": "Prześlij wtyczkę z pliku", - "installed": "Zainstalowane", - "available_plugins": "Dostępne wtyczki", - "configure_your_own_metadata_plugin": "Skonfiguruj własnego dostawcę metadanych dla playlisty/albumu/artysty/kanału", - "audio_scrobblers": "Scrobblery audio", - "scrobbling": "Scrobbling", - "download_music_format": "Format pobierania muzyki", - "streaming_music_format": "Format strumieniowania muzyki", - "download_music_quality": "Jakość pobierania", - "streaming_music_quality": "Jakość strumieniowania", - "default_metadata_source": "Domyślne źródło metadanych", - "set_default_metadata_source": "Ustaw domyślne źródło metadanych", - "default_audio_source": "Domyślne źródło audio", - "set_default_audio_source": "Ustaw domyślne źródło audio", - "plugins": "Wtyczki", - "configure_plugins": "Skonfiguruj własne wtyczki dostawców metadanych i źródeł audio", - "source": "Źródło: ", - "uncompressed": "Nieskompresowany", - "dab_music_source_description": "Dla audiofilów. Oferuje strumienie audio wysokiej jakości/lossless. Precyzyjne dopasowanie utworów na podstawie ISRC." -} \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb deleted file mode 100644 index fa7845c3..00000000 --- a/lib/l10n/app_pt.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "Visitante", - "browse": "Explorar", - "search": "Buscar", - "library": "Biblioteca", - "lyrics": "Letras", - "settings": "Configurações", - "genre_categories_filter": "Filtrar categorias ou gêneros...", - "genre": "Gênero", - "personalized": "Personalizado", - "featured": "Destaque", - "new_releases": "Novos Lançamentos", - "songs": "Músicas", - "playing_track": "Tocando {track}", - "queue_clear_alert": "Isso irá limpar a fila atual. {track_length} músicas serão removidas.\nDeseja continuar?", - "load_more": "Carregar mais", - "playlists": "Playlists", - "artists": "Artistas", - "albums": "Álbuns", - "tracks": "Faixas", - "downloads": "Downloads", - "filter_playlists": "Filtrar suas playlists...", - "liked_tracks": "Músicas Curtidas", - "liked_tracks_description": "Todas as suas músicas curtidas", - "create_playlist": "Criar Playlist", - "create_a_playlist": "Criar uma playlist", - "create": "Criar", - "cancel": "Cancelar", - "playlist_name": "Nome da Playlist", - "name_of_playlist": "Nome da playlist", - "description": "Descrição", - "public": "Pública", - "collaborative": "Colaborativa", - "search_local_tracks": "Buscar músicas locais...", - "play": "Reproduzir", - "delete": "Excluir", - "none": "Nenhum", - "sort_a_z": "Ordenar de A-Z", - "sort_z_a": "Ordenar de Z-A", - "sort_artist": "Ordenar por Artista", - "sort_album": "Ordenar por Álbum", - "sort_tracks": "Ordenar Faixas", - "currently_downloading": "Baixando no momento ({tracks_length})", - "cancel_all": "Cancelar Tudo", - "filter_artist": "Filtrar artistas...", - "followers": "{followers} Seguidores", - "add_artist_to_blacklist": "Adicionar artista à lista negra", - "top_tracks": "Principais Músicas", - "fans_also_like": "Fãs também curtiram", - "loading": "Carregando...", - "artist": "Artista", - "blacklisted": "Na Lista Negra", - "following": "Seguindo", - "follow": "Seguir", - "artist_url_copied": "URL do artista copiada para a área de transferência", - "added_to_queue": "Adicionadas {tracks} músicas à fila", - "filter_albums": "Filtrar álbuns...", - "synced": "Sincronizado", - "plain": "Simples", - "shuffle": "Aleatório", - "search_tracks": "Buscar músicas...", - "released": "Lançado", - "error": "Erro {error}", - "title": "Título", - "time": "Tempo", - "more_actions": "Mais ações", - "download_count": "Baixar ({count})", - "add_count_to_playlist": "Adicionar ({count}) à Playlist", - "add_count_to_queue": "Adicionar ({count}) à Fila", - "play_count_next": "Reproduzir ({count}) em seguida", - "album": "Álbum", - "copied_to_clipboard": "{data} copiado para a área de transferência", - "add_to_following_playlists": "Adicionar {track} às Playlists Seguintes", - "add": "Adicionar", - "added_track_to_queue": "Adicionada {track} à fila", - "add_to_queue": "Adicionar à fila", - "track_will_play_next": "{track} será reproduzida em seguida", - "play_next": "Reproduzir em seguida", - "removed_track_from_queue": "{track} removida da fila", - "remove_from_queue": "Remover da fila", - "remove_from_favorites": "Remover dos favoritos", - "save_as_favorite": "Salvar como favorita", - "add_to_playlist": "Adicionar à playlist", - "remove_from_playlist": "Remover da playlist", - "add_to_blacklist": "Adicionar à lista negra", - "remove_from_blacklist": "Remover da lista negra", - "share": "Compartilhar", - "mini_player": "Mini Player", - "slide_to_seek": "Arraste para avançar ou retroceder", - "shuffle_playlist": "Embaralhar playlist", - "unshuffle_playlist": "Desembaralhar playlist", - "previous_track": "Faixa anterior", - "next_track": "Próxima faixa", - "pause_playback": "Pausar Reprodução", - "resume_playback": "Continuar Reprodução", - "loop_track": "Repetir faixa", - "repeat_playlist": "Repetir playlist", - "queue": "Fila", - "alternative_track_sources": "Fontes alternativas de faixas", - "download_track": "Baixar faixa", - "tracks_in_queue": "{tracks} músicas na fila", - "clear_all": "Limpar tudo", - "show_hide_ui_on_hover": "Mostrar/Ocultar UI ao passar o mouse", - "always_on_top": "Sempre no topo", - "exit_mini_player": "Sair do Mini player", - "download_location": "Local de download", - "account": "Conta", - "login_with_spotify": "Fazer login com sua conta do Spotify", - "connect_with_spotify": "Conectar ao Spotify", - "logout": "Sair", - "logout_of_this_account": "Sair desta conta", - "language_region": "Idioma e Região", - "language": "Idioma", - "system_default": "Padrão do Sistema", - "market_place_region": "Região da Loja", - "recommendation_country": "País de Recomendação", - "appearance": "Aparência", - "layout_mode": "Modo de Layout", - "override_layout_settings": "Substituir configurações do modo de layout responsivo", - "adaptive": "Adaptável", - "compact": "Compacto", - "extended": "Estendido", - "theme": "Tema", - "dark": "Escuro", - "light": "Claro", - "system": "Sistema", - "accent_color": "Cor de Destaque", - "sync_album_color": "Sincronizar cor do álbum", - "sync_album_color_description": "Usa a cor predominante da capa do álbum como cor de destaque", - "playback": "Reprodução", - "audio_quality": "Qualidade do Áudio", - "high": "Alta", - "low": "Baixa", - "pre_download_play": "Pré-download e reprodução", - "pre_download_play_description": "Em vez de transmitir áudio, baixar bytes e reproduzir (recomendado para usuários com maior largura de banda)", - "skip_non_music": "Pular segmentos não musicais (SponsorBlock)", - "blacklist_description": "Faixas e artistas na lista negra", - "wait_for_download_to_finish": "Aguarde o download atual ser concluído", - "desktop": "Desktop", - "close_behavior": "Comportamento de Fechamento", - "close": "Fechar", - "minimize_to_tray": "Minimizar para a bandeja", - "show_tray_icon": "Mostrar ícone na bandeja do sistema", - "about": "Sobre", - "u_love_spotube": "Sabemos que você adora o Spotube", - "check_for_updates": "Verificar atualizações", - "about_spotube": "Sobre o Spotube", - "blacklist": "Lista Negra", - "please_sponsor": "Por favor, patrocine/doe", - "spotube_description": "Spotube, um cliente leve, multiplataforma e gratuito para o Spotify", - "version": "Versão", - "build_number": "Número de Build", - "founder": "Fundador", - "repository": "Repositório", - "bug_issues": "Bugs/Problemas", - "made_with": "Feito com ❤️ em Bangladesh🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Licença", - "add_spotify_credentials": "Adicione suas credenciais do Spotify para começar", - "credentials_will_not_be_shared_disclaimer": "Não se preocupe, suas credenciais não serão coletadas nem compartilhadas com ninguém", - "know_how_to_login": "Não sabe como fazer isso?", - "follow_step_by_step_guide": "Siga o guia passo a passo", - "spotify_cookie": "Cookie do Spotify {name}", - "cookie_name_cookie": "Cookie {name}", - "fill_in_all_fields": "Preencha todos os campos, por favor", - "submit": "Enviar", - "exit": "Sair", - "previous": "Anterior", - "next": "Próximo", - "done": "Concluído", - "step_1": "Passo 1", - "first_go_to": "Primeiro, vá para", - "login_if_not_logged_in": "e faça login/cadastro se ainda não estiver logado", - "step_2": "Passo 2", - "step_2_steps": "1. Uma vez logado, pressione F12 ou clique com o botão direito do mouse > Inspecionar para abrir as ferramentas de desenvolvimento do navegador.\n2. Em seguida, vá para a guia \"Aplicativo\" (Chrome, Edge, Brave, etc.) ou \"Armazenamento\" (Firefox, Palemoon, etc.)\n3. Acesse a seção \"Cookies\" e depois a subseção \"https://accounts.spotify.com\"", - "step_3": "Passo 3", - "success_emoji": "Sucesso🥳", - "success_message": "Agora você está logado com sucesso em sua conta do Spotify. Bom trabalho!", - "step_4": "Passo 4", - "something_went_wrong": "Algo deu errado", - "piped_instance": "Instância do Servidor Piped", - "piped_description": "A instância do servidor Piped a ser usada para correspondência de faixas", - "piped_warning": "Algumas delas podem não funcionar bem. Use por sua conta e risco", - "generate_playlist": "Gerar Playlist", - "track_exists": "A faixa {track} já existe", - "replace_downloaded_tracks": "Substituir todas as faixas baixadas", - "skip_download_tracks": "Pular o download de todas as faixas baixadas", - "do_you_want_to_replace": "Deseja substituir a faixa existente?", - "replace": "Substituir", - "skip": "Pular", - "select_up_to_count_type": "Selecione até {count} {type}", - "select_genres": "Selecionar Gêneros", - "add_genres": "Adicionar Gêneros", - "country": "País", - "number_of_tracks_generate": "Número de faixas a gerar", - "acousticness": "Acústica", - "danceability": "Dançabilidade", - "energy": "Energia", - "instrumentalness": "Instrumentalidade", - "liveness": "Vivacidade", - "loudness": "Volume", - "speechiness": "Discurso", - "valence": "Valência", - "popularity": "Popularidade", - "key": "Tonalidade", - "duration": "Duração (s)", - "tempo": "Tempo (BPM)", - "mode": "Modo", - "time_signature": "Assinatura de tempo", - "short": "Curto", - "medium": "Médio", - "long": "Longo", - "min": "Min", - "max": "Máx", - "target": "Alvo", - "moderate": "Moderado", - "deselect_all": "Desmarcar Todos", - "select_all": "Selecionar Todos", - "are_you_sure": "Tem certeza?", - "generating_playlist": "Gerando sua playlist personalizada...", - "selected_count_tracks": "{count} faixas selecionadas", - "download_warning": "Se você baixar todas as faixas em massa, estará claramente pirateando música e causando danos à sociedade criativa da música. Espero que você esteja ciente disso. Sempre tente respeitar e apoiar o trabalho árduo dos artistas", - "download_ip_ban_warning": "Além disso, seu IP pode ser bloqueado no YouTube devido a solicitações de download excessivas. O bloqueio de IP significa que você não poderá usar o YouTube (mesmo se estiver conectado) por pelo menos 2-3 meses a partir do dispositivo IP. E o Spotube não se responsabiliza se isso acontecer", - "by_clicking_accept_terms": "Ao clicar em 'aceitar', você concorda com os seguintes termos:", - "download_agreement_1": "Eu sei que estou pirateando música. Sou mau", - "download_agreement_2": "Vou apoiar o artista onde puder e estou fazendo isso porque não tenho dinheiro para comprar sua arte", - "download_agreement_3": "Estou completamente ciente de que meu IP pode ser bloqueado no YouTube e não responsabilizo o Spotube ou seus proprietários/colaboradores por quaisquer acidentes causados pela minha ação atual", - "decline": "Recusar", - "accept": "Aceitar", - "details": "Detalhes", - "youtube": "YouTube", - "channel": "Canal", - "likes": "Curtidas", - "dislikes": "Descurtidas", - "views": "Visualizações", - "streamUrl": "URL do Stream", - "stop": "Parar", - "sort_newest": "Ordenar por mais recente adicionado", - "sort_oldest": "Ordenar por mais antigo adicionado", - "sleep_timer": "Temporizador de Sono", - "mins": "{minutes} Minutos", - "hours": "{hours} Horas", - "hour": "{hours} Hora", - "custom_hours": "Horas Personalizadas", - "logs": "Registros", - "developers": "Desenvolvedores", - "not_logged_in": "Você não está logado", - "search_mode": "Modo de Busca", - "audio_source": "Fonte de Áudio", - "ok": "Ok", - "failed_to_encrypt": "Falha ao criptografar", - "encryption_failed_warning": "O Spotube usa criptografia para armazenar seus dados com segurança, mas falhou em fazê-lo. Portanto, ele voltará para o armazenamento não seguro.\nSe você estiver usando o Linux, certifique-se de ter algum serviço secreto (gnome-keyring, kde-wallet, keepassxc, etc.) instalado", - "querying_info": "Consultando informações...", - "piped_api_down": "A API do Piped está indisponível", - "piped_down_error_instructions": "A instância do Piped {pipedInstance} está atualmente indisponível\n\nMude a instância ou mude o 'Tipo de API' para a API oficial do YouTube\n\nCertifique-se de reiniciar o aplicativo após a alteração", - "you_are_offline": "Você está offline no momento", - "connection_restored": "Sua conexão com a internet foi restaurada", - "use_system_title_bar": "Usar a barra de título do sistema", - "update_playlist": "Atualizar lista de reprodução", - "update": "Atualizar", - "crunching_results": "Processando resultados...", - "search_to_get_results": "Pesquisar para obter resultados", - "use_amoled_mode": "Modo AMOLED", - "pitch_dark_theme": "Tema escuro", - "normalize_audio": "Normalizar áudio", - "change_cover": "Alterar capa", - "add_cover": "Adicionar capa", - "restore_defaults": "Restaurar padrões", - "download_music_codec": "Descarregar codec de música", - "streaming_music_codec": "Codec de streaming de música", - "login_with_lastfm": "Iniciar sessão com o Last.fm", - "connect": "Ligar", - "disconnect_lastfm": "Desligar do Last.fm", - "disconnect": "Desligar", - "username": "Nome de utilizador", - "password": "Palavra-passe", - "login": "Iniciar sessão", - "login_with_your_lastfm": "Inicie sessão na sua conta Last.fm", - "scrobble_to_lastfm": "Scrobble para o Last.fm", - "go_to_album": "Ir para o álbum", - "discord_rich_presence": "Presença rica no Discord", - "browse_all": "Navegar por tudo", - "genres": "Gêneros", - "explore_genres": "Explorar gêneros", - "step_3_steps": "Copie o valor do cookie \"sp_dc\"", - "step_4_steps": "Cole o valor copiado de \"sp_dc\"", - "friends": "Amigos", - "no_lyrics_available": "Desculpe, não foi possível encontrar a letra desta faixa", - "sort_duration": "Ordenar por Duração", - "start_a_radio": "Iniciar uma Rádio", - "how_to_start_radio": "Como você deseja iniciar a rádio?", - "replace_queue_question": "Você deseja substituir a fila atual ou acrescentar a ela?", - "endless_playback": "Reprodução sem fim", - "delete_playlist": "Excluir Lista de Reprodução", - "delete_playlist_confirmation": "Tem certeza de que deseja excluir esta lista de reprodução?", - "local_tracks": "Faixas Locais", - "song_link": "Link da Música", - "skip_this_nonsense": "Pular essa bobagem", - "freedom_of_music": "“Liberdade da Música”", - "freedom_of_music_palm": "“Liberdade da Música na palma da sua mão”", - "get_started": "Vamos começar", - "youtube_source_description": "Recomendado e funciona melhor.", - "piped_source_description": "Sentindo-se livre? Igual ao YouTube, mas muito mais grátis.", - "jiosaavn_source_description": "Melhor para a região da Ásia do Sul.", - "highest_quality": "Melhor Qualidade: {quality}", - "select_audio_source": "Selecionar Fonte de Áudio", - "endless_playback_description": "Adicionar automaticamente novas músicas\nao final da fila", - "choose_your_region": "Escolha sua região", - "choose_your_region_description": "Isso ajudará o Spotube a mostrar o conteúdo certo\npara sua localização.", - "choose_your_language": "Escolha seu idioma", - "help_project_grow": "Ajude este projeto a crescer", - "help_project_grow_description": "Spotube é um projeto de código aberto. Você pode ajudar este projeto a crescer contribuindo para o projeto, relatando bugs ou sugerindo novos recursos.", - "contribute_on_github": "Contribuir no GitHub", - "donate_on_open_collective": "Doar no Open Collective", - "browse_anonymously": "Navegar Anonimamente", - "enable_connect": "Ativar conexão", - "enable_connect_description": "Controle o Spotube a partir de outros dispositivos", - "devices": "Dispositivos", - "select": "Selecionar", - "connect_client_alert": "Você está sendo controlado por {client}", - "this_device": "Este dispositivo", - "remote": "Remoto", - "local_library": "Biblioteca local", - "add_library_location": "Adicionar à biblioteca", - "remove_library_location": "Remover da biblioteca", - "local_tab": "Local", - "stats": "Estatísticas", - "and_n_more": "e {count} mais", - "recently_played": "Reproduzido Recentemente", - "browse_more": "Ver Mais", - "no_title": "Sem Título", - "not_playing": "Não está a reproduzir", - "epic_failure": "Fracasso épico!", - "added_num_tracks_to_queue": "Adicionados {tracks_length} faixas à fila", - "spotube_has_an_update": "Spotube tem uma atualização", - "download_now": "Baixar Agora", - "nightly_version": "Spotube Nightly {nightlyBuildNum} foi lançado", - "release_version": "Spotube v{version} foi lançado", - "read_the_latest": "Leia o mais recente ", - "release_notes": "notas de versão", - "pick_color_scheme": "Escolha o esquema de cores", - "save": "Salvar", - "choose_the_device": "Escolha o dispositivo:", - "multiple_device_connected": "Há vários dispositivos conectados.\nEscolha o dispositivo no qual deseja executar esta ação", - "nothing_found": "Nada encontrado", - "the_box_is_empty": "A caixa está vazia", - "top_artists": "Principais Artistas", - "top_albums": "Principais Álbuns", - "this_week": "Esta semana", - "this_month": "Este mês", - "last_6_months": "Últimos 6 meses", - "this_year": "Este ano", - "last_2_years": "Últimos 2 anos", - "all_time": "De todos os tempos", - "powered_by_provider": "Desenvolvido por {providerName}", - "email": "E-mail", - "profile_followers": "Seguidores", - "birthday": "Aniversário", - "subscription": "Assinatura", - "not_born": "Não nascido", - "hacker": "Hacker", - "profile": "Perfil", - "no_name": "Sem Nome", - "edit": "Editar", - "user_profile": "Perfil do Usuário", - "count_plays": "{count} reproduzidos", - "streaming_fees_hypothetical": "*Calculado com base no pagamento por stream do Spotify\nque varia de $0.003 a $0.005. Isso é um cálculo hipotético\npara fornecer uma visão ao usuário sobre quanto eles\nteriam pago aos artistas se estivessem ouvindo\no seu som no Spotify.", - "count_mins": "{minutes} min", - "summary_minutes": "minutos", - "summary_listened_to_music": "Música ouvida", - "summary_songs": "faixas", - "summary_streamed_overall": "Total de streams", - "summary_owed_to_artists": "Devido aos artistas\neste mês", - "summary_artists": "artista", - "summary_music_reached_you": "A música chegou até você", - "summary_full_albums": "álbuns completos", - "summary_got_your_love": "Recebeu seu amor", - "summary_playlists": "playlists", - "summary_were_on_repeat": "Estavam em repetição", - "total_money": "Total {money}", - "minutes_listened": "Minutos ouvidos", - "streamed_songs": "Músicas transmitidas", - "count_streams": "{count} streams", - "owned_by_you": "De sua propriedade", - "copied_shareurl_to_clipboard": "{shareUrl} copiado para a área de transferência", - "spotify_hipotetical_calculation": "*Isso é calculado com base no pagamento por stream do Spotify\nque varia de $0.003 a $0.005. Esta é uma cálculo hipotético\npara dar ao usuário uma visão de quanto teriam pago aos artistas\nse eles ouvissem suas músicas no Spotify.", - "webview_not_found": "Webview não encontrado", - "webview_not_found_description": "Nenhum runtime Webview está instalado no seu dispositivo.\nSe estiver instalado, certifique-se de que está no environment PATH\n\nApós a instalação, reinicie o aplicativo", - "unsupported_platform": "Plataforma não suportada", - "invidious_instance": "Instância do Servidor Invidious", - "invidious_description": "A instância do servidor Invidious a ser usada para correspondência de faixas", - "invidious_warning": "Alguns podem não funcionar bem. Use por sua conta e risco", - "invidious_source_description": "Semelhante ao Piped, mas com maior disponibilidade.", - "cache_music": "Música em cache", - "open": "Abrir", - "cache_folder": "Pasta de cache", - "export": "Exportar", - "clear_cache": "Limpar cache", - "clear_cache_confirmation": "Deseja limpar o cache?", - "export_cache_files": "Exportar Arquivos em Cache", - "found_n_files": "Encontrados {count} arquivos", - "export_cache_confirmation": "Deseja exportar estes arquivos para", - "exported_n_out_of_m_files": "Exportados {filesExported} de {files} arquivos", - "playlist": "Playlist", - "no_loop": "Sem loop", - "generate": "Gerar", - "undo": "Desfazer", - "download_all": "Baixar tudo", - "add_all_to_playlist": "Adicionar tudo à playlist", - "add_all_to_queue": "Adicionar tudo à fila", - "play_all_next": "Reproduzir tudo a seguir", - "pause": "Pausar", - "view_all": "Ver tudo", - "no_tracks_added_yet": "Parece que você ainda não adicionou nenhuma faixa", - "no_tracks": "Parece que não há faixas aqui", - "no_tracks_listened_yet": "Parece que você ainda não ouviu nada", - "not_following_artists": "Você não está seguindo nenhum artista", - "no_favorite_albums_yet": "Parece que você ainda não adicionou nenhum álbum aos favoritos", - "no_logs_found": "Nenhum log encontrado", - "youtube_engine": "Motor YouTube", - "youtube_engine_not_installed_title": "{engine} não está instalado", - "youtube_engine_not_installed_message": "{engine} não está instalado no seu sistema.", - "youtube_engine_set_path": "Certifique-se de que está disponível na variável PATH ou\ndefina o caminho absoluto para o executável {engine} abaixo", - "youtube_engine_unix_issue_message": "Em sistemas macOS/Linux/unix, definir o caminho no .zshrc/.bashrc/.bash_profile etc. não funcionará.\nVocê precisa definir o caminho no arquivo de configuração do shell", - "download": "Baixar", - "file_not_found": "Arquivo não encontrado", - "custom": "Personalizado", - "add_custom_url": "Adicionar URL personalizada", - "edit_port": "Editar porta", - "port_helper_msg": "O padrão é -1, que indica um número aleatório. Se você tiver um firewall configurado, é recomendável definir isso.", - "connect_request": "Permitir que {client} se conecte?", - "connection_request_denied": "Conexão negada. O usuário negou o acesso .", - "hipotetical_calculation": "*Isso é calculado com base no pagamento médio por stream de plataformas de streaming de música online de US$ 0,003 a US$ 0,005. Esta é uma estimativa hipotética para dar ao usuário uma ideia de quanto ele teria pago aos artistas se ouvisse sua música em diferentes plataformas de streaming de música.", - "an_error_occurred": "Ocorreu um erro", - "copy_to_clipboard": "Copiar para a área de transferência", - "view_logs": "Ver logs", - "retry": "Tentar novamente", - "no_default_metadata_provider_selected": "Você não tem um provedor de metadados padrão definido", - "manage_metadata_providers": "Gerenciar provedores de metadados", - "open_link_in_browser": "Abrir link no navegador?", - "do_you_want_to_open_the_following_link": "Você deseja abrir o seguinte link", - "unsafe_url_warning": "Pode ser inseguro abrir links de fontes não confiáveis. Tenha cautela!\nVocê também pode copiar o link para sua área de transferência.", - "copy_link": "Copiar link", - "building_your_timeline": "Construindo sua linha do tempo com base em suas audições...", - "official": "Oficial", - "author_name": "Autor: {author}", - "third_party": "Terceiros", - "plugin_requires_authentication": "Plugin requer autenticação", - "update_available": "Atualização disponível", - "supports_scrobbling": "Suporta scrobbling", - "plugin_scrobbling_info": "Este plugin faz o scrobbling de sua música para gerar seu histórico de audição.", - "default_plugin": "Padrão", - "set_default": "Definir como padrão", - "support": "Suporte", - "support_plugin_development": "Apoiar o desenvolvimento do plugin", - "can_access_name_api": "- Pode acessar a API **{name}**", - "do_you_want_to_install_this_plugin": "Você deseja instalar este plugin?", - "third_party_plugin_warning": "Este plugin é de um repositório de terceiros. Certifique-se de que você confia na fonte antes de instalá-lo.", - "author": "Autor", - "this_plugin_can_do_following": "Este plugin pode fazer o seguinte", - "install": "Instalar", - "install_a_metadata_provider": "Instalar um provedor de metadados", - "no_tracks_playing": "Nenhuma música sendo reproduzida no momento", - "synced_lyrics_not_available": "As letras sincronizadas não estão disponíveis para esta música. Por favor, use a aba", - "plain_lyrics": "Letras simples", - "tab_instead": "em vez disso.", - "disclaimer": "Aviso", - "third_party_plugin_dmca_notice": "A equipe Spotube não se responsabiliza (incluindo legalmente) por quaisquer plugins de \"terceiros\".\nUse-os por sua conta e risco. Para quaisquer bugs/problemas, por favor, relate-os ao repositório do plugin.\n\nSe algum plugin de \"terceiros\" estiver violando os Termos de Serviço/DMCA de qualquer serviço/entidade legal, por favor, peça ao autor do plugin \"terceiro\" ou à plataforma de hospedagem, por exemplo, GitHub/Codeberg, para tomar medidas. Os plugins listados acima (rotulados como \"terceiros\") são todos plugins públicos/mantidos pela comunidade. Não os estamos curando, então não podemos tomar nenhuma medida sobre eles.\n\n", - "input_does_not_match_format": "A entrada não corresponde ao formato exigido", - "metadata_provider_plugins": "Plugins do provedor de metadados", - "paste_plugin_download_url": "Cole a url de download ou a url do repositório GitHub/Codeberg ou o link direto para o arquivo .smplug", - "download_and_install_plugin_from_url": "Baixar e instalar o plugin a partir da url", - "failed_to_add_plugin_error": "Falha ao adicionar plugin: {error}", - "upload_plugin_from_file": "Carregar plugin a partir de arquivo", - "installed": "Instalado", - "available_plugins": "Plugins disponíveis", - "configure_your_own_metadata_plugin": "Configure seu próprio provedor de metadados de playlist/álbum/artista/feed", - "audio_scrobblers": "Scrobblers de áudio", - "scrobbling": "Scrobbling", - "download_music_format": "Formato de download de música", - "streaming_music_format": "Formato de streaming de música", - "download_music_quality": "Qualidade de download", - "streaming_music_quality": "Qualidade de streaming", - "default_metadata_source": "Fonte padrão de metadados", - "set_default_metadata_source": "Definir fonte padrão de metadados", - "default_audio_source": "Fonte de áudio padrão", - "set_default_audio_source": "Definir fonte de áudio padrão", - "plugins": "Plugins", - "configure_plugins": "Configure seus próprios plugins de provedores de metadados e fontes de áudio", - "source": "Fonte: ", - "uncompressed": "Não comprimido", - "dab_music_source_description": "Para audiófilos. Fornece streams de áudio de alta qualidade/sem perdas. Correspondência precisa de faixas baseada em ISRC." -} \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb deleted file mode 100644 index 2e864268..00000000 --- a/lib/l10n/app_ru.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "Гость", - "browse": "Обзор", - "search": "Поиск", - "library": "Библиотека", - "lyrics": "Текст", - "settings": "Настройки", - "genre_categories_filter": "Фильтр по категориям или жанрам...", - "genre": "Жанр", - "personalized": "Персонализированный", - "featured": "Популярное", - "new_releases": "Новое", - "songs": "Треки", - "playing_track": "Играет {track}", - "queue_clear_alert": "Это удалит текущую очередь. {track_length} треков будет удалено. Вы хотите продолжить?", - "load_more": "Загрузить больше", - "playlists": "Плейлисты", - "artists": "Исполнители", - "albums": "Альбомы", - "tracks": "Треки", - "downloads": "Загрузки", - "filter_playlists": "Применить фильтры к вашим плейлистам...", - "liked_tracks": "Понравившиеся треки", - "liked_tracks_description": "Все понравившиеся треки", - "create_playlist": "Создание плейлиста", - "create_a_playlist": "Создать плейлист", - "create": "Создать", - "cancel": "Отмена", - "update": "Обновить", - "playlist_name": "Назвать плейлист", - "name_of_playlist": "Название плейлиста", - "description": "Описание", - "public": "Публичный", - "collaborative": "Совместный", - "search_local_tracks": "Поиск песен на вашем устройстве...", - "play": "Играть", - "delete": "Удалить", - "none": "Пусто", - "sort_a_z": "Сортировка по алфавиту", - "sort_z_a": "Сортировка по алфавиту в обратную сторону", - "sort_artist": "Сортировать по исполнителю", - "sort_album": "Сортировать по альбомам", - "sort_duration": "Сортировать по длительности", - "sort_tracks": "Сортировать треки", - "currently_downloading": "Загружается ({tracks_length})", - "cancel_all": "Отменить все", - "filter_artist": "Фильтровать по исполнителю...", - "followers": "{followers} Подписчики", - "add_artist_to_blacklist": "Добавить исполнителя в черный список", - "top_tracks": "Чарт", - "fans_also_like": "Поклонникам также нравится", - "loading": "Загрузка...", - "artist": "Исполнитель", - "blacklisted": "Внесен в черный список", - "following": "Подписаны", - "follow": "Подписаться", - "artist_url_copied": "URL-адрес исполнителя скопирован в буфер обмена", - "added_to_queue": "Добавлено {tracks} треков в очередь", - "filter_albums": "Фильтровать альбомы...", - "synced": "Синхронизировано", - "plain": "Обычный", - "shuffle": "Перемешать", - "search_tracks": "Поиск треков...", - "released": "Дата выхода", - "error": "Ошибка {error}", - "title": "Заголовок", - "time": "Время", - "more_actions": "Больше действий", - "download_count": "Скачать ({count})", - "add_count_to_playlist": "Добавить ({count}) в плейлист", - "add_count_to_queue": "Добавить ({count}) в очередь", - "play_count_next": "Воспроизвести ({count}) следующий", - "album": "Альбом", - "copied_to_clipboard": "Скопировано {data} в буфер обмена", - "add_to_following_playlists": "Добавить {track} в этот плейлист", - "add": "Добавить", - "added_track_to_queue": "Добавлен {track} в очередь", - "add_to_queue": "Добавить в очередь", - "track_will_play_next": "{track} будет воспроизведен следующим", - "play_next": "Воспроизвести следующий", - "removed_track_from_queue": "{track} удален из очереди", - "remove_from_queue": "Удалить из очереди", - "remove_from_favorites": "Удалить из избранного", - "save_as_favorite": "Сохранить в избранное", - "add_to_playlist": "Добавить в плейлист", - "remove_from_playlist": "Удалить из плейлиста", - "add_to_blacklist": "Добавить в черный список", - "remove_from_blacklist": "Удалить из черного списка", - "share": "Поделиться", - "mini_player": "Мини-плеер", - "slide_to_seek": "Потяните для перемотки вперед или назад", - "shuffle_playlist": "Перемешать плейлист", - "unshuffle_playlist": "Снять перемешивание плейлиста", - "previous_track": "Предыдущий трек", - "next_track": "Следующий трек", - "pause_playback": "Пауза воспроизведения", - "resume_playback": "Возобновить воспроизведение", - "loop_track": "Циклический трек", - "repeat_playlist": "Повторите плейлист", - "queue": "Очередь", - "alternative_track_sources": "Альтернативные источники треков", - "download_track": "Скачать трек", - "tracks_in_queue": "{tracks} треков в очереди", - "clear_all": "Очистить все", - "show_hide_ui_on_hover": "Показать/Скрыть интерфейс при наведении", - "always_on_top": "Всегда сверху", - "exit_mini_player": "Выйти из мини-плеера", - "download_location": "Место загрузки", - "local_library": "Локальная библиотека", - "add_library_location": "Добавить в библиотеку", - "remove_library_location": "Удалить из библиотеки", - "account": "Аккаунт", - "login_with_spotify": "Войдите с помощью своей учетной записи Spotify", - "connect_with_spotify": "Подключитесь к Spotify", - "logout": "Выйти", - "logout_of_this_account": "Выйдите из этого аккаунта", - "language_region": "Язык и регион", - "language": "Язык", - "system_default": "Системное значение по умолчанию", - "market_place_region": "Региональное пространство", - "recommendation_country": "Страна рекомендаций", - "appearance": "Внешний вид", - "layout_mode": "Режим компоновки", - "override_layout_settings": "Изменить настройки режима адаптивной компоновки", - "adaptive": "Адаптивный", - "compact": "Компактный", - "extended": "Расширенный", - "theme": "Тема", - "dark": "Тёмная", - "light": "Светлая", - "system": "Системная", - "accent_color": "Акцентный цвет", - "sync_album_color": "Синхронизировать цвет альбома", - "sync_album_color_description": "Использует основной цвет обложки альбома как цвет акцента", - "playback": "Воспроизведение", - "audio_quality": "Качество звука", - "high": "Высокое", - "low": "Низкое", - "pre_download_play": "Предварительная загрузка и воспроизведение", - "pre_download_play_description": "Вместо потоковой передачи аудио используйте загруженные байты и воспроизводьте их (рекомендуется для пользователей с высокой пропускной способностью)", - "skip_non_music": "Пропускать немузыкальные сегменты (SponsorBlock)", - "blacklist_description": "Черный список треков и артистов", - "wait_for_download_to_finish": "Пожалуйста, дождитесь завершения текущей загрузки", - "desktop": "Компьютер", - "close_behavior": "Поведение при закрытии", - "close": "Закрыть", - "minimize_to_tray": "Свернуть", - "show_tray_icon": "Показать значок на панели задач", - "about": "О нас", - "u_love_spotube": "Мы знаем что вам нравится Spotube", - "check_for_updates": "Проверьте наличие обновлений", - "about_spotube": "О Spotube", - "blacklist": "Чёрный список", - "please_sponsor": "Стать спосором/поддержать", - "spotube_description": "Spotube – это легкий, кросс-платформенный клиент Spotify, предоставляющий бесплатный доступ для всех пользователей", - "version": "Версия", - "build_number": "Номер сборки", - "founder": "Создатель", - "repository": "Репозиторий", - "bug_issues": "Ошибки и проблемы", - "made_with": "Сделано Bangladesh🇧🇩 с ❤️", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Лицензия", - "add_spotify_credentials": "Добавьте ваши учетные данные Spotify, чтобы начать", - "credentials_will_not_be_shared_disclaimer": "Не беспокойся, никакая личная информация не собирается и не передается", - "know_how_to_login": "Не знаете, как это сделать?", - "follow_step_by_step_guide": "Следуйте пошаговому руководству", - "spotify_cookie": "Spotify {name} Cookie", - "cookie_name_cookie": "{name} Cookie", - "fill_in_all_fields": "Пожалуйста, заполните все поля", - "submit": "Отправить", - "exit": "Выйти", - "previous": "Предыдущий", - "next": "Следующий", - "done": "Готово", - "step_1": "Шаг 1", - "first_go_to": "Сначала перейдите в", - "login_if_not_logged_in": "и войдите или зарегистрируйтесь, если вы не вошли в систему", - "step_2": "Шаг 2", - "step_2_steps": "1. После входа в систему нажмите F12 или щелкните правой кнопкой мыши > «Проверить», чтобы открыть инструменты разработчика браузера.\n2. Затем перейдите на вкладку \"Application\" (Chrome, Edge, Brave и т.д..) or \"Storage\" (Firefox, Palemoon и т.д..)\n3. Перейдите в раздел \"Cookies\", а затем в подраздел \"https://accounts.spotify.com\"", - "step_3": "Шаг 3", - "step_3_steps": "Скопируйте значение Cookie \"sp_dc\"", - "success_emoji": "Успешно🥳", - "success_message": "Теперь вы успешно вошли в свою учетную запись Spotify. Отличная работа, приятель!", - "step_4": "Шаг 4", - "step_4_steps": "Вставьте скопированное значение \"sp_dc\"", - "something_went_wrong": "Что-то пошло не так", - "piped_instance": "Экземпляр сервера Piped", - "piped_description": "Серверный экземпляр Piped для сопоставления треков", - "piped_warning": "Некоторые из них могут работать неправильно, поэтому используйте на свой страх и риск", - "generate_playlist": "Создать плейлист", - "track_exists": "Трек {track} уже существует", - "replace_downloaded_tracks": "Заменить все ранее скачанные треки", - "skip_download_tracks": "Пропустить загрузку всех ранее скачанных треков", - "do_you_want_to_replace": "Хотите заменить существующий трек??", - "replace": "Заменить", - "skip": "Пропустить", - "select_up_to_count_type": "Выберите до {count} {type}", - "select_genres": "Выберите жанр", - "add_genres": "Добавить жанр", - "country": "Страна", - "number_of_tracks_generate": "Количество треков для создания", - "acousticness": "Акустичность", - "danceability": "Ритмичность", - "energy": "Энергичность", - "instrumentalness": "Инструментальность", - "liveness": "Живость", - "loudness": "Громкость", - "speechiness": "Речевой характер", - "valence": "Значимость", - "popularity": "Популярность", - "key": "Ключ", - "duration": "Продолжительность (с)", - "tempo": "Темп (BPM)", - "mode": "Режим", - "time_signature": "Тактовый размер", - "short": "Короткий", - "medium": "Средний", - "long": "Длинный", - "min": "Минимум", - "max": "Максимум", - "target": "Цель", - "moderate": "Отобрать", - "deselect_all": "Убрать выделение со всех", - "select_all": "Выделить все", - "are_you_sure": "Вы уверены?", - "generating_playlist": "Создание собственного плейлиста...", - "selected_count_tracks": "Выбрано {count} треков", - "download_warning": "При скачивании всех треков пакетом вы фактически занимаетесь пиратством и наносите ущерб творческому обществу музыки. Надеюсь, что вы осознаете это. Всегда старайтесь уважать и поддерживать усилия исполнителей, вложенные в их творчество", - "download_ip_ban_warning": "Кроме того, стоит учитывать, что из-за чрезмерного количества запросов на скачивание ваш IP-адрес может быть заблокирован на YouTube. Блокировка IP означает, что вы не сможете использовать YouTube (даже если вы вошли в свою учетную запись) в течение, как минимум, 2-3 месяцев с того устройства, с которого были сделаны эти запросы. Важно заметить, что Spotube не несет ответственности за такие события", - "by_clicking_accept_terms": "Нажимая 'принять', вы соглашаетесь с следующими условиями:", - "download_agreement_1": "Я осознаю, что я использую музыку незаконно. Это плохо.", - "download_agreement_2": "Я бы поддержал исполнителей, где только смог, и делаю это, так как не имею средств на приобретение их творчества", - "download_agreement_3": "Я полностью осознаю, что мой IP-адрес может быть заблокирован на YouTube, и я не считаю Spotube или его владельцев/соавторов ответственными за какие-либо неприятности, вызванные моими текущими действиями", - "decline": "Отклонить", - "accept": "Принять", - "details": "Детали", - "youtube": "YouTube", - "channel": "Канал", - "likes": "Нравится", - "dislikes": "Не нравится", - "views": "Просмотров", - "streamUrl": "URL-адрес потока", - "stop": "Остановить", - "sort_newest": "Сортировать по самым новым добавленным", - "sort_oldest": "Сортировать по самым старым добавленным", - "sleep_timer": "Таймер сна", - "mins": "{minutes} Минут", - "hours": "{hours} Часы", - "hour": "{hours} Час", - "custom_hours": "Пользовательские часы", - "logs": "Журналы", - "developers": "Разработчики", - "not_logged_in": "Вы не выполнили вход", - "search_mode": "Режим поиска", - "audio_source": "Источник аудио", - "ok": "Ок", - "failed_to_encrypt": "Не удалось зашифровать", - "encryption_failed_warning": "Spotube использует шифрование для безопасного хранения ваших данных. Однако в этом случае произошла ошибка. Поэтому будет использовано небезопасное хранилище.\nЕсли вы используете Linux, убедитесь, что у вас установлен какой-либо инструмент для работы с секретами (gnome-keyring, kde-wallet, keepassxc и т.д.)", - "querying_info": "Запрос информации...", - "piped_api_down": "Piped API не отвечает", - "piped_down_error_instructions": "Экземпляр Piped {pipedInstance} в данный момент недоступен.\n\nВы можете либо изменить экземпляр, либо переключиться на использование официального API YouTube.\n\nНе забудьте перезапустить приложение после внесенных изменений", - "you_are_offline": "Нет доступа к сети", - "connection_restored": "Ваше интернет-соединение восстановлено", - "use_system_title_bar": "Использовать системную панель заголовка", - "crunching_results": "Обработка результатов...", - "search_to_get_results": "Поиск для получения результатов", - "use_amoled_mode": "Режим AMOLED", - "pitch_dark_theme": "Темная тема", - "normalize_audio": "Нормализовать звук", - "change_cover": "Изменить обложку", - "add_cover": "Добавить обложку", - "restore_defaults": "Восстановить настройки по умолчанию", - "download_music_codec": "Загрузить кодек для музыки", - "streaming_music_codec": "Кодек потоковой передачи музыки", - "login_with_lastfm": "Войти с помощью Last.fm", - "connect": "Подключить", - "disconnect_lastfm": "Отключиться от Last.fm", - "disconnect": "Отключить", - "username": "Имя пользователя", - "password": "Пароль", - "login": "Войти", - "login_with_your_lastfm": "Войти в свою учетную запись Last.fm", - "scrobble_to_lastfm": "Скробблинг на Last.fm", - "go_to_album": "Перейти к альбому", - "discord_rich_presence": "Богатое присутствие в Discord", - "browse_all": "Просмотреть все", - "genres": "Жанры", - "explore_genres": "Исследовать жанры", - "friends": "Друзья", - "no_lyrics_available": "Извините, не удается найти текст для этого трека", - "start_a_radio": "Запустить радио", - "how_to_start_radio": "Как вы хотите запустить радио?", - "replace_queue_question": "Хотите заменить текущую очередь или добавить к ней?", - "endless_playback": "Бесконечное воспроизведение", - "delete_playlist": "Удалить плейлист", - "delete_playlist_confirmation": "Вы уверены, что хотите удалить этот плейлист?", - "local_tracks": "Локальные треки", - "local_tab": "Локальное", - "song_link": "Ссылка на песню", - "skip_this_nonsense": "Пропустить этот бред", - "freedom_of_music": "“Свобода музыки”", - "freedom_of_music_palm": "“Свобода музыки в вашей ладони”", - "get_started": "Начнем", - "youtube_source_description": "Рекомендуется и лучше всего работает.", - "piped_source_description": "Чувствуете себя свободно? То же самое, что и YouTube, но намного бесплатно.", - "jiosaavn_source_description": "Лучший для Южно-Азиатского региона.", - "highest_quality": "Наивысшее качество: {quality}", - "select_audio_source": "Выберите аудиоисточник", - "endless_playback_description": "Автоматически добавляйте новые песни\nв конец очереди", - "choose_your_region": "Выберите ваш регион", - "choose_your_region_description": "Это поможет Spotube показать вам правильный контент\nдля вашего местоположения.", - "choose_your_language": "Выберите ваш язык", - "help_project_grow": "Помогите этому проекту расти", - "help_project_grow_description": "Spotube - это проект с открытым исходным кодом. Вы можете помочь этому проекту развиваться, внося вклад в проект, сообщая ошибках или предлагая новые функции.", - "contribute_on_github": "Внести вклад на GitHub", - "donate_on_open_collective": "Пожертвовать на Open Collective", - "browse_anonymously": "Анонимно просматривать", - "enable_connect": "Включить подключение", - "enable_connect_description": "Управление Spotube с других устройств", - "devices": "Устройства", - "select": "Выбрать", - "connect_client_alert": "Вас контролирует {client}", - "this_device": "Это устройство", - "remote": "Дистанционное управление", - "stats": "Статистика", - "update_playlist": "Обновить плейлист", - "and_n_more": "и {count} еще", - "recently_played": "Недавно воспроизведено", - "browse_more": "Посмотреть больше", - "no_title": "Без названия", - "not_playing": "Не воспроизводится", - "epic_failure": "Эпическое фиаско!", - "added_num_tracks_to_queue": "Добавлено {tracks_length} треков в очередь", - "spotube_has_an_update": "В Spotube доступно обновление", - "download_now": "Скачать сейчас", - "nightly_version": "Spotube Nightly {nightlyBuildNum} выпущен", - "release_version": "Spotube v{version} выпущен", - "read_the_latest": "Читать последние ", - "release_notes": "заметки о версии", - "pick_color_scheme": "Выберите цветовую схему", - "save": "Сохранить", - "choose_the_device": "Выберите устройство:", - "multiple_device_connected": "Подключено несколько устройств.\nВыберите устройство, на котором вы хотите выполнить это действие", - "nothing_found": "Ничего не найдено", - "the_box_is_empty": "Коробка пуста", - "top_artists": "Лучшие артисты", - "top_albums": "Лучшие альбомы", - "this_week": "На этой неделе", - "this_month": "В этом месяце", - "last_6_months": "Последние 6 месяцев", - "this_year": "В этом году", - "last_2_years": "Последние 2 года", - "all_time": "Все время", - "powered_by_provider": "При поддержке {providerName}", - "email": "Электронная почта", - "profile_followers": "Подписчики", - "birthday": "День рождения", - "subscription": "Подписка", - "not_born": "Не рожден", - "hacker": "Хакер", - "profile": "Профиль", - "no_name": "Без имени", - "edit": "Редактировать", - "user_profile": "Профиль пользователя", - "count_plays": "{count} воспроизведений", - "streaming_fees_hypothetical": "*Рассчитано на основе выплат Spotify за стрим\nот $0.003 до $0.005. Это гипотетический\nрасчет, чтобы показать пользователю, сколько бы он\nзаплатил артистам, если бы слушал их песни на Spotify.", - "count_mins": "{minutes} мин", - "summary_minutes": "минуты", - "summary_listened_to_music": "Слушанная музыка", - "summary_songs": "песни", - "summary_streamed_overall": "Всего стримов", - "summary_owed_to_artists": "К выплате артистам\nв этом месяце", - "summary_artists": "артиста", - "summary_music_reached_you": "Музыка дошла до вас", - "summary_full_albums": "полные альбомы", - "summary_got_your_love": "Получил вашу любовь", - "summary_playlists": "плейлисты", - "summary_were_on_repeat": "Были на повторе", - "total_money": "Всего {money}", - "minutes_listened": "Минут прослушивания", - "streamed_songs": "Стримленные песни", - "count_streams": "{count} стримов", - "owned_by_you": "Ваша собственность", - "copied_shareurl_to_clipboard": "{shareUrl} скопировано в буфер обмена", - "spotify_hipotetical_calculation": "*Это рассчитано на основе выплат Spotify за стрим\nот $0.003 до $0.005. Это гипотетический расчет,\nчтобы дать пользователю представление о том, сколько бы он\nзаплатил артистам, если бы слушал их песни на Spotify.", - "webview_not_found": "Webview не найден", - "webview_not_found_description": "На вашем устройстве не установлена среда выполнения Webview.\nЕсли он установлен, убедитесь, что он находится в environment PATH\n\nПосле установки перезапустите приложение", - "unsupported_platform": "Платформа не поддерживается", - "invidious_instance": "Экземпляр сервера Invidious", - "invidious_description": "Экземпляр сервера Invidious для сопоставления треков", - "invidious_warning": "Некоторые могут работать не очень хорошо. Используйте на свой страх и риск", - "invidious_source_description": "Похож на Piped, но с более высокой доступностью.", - "cache_music": "Кэшировать музыку", - "open": "Открыть", - "cache_folder": "Папка кэша", - "export": "Экспорт", - "clear_cache": "Очистить кэш", - "clear_cache_confirmation": "Вы хотите очистить кэш?", - "export_cache_files": "Экспортировать кэшированные файлы", - "found_n_files": "Найдено {count} файлов", - "export_cache_confirmation": "Вы хотите экспортировать эти файлы в", - "exported_n_out_of_m_files": "Экспортировано {filesExported} из {files} файлов", - "playlist": "Плейлист", - "no_loop": "Без повтора", - "generate": "Генерировать", - "undo": "Отменить", - "download_all": "Скачать все", - "add_all_to_playlist": "Добавить все в плейлист", - "add_all_to_queue": "Добавить все в очередь", - "play_all_next": "Воспроизвести все следующее", - "pause": "Пауза", - "view_all": "Просмотреть все", - "no_tracks_added_yet": "Похоже, вы ещё не добавили ни одного трека", - "no_tracks": "Похоже, здесь нет треков", - "no_tracks_listened_yet": "Похоже, вы ещё ничего не слушали", - "not_following_artists": "Вы не подписаны на художников", - "no_favorite_albums_yet": "Похоже, вы ещё не добавили ни одного альбома в избранное", - "no_logs_found": "Логи не найдены", - "youtube_engine": "YouTube Движок", - "youtube_engine_not_installed_title": "{engine} не установлен", - "youtube_engine_not_installed_message": "{engine} не установлен в вашей системе.", - "youtube_engine_set_path": "Убедитесь, что он доступен в переменной PATH или\nустановите абсолютный путь к исполнимому файлу {engine} ниже", - "youtube_engine_unix_issue_message": "В macOS/Linux/Unix-подобных ОС, установка пути в .zshrc/.bashrc/.bash_profile и т.д. не будет работать.\nВы должны установить путь в файле конфигурации оболочки", - "download": "Скачать", - "file_not_found": "Файл не найден", - "custom": "Пользовательский", - "add_custom_url": "Добавить пользовательский URL", - "edit_port": "Редактировать порт", - "port_helper_msg": "По умолчанию -1, что означает случайное число. Если у вас настроен брандмауэр, рекомендуется установить это.", - "connect_request": "Разрешить {client} подключение?", - "connection_request_denied": "Подключение отклонено. Пользователь отказал в доступе.", - "hipotetical_calculation": "*Это рассчитано на основе средней выплаты за прослушивание на онлайн-платформах для потоковой передачи музыки в размере от 0,003 до 0,005 долларов США. Это гипотетический расчет, чтобы дать пользователю представление о том, сколько бы они заплатили артистам, если бы слушали их песни на разных музыкальных стриминговых платформах.", - "an_error_occurred": "Произошла ошибка", - "copy_to_clipboard": "Скопировать в буфер обмена", - "view_logs": "Просмотреть журналы", - "retry": "Повторить", - "no_default_metadata_provider_selected": "Вы не выбрали поставщика метаданных по умолчанию", - "manage_metadata_providers": "Управление поставщиками метаданных", - "open_link_in_browser": "Открыть ссылку в браузере?", - "do_you_want_to_open_the_following_link": "Вы хотите открыть следующую ссылку", - "unsafe_url_warning": "Открытие ссылок из ненадежных источников может быть небезопасным. Будьте осторожны!\nВы также можете скопировать ссылку в буфер обмена.", - "copy_link": "Копировать ссылку", - "building_your_timeline": "Создание вашей временной шкалы на основе ваших прослушиваний...", - "official": "Официальный", - "author_name": "Автор: {author}", - "third_party": "Сторонний", - "plugin_requires_authentication": "Плагин требует аутентификации", - "update_available": "Доступно обновление", - "supports_scrobbling": "Поддерживает скробблинг", - "plugin_scrobbling_info": "Этот плагин скробблит вашу музыку для создания вашей истории прослушиваний.", - "default_plugin": "По умолчанию", - "set_default": "Установить по умолчанию", - "support": "Поддержка", - "support_plugin_development": "Поддержать разработку плагина", - "can_access_name_api": "- Может получить доступ к API **{name}**", - "do_you_want_to_install_this_plugin": "Вы хотите установить этот плагин?", - "third_party_plugin_warning": "Этот плагин из стороннего репозитория. Пожалуйста, убедитесь, что вы доверяете источнику перед установкой.", - "author": "Автор", - "this_plugin_can_do_following": "Этот плагин может выполнять следующее", - "install": "Установить", - "install_a_metadata_provider": "Установить поставщика метаданных", - "no_tracks_playing": "В настоящее время не воспроизводится ни один трек", - "synced_lyrics_not_available": "Синхронизированные тексты недоступны для этой песни. Пожалуйста, используйте вкладку", - "plain_lyrics": "Простые тексты", - "tab_instead": "вместо этого.", - "disclaimer": "Отказ от ответственности", - "third_party_plugin_dmca_notice": "Команда Spotube не несет никакой ответственности (в том числе юридической) за какие-либо \"сторонние\" плагины.\nПожалуйста, используйте их на свой страх и риск. О любых ошибках/проблемах сообщайте в репозиторий плагина.\n\nЕсли какой-либо \"сторонний\" плагин нарушает ToS/DMCA какого-либо сервиса/юридического лица, пожалуйста, попросите автора плагина \"стороннего\" или хостинговую платформу, например, GitHub/Codeberg, принять меры. Перечисленные выше (помеченные как \"сторонние\") являются общедоступными/поддерживаемыми сообществом плагинами. Мы не курируем их, поэтому не можем принимать по ним никаких мер.\n\n", - "input_does_not_match_format": "Введенные данные не соответствуют требуемому формату", - "metadata_provider_plugins": "Плагины поставщика метаданных", - "paste_plugin_download_url": "Вставьте URL-адрес для загрузки или URL-адрес репозитория GitHub/Codeberg или прямую ссылку на файл .smplug", - "download_and_install_plugin_from_url": "Загрузить и установить плагин по URL-адресу", - "failed_to_add_plugin_error": "Не удалось добавить плагин: {error}", - "upload_plugin_from_file": "Загрузить плагин из файла", - "installed": "Установлено", - "available_plugins": "Доступные плагины", - "configure_your_own_metadata_plugin": "Настройте свой собственный поставщик метаданных для плейлиста/альбома/артиста/ленты", - "audio_scrobblers": "Аудио скробблеры", - "scrobbling": "Скробблинг", - "download_music_format": "Формат загрузки музыки", - "streaming_music_format": "Формат потоковой музыки", - "download_music_quality": "Качество загрузки", - "streaming_music_quality": "Качество стриминга", - "default_metadata_source": "Источник метаданных по умолчанию", - "set_default_metadata_source": "Задать источник метаданных по умолчанию", - "default_audio_source": "Источник аудио по умолчанию", - "set_default_audio_source": "Задать источник аудио по умолчанию", - "plugins": "Плагины", - "configure_plugins": "Настройте собственные плагины провайдеров метаданных и источников аудио", - "source": "Источник: ", - "uncompressed": "Несжатый", - "dab_music_source_description": "Для аудиофилов. Предоставляет высококачественные/lossless аудиопотоки. Точное совпадение треков по ISRC." -} \ No newline at end of file diff --git a/lib/l10n/app_ta.arb b/lib/l10n/app_ta.arb deleted file mode 100644 index 6cea7b1a..00000000 --- a/lib/l10n/app_ta.arb +++ /dev/null @@ -1,492 +0,0 @@ -{ - "guest": "விருந்தினர்", - "browse": "உலாவு", - "search": "தேடுக", - "library": "நூலகம்", - "lyrics": "பாடல் வரிகள்", - "settings": "அமைப்புகள்", - "genre_categories_filter": "வகைகள் அல்லது பாணிகளை வடிகட்டுக...", - "genre": "பாணி", - "personalized": "தனிப்பயனாக்கப்பட்ட", - "featured": "சிறப்பிடம் பெற்ற", - "new_releases": "புதிய வெளியீடுகள்", - "songs": "பாடல்கள்", - "playing_track": "{track} இயங்குகிறது", - "queue_clear_alert": "இது தற்போதைய வரிசையை அழிக்கும். {track_length} பாடல்கள் நீக்கப்படும்\nதொடர விரும்புகிறீர்களா?", - "load_more": "மேலும் ஏற்றுக", - "playlists": "பாடல் பட்டியல்கள்", - "artists": "கலைஞர்கள்", - "albums": "ஆல்பங்கள்", - "tracks": "பாடல்கள்", - "downloads": "பதிவிறக்கங்கள்", - "filter_playlists": "உங்கள் பாடல் பட்டியல்களை வடிகட்டுக...", - "liked_tracks": "விரும்பிய பாடல்கள்", - "liked_tracks_description": "உங்கள் விரும்பிய பாடல்கள் அனைத்தும்", - "playlist": "பாடல் பட்டியல்", - "create_a_playlist": "பாடல் பட்டியலை உருவாக்குக", - "update_playlist": "பாடல் பட்டியலைப் புதுப்பிக்க", - "create": "உருவாக்கு", - "cancel": "ரத்து செய்", - "update": "புதுப்பி", - "playlist_name": "பாடல் பட்டியல் பெயர்", - "name_of_playlist": "பாடல் பட்டியலின் பெயர்", - "description": "விளக்கம்", - "public": "பொது", - "collaborative": "கூட்டு", - "search_local_tracks": "உள்ளூர் பாடல்களைத் தேடுக...", - "play": "இயக்கு", - "delete": "அழி", - "none": "எதுவுமில்லை", - "sort_a_z": "A-Z வரிசைப்படுத்து", - "sort_z_a": "Z-A வரிசைப்படுத்து", - "sort_artist": "கலைஞர் மூலம் வரிசைப்படுத்து", - "sort_album": "ஆல்பம் மூலம் வரிசைப்படுத்து", - "sort_duration": "கால அளவு மூலம் வரிசைப்படுத்து", - "sort_tracks": "பாடல்களை வரிசைப்படுத்து", - "currently_downloading": "தற்போது பதிவிறக்குகிறது ({tracks_length})", - "cancel_all": "அனைத்தையும் ரத்து செய்", - "filter_artist": "கலைஞர்களை வடிகட்டுக...", - "followers": "{followers} பின்தொடர்பவர்கள்", - "add_artist_to_blacklist": "கலைஞரை தடைப்பட்டியலில் சேர்க்க", - "top_tracks": "சிறந்த பாடல்கள்", - "fans_also_like": "ரசிகர்கள் விரும்புவது", - "loading": "ஏற்றுகிறது...", - "artist": "கலைஞர்", - "blacklisted": "தடைப்பட்டியலில் உள்ளது", - "following": "பின்தொடர்கிறது", - "follow": "பின்தொடர்", - "artist_url_copied": "கலைஞர் URL கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது", - "added_to_queue": "{tracks} பாடல்கள் வரிசையில் சேர்க்கப்பட்டன", - "filter_albums": "ஆல்பங்களை வடிகட்டுக...", - "synced": "ஒத்திசைக்கப்பட்டது", - "plain": "சாதாரண", - "shuffle": "கலக்கு", - "search_tracks": "பாடல்களைத் தேடுக...", - "released": "வெளியிடப்பட்டது", - "error": "பிழை {error}", - "title": "தலைப்பு", - "time": "நேரம்", - "more_actions": "மேலும் செயல்கள்", - "download_count": "பதிவிறக்கு ({count})", - "add_count_to_playlist": "({count}) பாடல் பட்டியலில் சேர்", - "add_count_to_queue": "({count}) வரிசையில் சேர்", - "play_count_next": "({count}) அடுத்து இயக்கு", - "album": "ஆல்பம்", - "copied_to_clipboard": "{data} கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது", - "add_to_following_playlists": "{track} பின்வரும் பாடல் பட்டியல்களில் சேர்", - "add": "சேர்", - "added_track_to_queue": "{track} வரிசையில் சேர்க்கப்பட்டது", - "add_to_queue": "வரிசையில் சேர்", - "track_will_play_next": "{track} அடுத்து இயக்கப்படும்", - "play_next": "அடுத்து இயக்கு", - "removed_track_from_queue": "{track} வரிசையிலிருந்து நீக்கப்பட்டது", - "remove_from_queue": "வரிசையிலிருந்து நீக்கு", - "remove_from_favorites": "பிடித்தவையிலிருந்து நீக்கு", - "save_as_favorite": "பிடித்தவையாக சேமி", - "add_to_playlist": "பாடல் பட்டியலில் சேர்", - "remove_from_playlist": "பாடல் பட்டியலிலிருந்து நீக்கு", - "add_to_blacklist": "தடைப்பட்டியலில் சேர்", - "remove_from_blacklist": "தடைப்பட்டியலிலிருந்து நீக்கு", - "share": "பகிர்", - "mini_player": "சிறிய இயக்கி", - "slide_to_seek": "முன்னோக்கி அல்லது பின்னோக்கி செல்ல சறுக்கவும்", - "shuffle_playlist": "பாடல் பட்டியலை கலக்கு", - "unshuffle_playlist": "பாடல் பட்டியலை கலக்காதே", - "previous_track": "முந்தைய பாடல்", - "next_track": "அடுத்த பாடல்", - "pause_playback": "இயக்கத்தை நிறுத்து", - "resume_playback": "இயக்கத்தை தொடர்", - "loop_track": "பாடலை சுழற்று", - "no_loop": "சுழற்சி இல்லை", - "repeat_playlist": "பாடல் பட்டியலை மீண்டும் இயக்கு", - "queue": "வரிசை", - "alternative_track_sources": "மாற்று பாடல் மூலங்கள்", - "download_track": "பாடலைப் பதிவிறக்கு", - "tracks_in_queue": "வரிசையில் {tracks} பாடல்கள்", - "clear_all": "அனைத்தையும் அழி", - "show_hide_ui_on_hover": "மேலே வரும்போது UI ஐக் காட்டு/மறை", - "always_on_top": "எப்போதும் மேலே", - "exit_mini_player": "சிறிய இயக்கியிலிருந்து வெளியேறு", - "download_location": "பதிவிறக்க இடம்", - "local_library": "உள்ளூர் நூலகம்", - "add_library_location": "நூலகத்தில் சேர்", - "remove_library_location": "நூலகத்திலிருந்து நீக்கு", - "account": "கணக்கு", - "login_with_spotify": "உங்கள் Spotify கணக்கில் உள்நுழைக", - "connect_with_spotify": "Spotify உடன் இணைக்கவும்", - "logout": "வெளியேறு", - "logout_of_this_account": "இந்த கணக்கிலிருந்து வெளியேறு", - "language_region": "மொழி & பிராந்தியம்", - "language": "மொழி", - "system_default": "கணினி இயல்புநிலை", - "market_place_region": "சந்தை பிராந்தியம்", - "recommendation_country": "பரிந்துரை நாடு", - "appearance": "தோற்றம்", - "layout_mode": "அமைப்பு முறை", - "override_layout_settings": "தளவமைப்பு அமைப்புகளை மாற்றியமை", - "adaptive": "தகவமைப்பு", - "compact": "சுருக்கமான", - "extended": "விரிவான", - "theme": "தீம்", - "dark": "இருள்", - "light": "வெளிர்", - "system": "கணினி வழி", - "accent_color": "அழுத்த நிறம்", - "sync_album_color": "ஆல்பம் நிறத்தை ஒத்திசை", - "sync_album_color_description": "ஆல்பம் படத்தின் முக்கிய நிறத்தை அழுத்த நிறமாகப் பயன்படுத்துகிறது", - "playback": "பின்னணி", - "audio_quality": "ஒலி தரம்", - "high": "உயர்", - "low": "குறைந்த", - "pre_download_play": "முன்பதிவிறக்கம் மற்றும் இயக்கம்", - "pre_download_play_description": "ஒலியை ஸ்ட்ரீம் செய்வதற்குப் பதிலாக, பைட்டுகளைப் பதிவிறக்கி இயக்கவும் (அதிக பேண்ட்விட்த் பயனர்களுக்கு பரிந்துரைக்கப்படுகிறது)", - "skip_non_music": "இசையல்லாத பகுதிகளைத் தவிர் (SponsorBlock)", - "blacklist_description": "தடைசெய்யப்பட்ட பாடல்கள் மற்றும் கலைஞர்கள்", - "wait_for_download_to_finish": "தற்போதைய பதிவிறக்கம் முடியும் வரை காத்திருக்கவும்", - "desktop": "கணினி", - "close_behavior": "மூடும் நடத்தை", - "close": "மூடு", - "minimize_to_tray": "ட்ரேயை குறைக்கவும்", - "show_tray_icon": "ட்ரே ஐகானைக் காட்டு", - "about": "பற்றி", - "u_love_spotube": "நீங்கள் Spotube ஐ நேசிக்கிறீர்கள் என்பது எங்களுக்குத் தெரியும்", - "check_for_updates": "புதுப்பிப்புகளைச் சரிபார்", - "about_spotube": "Spotube பற்றி", - "blacklist": "தடைப்பட்டியல்", - "please_sponsor": "தயவுசெய்து ஆதரவு/நன்கொடை அளியுங்கள்", - "spotube_description": "Spotube, ஒரு லேசான, பல தளங்களில் இயங்கும், அனைவருக்கும் இலவசமான spotify கிளையன்ட்", - "version": "பதிப்பு", - "build_number": "கட்டமைப்பு எண்", - "founder": "நிறுவனர்", - "repository": "களஞ்சியம்", - "bug_issues": "பிழை_சிக்கல்கள்", - "made_with": "வங்காளதேசத்திலிருந்து🇧🇩 ❤️ உருவாக்கப்பட்டது", - "kingkor_roy_tirtho": "கிங்கர் ராய் திர்தோ", - "copyright": "© 2021-{current_year} கிங்கர் ராய் திர்தோ", - "license": "உரிமம்", - "add_spotify_credentials": "தொடங்குவதற்கு உங்கள் spotify சான்றுகளைச் சேர்க்கவும்", - "credentials_will_not_be_shared_disclaimer": "கவலைப்பட வேண்டாம், உங்கள் சான்றுகள் எதுவும் சேகரிக்கப்படாது அல்லது யாருடனும் பகிரப்படாது", - "know_how_to_login": "இதை எப்படி செய்வது என்று தெரியவில்லையா?", - "follow_step_by_step_guide": "படிப்படியான வழிகாட்டியைப் பின்பற்றவும்", - "spotify_cookie": "Spotify {name} நட்புநிரல்", - "cookie_name_cookie": "{name} நட்புநிரல்", - "fill_in_all_fields": "அனைத்து களங்களையும் நிரப்பவும்", - "submit": "சமர்ப்பி", - "exit": "வெளியேறு", - "previous": "முந்தைய", - "next": "அடுத்து", - "done": "முடிந்தது", - "step_1": "முதல் படி", - "first_go_to": "முதலில், செல்லவேண்டியது", - "login_if_not_logged_in": "நீங்கள் உள்நுழையவில்லை என்றால் உள்நுழைக/பதிவுசெய்க", - "step_2": "இரண்டாம் படி", - "step_2_steps": "1. நீங்கள் உள்நுழைந்தவுடன், F12 ஐ அழுத்தவும் அல்லது வலது கிளிக் செய்து > ஆய்வு செய்யவும் உலாவி டெவ்டூல்களைத் திறக்கவும்.\n2. பின்னர் \"பயன்பாடு\" தாவலுக்குச் செல்லவும் (Chrome, Edge, Brave போன்றவை) அல்லது \"சேமிப்பகம்\" தாவல் (Firefox, Palemoon போன்றவை)\n3. \"குக்கிகள்\" பிரிவுக்குச் சென்று பின்னர் \"https://accounts.spotify.com\" பிரிவுக்குச் செல்லவும்", - "step_3": "மூன்றாம் படி", - "step_3_steps": "\"sp_dc\" நட்புநிரலின் மதிப்பை நகலெடுக்கவும்", - "success_emoji": "வெற்றி🥳", - "success_message": "இப்போது நீங்கள் உங்கள் Spotify கணக்கில் வெற்றிகரமாக உள்நுழைந்துள்ளீர்கள். நல்லது, நண்பரே!", - "step_4": "நான்காம் படி", - "step_4_steps": "நகலெடுக்கப்பட்ட \"sp_dc\" மதிப்பை ஒட்டவும்", - "something_went_wrong": "ஏதோ தவறு நடந்துவிட்டது", - "piped_instance": "Piped சேவையகம் நிகழ்வு", - "piped_description": "பாடல் பொருத்தத்திற்குப் பயன்படுத்த வேண்டிய Piped சேவையகம் நிகழ்வு", - "piped_warning": "அவற்றில் சில நன்றாக வேலை செய்யாமல் இருக்கலாம். எனவே உங்கள் சொந்த ஆபத்தில் பயன்படுத்தவும்", - "invidious_instance": "Invidious சேவையக நிகழ்வு", - "invidious_description": "பாடல் பொருத்தத்திற்குப் பயன்படுத்த வேண்டிய Invidious சேவையக நிகழ்வு", - "invidious_warning": "அவற்றில் சில நன்றாக வேலை செய்யாமல் இருக்கலாம். எனவே உங்கள் சொந்த ஆபத்தில் பயன்படுத்தவும்", - "generate": "உருவாக்கு", - "track_exists": "பாடல் {track} ஏற்கனவே உள்ளது", - "replace_downloaded_tracks": "பதிவிறக்கம் செய்யப்பட்ட அனைத்து பாடல்களையும் மாற்றவும்", - "skip_download_tracks": "பதிவிறக்கம் செய்யப்பட்ட அனைத்து பாடல்களையும் தவிர்க்கவும்", - "do_you_want_to_replace": "ஏற்கனவே உள்ள பாடலை மாற்ற விரும்புகிறீர்களா?", - "replace": "மாற்று", - "skip": "தவிர்", - "select_up_to_count_type": "{count} {type} வரை தேர்ந்தெடுக்கவும்", - "select_genres": "வகைகளைத் தேர்ந்தெடுக்கவும்", - "add_genres": "வகைகளைச் சேர்க்கவும்", - "country": "நாடு", - "number_of_tracks_generate": "உருவாக்க வேண்டிய பாடல்களின் எண்ணிக்கை", - "acousticness": "அகவுஸ்டிக்னெஸ்", - "danceability": "நடனத்தன்மை", - "energy": "ஆற்றல்", - "instrumentalness": "கருவித்தன்மை", - "liveness": "உயிர்ப்புத்தன்மை", - "loudness": "ஒலி அளவு", - "speechiness": "பேச்சுத்தன்மை", - "valence": "உணர்வு", - "popularity": "பிரபலம்", - "key": "இசை குறிப்பு", - "duration": "கால அளவு (வினாடிகள்)", - "tempo": "வேகம் (BPM)", - "mode": "முறை", - "time_signature": "நேர கையொப்பம்", - "short": "குறுகிய", - "medium": "நடுத்தர", - "long": "நீண்ட", - "min": "குறைந்தபட்சம்", - "max": "அதிகபட்சம்", - "target": "இலக்கு", - "moderate": "மிதமான", - "deselect_all": "அனைத்தையும் தேர்வுநீக்கு", - "select_all": "அனைத்தையும் தேர்ந்தெடு", - "are_you_sure": "உறுதியாக இருக்கிறீர்களா?", - "generating_playlist": "உங்கள் தனிப்பயன்பாட்டிற்கான பாடல் பட்டியலை உருவாக்குகிறது...", - "selected_count_tracks": "{count} பாடல்கள் தேர்ந்தெடுக்கப்பட்டன", - "download_warning": "நீங்கள் அனைத்து பாடல்களையும் மொத்தமாக பதிவிறக்கினால், நீங்கள் தெளிவாக இசையைத் திருடுகிறீர்கள் மற்றும் இசையின் படைப்பாற்றல் சமூகத்திற்கு சேதம் விளைவிக்கிறீர்கள். நீங்கள் இதை அறிந்திருக்கிறீர்கள் என்று நம்புகிறேன். எப்போதும், கலைஞரின் கடின உழைப்பை மதித்து ஆதரிக்க முயற்சி செய்யுங்கள்", - "download_ip_ban_warning": "மேலும், அதிகப்படியான பதிவிறக்க கோரிக்கைகள் காரணமாக உங்கள் IP YouTube இல் தடைசெய்யப்படலாம். IP தடை என்பது குறைந்தது 2-3 மாதங்களுக்கு அந்த IP சாதனத்திலிருந்து YouTube ஐப் பயன்படுத்த முடியாது (நீங்கள் உள்நுழைந்திருந்தாலும் கூட). இது ஒருபோதும் நடந்தால் Spotube பொறுப்பேற்காது", - "by_clicking_accept_terms": "'ஏற்றுக்கொள்' என்பதைக் கிளிக் செய்வதன் மூலம் பின்வரும் விதிமுறைகளுக்கு நீங்கள் ஒப்புக்கொள்கிறீர்கள்:", - "download_agreement_1": "நான் இசையைத் திருடுகிறேன் என்பது எனக்குத் தெரியும். நான் கெட்டவன்", - "download_agreement_2": "நான் கலைஞரை முடிந்தவரை ஆதரிப்பேன், அவர்களின் கலைக்கு பணம் செலுத்த எனக்கு பணம் இல்லாததால் மட்டுமே இதைச் செய்கிறேன்", - "download_agreement_3": "என் IP YouTube இல் தடைசெய்யப்படலாம் என்பதை நான் முழுமையாக அறிவேன், மேலும் என் தற்போதைய செயலால் ஏற்படும் எந்த விபத்துகளுக்கும் Spotube அல்லது அதன் உரிமையாளர்கள்/பங்களிப்பாளர்களை பொறுப்பாக்க மாட்டேன்", - "decline": "மறு", - "accept": "ஏற்றுக்கொள்", - "details": "விவரங்கள்", - "youtube": "YouTube", - "channel": "சேனல்", - "likes": "விருப்பங்கள்", - "dislikes": "விருப்பமில்லாதவை", - "views": "பார்வைகள்", - "streamUrl": "ஸ்ட்ரீம் URL", - "stop": "நிறுத்து", - "sort_newest": "புதிதாக சேர்க்கப்பட்டவற்றை வரிசைப்படுத்து", - "sort_oldest": "பழமையானவற்றை வரிசைப்படுத்து", - "sleep_timer": "உறக்க நேரம்", - "mins": "{minutes} நிமிடங்கள்", - "hours": "{hours} மணிநேரங்கள்", - "hour": "{hours} மணிநேரம்", - "custom_hours": "தனிப்பயன் மணிநேரங்கள்", - "logs": "பதிவுகள்", - "developers": "உருவாக்குநர்கள்", - "not_logged_in": "நீங்கள் உள்நுழையவில்லை", - "search_mode": "தேடல் முறை", - "audio_source": "ஒலி மூலம்", - "ok": "சரி", - "failed_to_encrypt": "குறியாக்கம் தோல்வியடைந்தது", - "encryption_failed_warning": "Spotube உங்கள் தரவை பாதுகாப்பாக சேமிக்க குறியாக்கத்தைப் பயன்படுத்துகிறது. ஆனால் அவ்வாறு செய்ய முடியவில்லை. எனவே இது பாதுகாப்பற்ற சேமிப்பகத்திற்கு மாறும்\nநீங்கள் லினக்ஸ் பயன்படுத்துகிறீர்கள் என்றால், எந்த ரகசிய சேவையும் (gnome-keyring, kde-wallet, keepassxc போன்றவை) நிறுவப்பட்டுள்ளதா என்பதை உறுதிப்படுத்தவும்", - "querying_info": "தகவலைக் கேட்கிறது...", - "piped_api_down": "Piped API செயலிழந்துள்ளது", - "piped_down_error_instructions": "Piped நிகழ்வு {pipedInstance} தற்போது செயலிழந்துள்ளது\n\nநிகழ்வை மாற்றவும் அல்லது 'API வகை'யை அதிகாரப்பூர்வ YouTube API க்கு மாற்றவும்\n\nமாற்றத்திற்குப் பிறகு பயன்பாட்டை மறுதொடக்கம் செய்வதை உறுதிப்படுத்தவும்", - "you_are_offline": "நீங்கள் தற்போது ஆஃப்லைனில் உள்ளீர்கள்", - "connection_restored": "உங்கள் இணைய இணைப்பு மீட்டெடுக்கப்பட்டது", - "use_system_title_bar": "கணினி தலைப்புப் பட்டியைப் பயன்படுத்தவும்", - "crunching_results": "முடிவுகளை செயலாக்குகிறது...", - "search_to_get_results": "முடிவுகளைப் பெற தேடவும்", - "use_amoled_mode": "கருமை நிற இருண்ட தீம்", - "pitch_dark_theme": "AMOLED முறை", - "normalize_audio": "ஒலியை சீரமை", - "change_cover": "அட்டையை மாற்று", - "add_cover": "அட்டையைச் சேர்", - "restore_defaults": "இயல்புநிலைகளை மீட்டமை", - "download_music_codec": "இசை கோடெக்கை பதிவிறக்கு", - "streaming_music_codec": "இசை கோடெக்கை ஸ்ட்ரீம் செய்", - "login_with_lastfm": "Last.fm உடன் உள்நுழைக", - "connect": "இணை", - "disconnect_lastfm": "Last.fm இலிருந்து துண்டி", - "disconnect": "துண்டி", - "username": "பயனர்பெயர்", - "password": "கடவுச்சொல்", - "login": "உள்நுழைக", - "login_with_your_lastfm": "உங்கள் Last.fm கணக்குடன் உள்நுழைக", - "scrobble_to_lastfm": "Last.fm க்கு ஸ்க்ரோபிள் செய்", - "go_to_album": "ஆல்பத்திற்குச் செல்", - "discord_rich_presence": "Discord செழுமையான தோற்றம்", - "browse_all": "அனைத்தையும் உலாவு", - "genres": "வகைகள்", - "explore_genres": "வகைகளை ஆராயுங்கள்", - "friends": "நண்பர்கள்", - "no_lyrics_available": "மன்னிக்கவும், இந்தப் பாடலுக்கான பாடல் வரிகளைக் கண்டுபிடிக்க முடியவில்லை", - "start_a_radio": "வானொலியைத் தொடங்கு", - "how_to_start_radio": "வானொலியை எவ்வாறு தொடங்க விரும்புகிறீர்கள்?", - "replace_queue_question": "தற்போதைய வரிசையை மாற்ற விரும்புகிறீர்களா அல்லது அதனுடன் சேர்க்க விரும்புகிறீர்களா?", - "endless_playback": "முடிவற்ற இயக்கம்", - "delete_playlist": "பாடல் பட்டியலை நீக்கு", - "delete_playlist_confirmation": "இந்த பாடல் பட்டியலை நீக்க விரும்புகிறீர்களா?", - "local_tracks": "உள்ளூர் பாடல்கள்", - "local_tab": "உள்ளூர்", - "song_link": "பாடல் இணைப்பு", - "skip_this_nonsense": "இந்த அர்த்தமற்றதைத் தவிர்", - "freedom_of_music": "\"இசையின் சுதந்திரம்\"", - "freedom_of_music_palm": "\"உங்கள் கைகளில் இசையின் சுதந்திரம்\"", - "get_started": "தொடங்குவோம்", - "youtube_source_description": "பரிந்துரைக்கப்படுகிறது மற்றும் சிறப்பாக செயல்படுகிறது.", - "piped_source_description": "சுதந்திரமாக உணர்கிறீர்களா? YouTube போலவே ஆனால் மிகவும் சுதந்திரமானது.", - "jiosaavn_source_description": "தெற்காசியப் பிராந்தியத்திற்கு சிறந்தது.", - "invidious_source_description": "Piped ஐப் போன்றது ஆனால் அதிக கிடைக்கும் தன்மையுடன்.", - "highest_quality": "உயர்ந்த தரம்: {quality}", - "select_audio_source": "ஒலி மூலத்தைத் தேர்ந்தெடுக்கவும்", - "endless_playback_description": "வரிசையின் இறுதியில் புதிய பாடல்களை\nதானாகவே சேர்க்கவும்", - "choose_your_region": "உங்கள் பிராந்தியத்தைத் தேர்ந்தெடுக்கவும்", - "choose_your_region_description": "இது உங்கள் இருப்பிடத்திற்கான சரியான உள்ளடக்கத்தை\nSpotube காட்ட உதவும்.", - "choose_your_language": "உங்கள் மொழியைத் தேர்ந்தெடுக்கவும்", - "help_project_grow": "இந்த திட்டம் வளர உதவுங்கள்", - "help_project_grow_description": "Spotube ஒரு திறந்த மூல திட்டம். திட்டத்திற்கு பங்களிப்பு செய்வதன் மூலம், பிழைகளைப் புகாரளிப்பதன் மூலம் அல்லது புதிய அம்சங்களைப் பரிந்துரைப்பதன் மூலம் இந்தத் திட்டம் வளர உதவலாம்.", - "contribute_on_github": "GitHub இல் பங்களியுங்கள்", - "donate_on_open_collective": "Open Collective இல் நன்கொடை அளியுங்கள்", - "browse_anonymously": "அநாமதேயமாக உலாவுக", - "enable_connect": "இணைப்பை இயக்கு", - "enable_connect_description": "மற்ற சாதனங்களிலிருந்து Spotube ஐக் கட்டுப்படுத்தவும்", - "devices": "சாதனங்கள்", - "select": "தேர்ந்தெடு", - "connect_client_alert": "நீங்கள் {client} ஆல் கட்டுப்படுத்தப்படுகிறீர்கள்", - "this_device": "இந்த சாதனம்", - "remote": "தொலைநிலை", - "stats": "புள்ளிவிவரங்கள்", - "and_n_more": "மற்றும் {count} கூடுதலாக", - "recently_played": "சமீபத்தில் இயக்கியவை", - "browse_more": "மேலும் உலாவு", - "no_title": "தலைப்பு இல்லை", - "not_playing": "இயக்கப்படவில்லை", - "epic_failure": "மோசமான தோல்வி!", - "added_num_tracks_to_queue": "{tracks_length} பாடல்கள் வரிசையில் சேர்க்கப்பட்டன", - "spotube_has_an_update": "Spotube க்கு ஒரு புதுப்பிப்பு உள்ளது", - "download_now": "இப்போது பதிவிறக்கு", - "nightly_version": "Spotube Nightly {nightlyBuildNum} வெளியிடப்பட்டுள்ளது", - "release_version": "Spotube v{version} வெளியிடப்பட்டுள்ளது", - "read_the_latest": "சமீபத்திய ", - "release_notes": "வெளியீட்டு குறிப்புகளைப் படிக்கவும்", - "pick_color_scheme": "வண்ணத் திட்டத்தைத் தேர்ந்தெடுக்கவும்", - "save": "சேமி", - "choose_the_device": "சாதனத்தைத் தேர்ந்தெடுக்கவும்:", - "multiple_device_connected": "பல சாதனங்கள் இணைக்கப்பட்டுள்ளன.\nஇந்த செயல் நடைபெற வேண்டிய சாதனத்தைத் தேர்ந்தெடுக்கவும்", - "nothing_found": "எதுவும் கிடைக்கவில்லை", - "the_box_is_empty": "பெட்டி காலியாக உள்ளது", - "top_artists": "சிறந்த கலைஞர்கள்", - "top_albums": "சிறந்த ஆல்பங்கள்", - "this_week": "இந்த வாரம்", - "this_month": "இந்த மாதம்", - "last_6_months": "கடந்த 6 மாதங்கள்", - "this_year": "இந்த ஆண்டு", - "last_2_years": "கடந்த 2 ஆண்டுகள்", - "all_time": "எல்லா நேரமும்", - "powered_by_provider": "{providerName} ஆல் இயக்கப்படுகிறது", - "email": "மின்னஞ்சல்", - "profile_followers": "பின்தொடர்பவர்கள்", - "birthday": "பிறந்த நாள்", - "subscription": "சந்தா", - "not_born": "பிறக்கவில்லை", - "hacker": "ஹேக்கர்", - "profile": "சுயவிவரம்", - "no_name": "பெயர் இல்லை", - "edit": "திருத்து", - "user_profile": "பயனர் சுயவிவரம்", - "count_plays": "{count} முறை இசைக்கப்பட்டது", - "streaming_fees_hypothetical": "ஸ்ட்ரீமிங் கட்டணங்கள் (கற்பனை)", - "minutes_listened": "காலம் கேட்டது", - "streamed_songs": "ஸ்ட்ரீமிங் செய்யப்பட்ட பாடல்கள்", - "count_streams": "{count} ஸ்ட்ரீம்கள்", - "owned_by_you": "உங்களால் கொண்டது", - "copied_shareurl_to_clipboard": "நகலெடுக்கப்பட்டது {shareUrl} கிளிப்போர்டுக்காக", - "spotify_hipotetical_calculation": "*இது Spotify இன் ஒவ்வொரு ஸ்ட்ரீமிற்கும்\n$0.003 முதல் $0.005 வரை அளவீடு அடிப்படையில் கணக்கிடப்படுகிறது. இது ஒரு கற்பனை\nகணக்கீடு ஆகும், பயனர் எந்த அளவிற்கு கலைஞர்களுக்கு\nஅதோர் பாடலை Spotify மென்பொருளில் கேட்டால் எவ்வளவு பணம் செலுத்தினார்கள் என்பதைக் கண்டுபிடிக்க.", - "count_mins": "{minutes} நிமிடங்கள்", - "summary_minutes": "நிமிடங்கள்", - "summary_listened_to_music": "இசை கேட்டது", - "summary_songs": "பாடல்கள்", - "summary_streamed_overall": "மொத்தமாக ஸ்ட்ரீமிங்", - "summary_owed_to_artists": "கலைஞர்களுக்கு\nஇந்த மாதம் சொந்தமானது", - "summary_artists": "கலைஞர்கள்", - "summary_music_reached_you": "இசை உங்களுக்கு வந்தது", - "summary_full_albums": "முழு ஆல்பங்கள்", - "summary_got_your_love": "உங்கள் அன்பை பெற்றுக்கொண்டேன்", - "summary_playlists": "பாடல் பட்டியல்கள்", - "summary_were_on_repeat": "மீண்டும் மீண்டும் இருந்தன", - "total_money": "மொத்தம் {money}", - "webview_not_found": "வெப்வியூ கிடைக்கவில்லை", - "webview_not_found_description": "உங்கள் சாதனத்தில் எந்தவொரு வெப்வியூ இயக்கத்தை நிறுவவில்லை.\nஇது நிறுவப்பட்டிருந்தால், சுற்றுச்சூழல் பாதையில் PATH உள்ளது என்பதை உறுதிபடுத்தவும்\n\nநிறுவித்த பிறகு, செயலியை மறுதொடக்கம் செய்யவும்", - "unsupported_platform": "அதிர்ஷ்டகாத உருப்படியை ஆதரிக்கவில்லை", - "cache_music": "இசையை கேஷ் செய்", - "open": "திறக்கவும்", - "cache_folder": "கேஷ் அடைவு", - "export": "ஏற்றுமதி", - "clear_cache": "கேஷ் அழிக்கவும்", - "clear_cache_confirmation": "கேஷைப் அழிக்க விரும்புகிறீர்களா?", - "export_cache_files": "கேஷில் உள்ள கோப்புகளை ஏற்றுமதி செய்யவும்", - "found_n_files": "{count} கோப்புகள் கிடைத்தன", - "export_cache_confirmation": "இந்த கோப்புகளை ஏற்றுமதி செய்ய விரும்புகிறீர்களா?", - "exported_n_out_of_m_files": "{filesExported} கோப்புகள் ஏற்றுமதி செய்யப்பட்டன, {files} கோப்புகளில்", - "undo": "செயல்தவிர்", - "download_all": "அனைத்தையும் பதிவிறக்குக", - "add_all_to_playlist": "அனைத்தையும் பாடல் பட்டியலில் சேர்க்கவும்", - "add_all_to_queue": "அனைத்தையும் வரிசைப்படுத்து", - "play_all_next": "அடுத்த உள்ள அனைத்தையும் இயக்கு", - "pause": "நிறுத்து", - "view_all": "அனைத்தையும் காண்க", - "no_tracks_added_yet": "உங்கள் பாடல்களை இன்னும் சேர்க்கவில்லை என்றால் தெரியாதே", - "no_tracks": "இங்கு பாடல்கள் எதுவும் இல்லை", - "no_tracks_listened_yet": "இன்னும் எதையும் கேள்வியில்லை", - "not_following_artists": "நீங்கள் எந்த கலைஞரையும் பின்தொடரவில்லை", - "no_favorite_albums_yet": "நீங்கள் இன்னும் எந்த ஆல்பங்களையும் பிடித்தவையாகச் சேர்க்கவில்லை", - "no_logs_found": "பதிவுகள் எதுவும் கிடைக்கவில்லை", - "youtube_engine": "YouTube இயந்திரம்", - "youtube_engine_not_installed_title": "{engine} நிறுவியதில்லை", - "youtube_engine_not_installed_message": "{engine} உங்கள் கணினியில் நிறுவியதில்லை.", - "youtube_engine_set_path": "PATH மாறியில் கிடைக்கிறதா என்பதை உறுதிப்படுத்தவும் அல்லது\n{engine} செயல் செய்யக்கூடிய முறையை கீழே அமைக்கவும்", - "youtube_engine_unix_issue_message": "macOS/Linux/unix போல் OS இல், .zshrc/.bashrc/.bash_profile போன்றவை அமைப்பில் பாதையை PATH அமைப்பது இயலாது.\nநீங்கள்.shell configuration file இல் பாதையை அமைக்க வேண்டும்", - "download": "பதிவிறக்கு", - "file_not_found": "கோப்பு கிடைக்கவில்லை", - "custom": "தனிப்பயன்", - "add_custom_url": "தனிப்பயன் URL ஐச் சேர்க்கவும்", - "edit_port": "போர்டு திருத்தவும்", - "port_helper_msg": "இயல்புநிலை -1 ஆகும், இது சீரற்ற எண்ணை குறிக்கிறது. நீங்கள் தீயணைப்பு அமைக்கப்பட்டிருந்தால், இதை அமைப்பது பரிந்துரைக்கப்படுகிறது.", - "connect_request": "{client} க்கு இணைக்க அனுமதிக்கவா?", - "connection_request_denied": "இணைப்பு மறுக்கப்பட்டது. பயனர் அணுகலை மறுத்தார்.", - "hipotetical_calculation": "*இது சராசரி ஆன்லைன் இசை ஸ்ட்ரீமிங் தளத்தின் ஒரு ஸ்ட்ரீமிற்கான $0.003 முதல் $0.005 வரையிலான கட்டணத்தின் அடிப்படையில் கணக்கிடப்படுகிறது. இது ஒரு கற்பனையான கணக்கீடு ஆகும், இது பயனர்கள் வெவ்வேறு இசை ஸ்ட்ரீமிங் தளங்களில் தங்கள் பாடல்களைக் கேட்டால் கலைஞர்களுக்கு எவ்வளவு பணம் செலுத்தியிருப்பார்கள் என்பது குறித்த நுண்ணறிவை வழங்குகிறது.", - "an_error_occurred": "ஒரு பிழை ஏற்பட்டது", - "copy_to_clipboard": "கிளிப்போர்டுக்கு நகலெடுக்கவும்", - "view_logs": "பதிவுகளைப் பார்க்கவும்", - "retry": "மீண்டும் முயற்சிக்கவும்", - "no_default_metadata_provider_selected": "நீங்கள் எந்த இயல்புநிலை மெட்டாடேட்டா வழங்குநரையும் அமைக்கவில்லை", - "manage_metadata_providers": "மெட்டாடேட்டா வழங்குநர்களை நிர்வகிக்கவும்", - "open_link_in_browser": "இணைப்பை உலாவியில் திறக்கவா?", - "do_you_want_to_open_the_following_link": "பின்வரும் இணைப்பை நீங்கள் திறக்க விரும்புகிறீர்களா", - "unsafe_url_warning": "நம்பத்தகாத மூலங்களிலிருந்து இணைப்புகளைத் திறப்பது பாதுகாப்பற்றதாக இருக்கலாம். எச்சரிக்கையாக இருங்கள்!\nநீங்கள் இணைப்பை உங்கள் கிளிப்போர்டுக்கு நகலெடுக்கலாம்.", - "copy_link": "இணைப்பை நகலெடுக்கவும்", - "building_your_timeline": "உங்கள் கேட்டலின் அடிப்படையில் உங்கள் காலவரிசையை உருவாக்குகிறது...", - "official": "அதிகாரபூர்வமானது", - "author_name": "ஆசிரியர்: {author}", - "third_party": "மூன்றாம் தரப்பு", - "plugin_requires_authentication": "பிளகின் அங்கீகாரத்தைக் கோருகிறது", - "update_available": "புதுப்பிப்பு உள்ளது", - "supports_scrobbling": "ஸ்க்ரோப்ளிங்கை ஆதரிக்கிறது", - "plugin_scrobbling_info": "இந்த பிளகின் உங்கள் கேட்பதின் வரலாற்றை உருவாக்க உங்கள் இசையை ஸ்க்ரோப்ள் செய்கிறது.", - "default_plugin": "இயல்புநிலை", - "set_default": "இயல்புநிலையாக அமைக்கவும்", - "support": "ஆதரவு", - "support_plugin_development": "பிளகின் வளர்ச்சிக்கு ஆதரவு", - "can_access_name_api": "- **{name}** API ஐ அணுக முடியும்", - "do_you_want_to_install_this_plugin": "இந்த பிளகினை நீங்கள் நிறுவ விரும்புகிறீர்களா?", - "third_party_plugin_warning": "இந்த பிளகின் மூன்றாம் தரப்பு களஞ்சியத்திலிருந்து வருகிறது. நிறுவும் முன் மூலத்தை நீங்கள் நம்புகிறீர்கள் என்பதை உறுதிப்படுத்தவும்.", - "author": "ஆசிரியர்", - "this_plugin_can_do_following": "இந்த பிளகின் பின்வருவனவற்றைச் செய்ய முடியும்", - "install": "நிறுவவும்", - "install_a_metadata_provider": "மெட்டாடேட்டா வழங்குநரை நிறுவவும்", - "no_tracks_playing": "தற்போது எந்த பாடலும் இயங்கவில்லை", - "synced_lyrics_not_available": "இந்த பாடலுக்கு ஒத்திசைக்கப்பட்ட வரிகள் கிடைக்கவில்லை. தயவுசெய்து", - "plain_lyrics": "சாதாரண வரிகள்", - "tab_instead": "தாவலை அதற்கு பதிலாக பயன்படுத்தவும்.", - "disclaimer": "துறப்பு", - "third_party_plugin_dmca_notice": "ஸ்பாட்யூப் குழு எந்த \"மூன்றாம் தரப்பு\" பிளகின்களுக்கும் எந்தப் பொறுப்பையும் (சட்டரீதியான உட்பட) ஏற்காது.\nதயவுசெய்து உங்கள் சொந்த ஆபத்தில் அவற்றைப் பயன்படுத்தவும். ஏதேனும் பிழைகள்/சிக்கல்களுக்கு, பிளகின் களஞ்சியத்தில் அவற்றைப் புகாரளிக்கவும்.\n\nஏதேனும் ஒரு \"மூன்றாம் தரப்பு\" பிளகின் ஒரு சேவை/சட்ட நிறுவனத்தின் ToS/DMCA ஐ மீறினால், தயவுசெய்து \"மூன்றாம் தரப்பு\" பிளகின் ஆசிரியரையோ அல்லது ஹோஸ்டிங் தளத்தையோ, எ.கா. GitHub/Codeberg, நடவடிக்கை எடுக்கக் கோரவும். மேலே பட்டியலிடப்பட்ட (\"மூன்றாம் தரப்பு\" என பெயரிடப்பட்ட) அனைத்து பொதுவான/சமூகத்தால் பராமரிக்கப்படும் பிளகின்கள். நாங்கள் அவற்றை க்யூரேட் செய்யவில்லை, எனவே அவற்றின் மீது எந்த நடவடிக்கையும் எடுக்க முடியாது.\n\n", - "input_does_not_match_format": "உள்ளீடு தேவையான வடிவத்துடன் பொருந்தவில்லை", - "metadata_provider_plugins": "மெட்டாடேட்டா வழங்குநர் பிளகின்கள்", - "paste_plugin_download_url": "பதிவிறக்க url அல்லது GitHub/Codeberg repo url அல்லது .smplug கோப்பிற்கான நேரடி இணைப்பை ஒட்டவும்", - "download_and_install_plugin_from_url": "url இலிருந்து பிளகினைப் பதிவிறக்கி நிறுவவும்", - "failed_to_add_plugin_error": "பிளகினைச் சேர்க்கத் தவறிவிட்டது: {error}", - "upload_plugin_from_file": "கோப்பிலிருந்து பிளகினைப் பதிவேற்றவும்", - "installed": "நிறுவப்பட்டது", - "available_plugins": "கிடைக்கக்கூடிய பிளகின்கள்", - "configure_your_own_metadata_plugin": "உங்கள் சொந்த பிளேலிஸ்ட்/ஆல்பம்/கலைஞர்/ஊட்ட மெட்டாடேட்டா வழங்குநரை உள்ளமைக்கவும்", - "audio_scrobblers": "ஆடியோ ஸ்க்ரோப்ளர்கள்", - "scrobbling": "ஸ்க்ரோப்ளிங்", - "download_music_format": "இசை பதிவிறக்க வடிவம்", - "streaming_music_format": "இசை ஸ்ட்ரீமிங் வடிவம்", - "download_music_quality": "பதிவிறக்க தரம்", - "streaming_music_quality": "ஸ்ட்ரீமிங் தரம்", - "default_metadata_source": "இயல்புநிலை மெட்டாடேட்டா மூலம்", - "set_default_metadata_source": "இயல்புநிலை மெட்டாடேட்டா மூலத்தை அமை", - "default_audio_source": "இயல்புநிலை ஆடியோ மூலம்", - "set_default_audio_source": "இயல்புநிலை ஆடியோ மூலத்தை அமை", - "plugins": "செருகுநிரல்கள்", - "configure_plugins": "உங்கள் சொந்த மெட்டாடேட்டா வழங்குநர் மற்றும் ஆடியோ மூல செருகுநிரல்களை அமைக்கவும்", - "source": "மூலம்: ", - "uncompressed": "அழுத்தப்படாத", - "dab_music_source_description": "ஆடியோஃபைல்களுக்காக. உயர்தர/லாஸ்லெஸ் ஆடியோ ஸ்ட்ரீம்களை வழங்குகிறது. ISRC அடிப்படையில் துல்லியமான பாடல் பொருத்தம்." -} \ No newline at end of file diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb deleted file mode 100644 index 4f2efc0e..00000000 --- a/lib/l10n/app_th.arb +++ /dev/null @@ -1,495 +0,0 @@ -{ - "guest": "ผู้มาเยือน", - "browse": "เรียกดู", - "search": "ค้นหา", - "library": "คลัง", - "lyrics": "เนื้อเพลง", - "settings": "ตั้งค่า", - "genre_categories_filter": "กรองประเภทหรือแนวเพลง...", - "genre": "ประเภท", - "personalized": "ปรับแต่ง", - "featured": "เด่น", - "new_releases": "เพิ่งปล่อยใหม่", - "songs": "เพลง", - "playing_track": "กำลังเล่น {track}", - "queue_clear_alert": "การดำเนินการนี้จะล้างคิวปัจจุบัน {track_length} แทร็ก จะถูกลบออก\nคุณต้องการดำเนินการต่อหรือไม่?", - "load_more": "โหลดเพิ่มเติม", - "playlists": "เพลย์ลิสต์", - "artists": "ศิลปิน", - "albums": "อัลบั้ม", - "tracks": "แทร็ก", - "downloads": "ดาวน์โหลด", - "filter_playlists": "กรองเพลย์ลิสต์...", - "liked_tracks": "เพลงที่ชอบ", - "liked_tracks_description": "เพลงที่คุณชื่นชอบทั้งหมด", - "create_playlist": "สร้างเพลย์ลิสต์", - "create_a_playlist": "สร้างเพลย์ลิสต์", - "update_playlist": "อัพเดทเพลย์ลิสต์", - "create": "สร้าง", - "cancel": "ยกเลิก", - "update": "อัพเดท", - "playlist_name": "ชื่อเพลย์ลิสต์", - "name_of_playlist": "ชื่อของเพลย์ลิสต์", - "description": "คำอธิบาย", - "public": "สาธารณะ", - "collaborative": "ร่วมมือกัน", - "search_local_tracks": "ค้นหาเพลงในเครื่อง...", - "play": "เล่น", - "delete": "ลบ", - "none": "ไม่มี", - "sort_a_z": "เรียงตาม A-Z", - "sort_z_a": "เรียงตาม Z-A", - "sort_artist": "เรียงตามศิลปิน", - "sort_album": "เรียงตามอัลบั้ม", - "sort_duration": "เรียงตามความยาว", - "sort_tracks": "เรียงตามเพลง", - "currently_downloading": "กำลังดาวน์โหลด ({tracks_length})", - "cancel_all": "ยกเลิกทั้งหมด", - "filter_artist": "กรองศิลปิน...", - "followers": "{followers} ผู้ติดตาม", - "add_artist_to_blacklist": "เพิ่มศิลปินในบัญชีดำ", - "top_tracks": "เพลงฮิต", - "fans_also_like": "แฟนๆ ยังชอบ", - "loading": "กำลังโหลด...", - "artist": "ศิลปิน", - "blacklisted": "อยู่ในบัญชีดำ", - "following": "กำลังติดตาม", - "follow": "ติดตาม", - "artist_url_copied": "คัดลอก URL ศิลปินไปยังคลิปบอร์ด", - "added_to_queue": "เพิ่ม {tracks} เพลงลงในคิว", - "filter_albums": "กรองอัลบั้ม...", - "synced": "ซิงค์", - "plain": "เรียบง่าย", - "shuffle": "สุ่ม", - "search_tracks": "ค้นหาเพลง...", - "released": "เผยแพร่", - "error": "ข้อผิดพลาด {error}", - "title": "ชื่อ", - "time": "เวลา", - "more_actions": "เพิ่มเติม", - "download_count": "ดาวน์โหลด ({count})", - "add_count_to_playlist": "เพิ่ม ({count}) ลงในเพลย์ลิสต์", - "add_count_to_queue": "เพิ่ม ({count}) ลงในคิว", - "play_count_next": "เล่น ({count}) ต่อไป", - "album": "อัลบั้ม", - "copied_to_clipboard": "คัดลอก {data} ไปยังคลิปบอร์ด", - "add_to_following_playlists": "เพิ่ม {track} ลงในเพลย์ลิสต์", - "add": "เพิ่ม", - "added_track_to_queue": "เพิ่ม {track} ลงในคิว", - "add_to_queue": "เพิ่มลงในคิว", - "track_will_play_next": "{track} จะเล่นต่อไป", - "play_next": "เล่นต่อไป", - "removed_track_from_queue": "ลบ {track} ออกจากคิว", - "remove_from_queue": "ลบออกจากคิว", - "remove_from_favorites": "ลบออกจากรายการโปรด", - "save_as_favorite": "บันทึกเป็นรายการโปรด", - "add_to_playlist": "เพิ่มลงในเพลย์ลิสต์", - "remove_from_playlist": "ลบออกจากเพลย์ลิสต์", - "add_to_blacklist": "เพิ่มลงในบัญชีดำ", - "remove_from_blacklist": "ลบออกจากบัญชีดำ", - "share": "แชร์", - "mini_player": "มินิเพลเยอร์", - "slide_to_seek": "เลื่อนเพื่อไปข้างหน้าหรือถอยหลัง", - "shuffle_playlist": "สุ่มเพลย์ลิสต์", - "unshuffle_playlist": "ยกเลิกการสุ่มเพลย์ลิสต์", - "previous_track": "แทร็กก่อนหน้า", - "next_track": "แทร็กถัดไป", - "pause_playback": "หยุดการเล่น", - "resume_playback": "เล่นต่อ", - "loop_track": "วนเพลง", - "repeat_playlist": "ซ้ำเพลย์ลิสต์", - "queue": "คิว", - "alternative_track_sources": "แหล่งแทร็กอื่น", - "download_track": "ดาวน์โหลดแทร็ก", - "tracks_in_queue": "{tracks} แทร็กในคิว", - "clear_all": "ล้างทั้งหมด", - "show_hide_ui_on_hover": "แสดง/ซ่อน UI เมื่อโฮเวอร์", - "always_on_top": "อยู่ด้านบนเสมอ", - "exit_mini_player": "ออกจากมินิเพลย์เยอร์", - "download_location": "ตำแหน่งดาวน์โหลด", - "account": "บัญชี", - "login_with_spotify": "เข้าสู่ระบบด้วยบัญชี Spotify", - "connect_with_spotify": "เชื่อมต่อกับ Spotify", - "logout": "ออกจากระบบ", - "logout_of_this_account": "ออกจากระบบบัญชีนี้", - "language_region": "ภาษาและภูมิภาค", - "language": "ภาษา", - "system_default": "ค่าเริ่มต้นของระบบ", - "market_place_region": "ภูมิภาค Marketplace", - "recommendation_country": "ประเทศที่แนะนำ", - "appearance": "ลักษณะที่ปรากฏ", - "layout_mode": "โหมดเค้าโครง", - "override_layout_settings": "แทนที่การตั้งค่าโหมดเค้าโครงแบบตอบสนอง", - "adaptive": "ปรับเปลี่ยน", - "compact": "กระชับ", - "extended": "ขยาย", - "theme": "ธีม", - "dark": "มืด", - "light": "สว่าง", - "system": "ระบบ", - "accent_color": "สีเน้น", - "sync_album_color": "ซิงค์สีอัลบั้ม", - "sync_album_color_description": "ใช้สีเด่นของอาร์ตอัลบั้มเป็นสีเน้น", - "playback": "การเล่น", - "audio_quality": "คุณภาพเสียง", - "high": "สูง", - "low": "ต่ำ", - "pre_download_play": "ดาวน์โหลดล่วงหน้าและเล่น", - "pre_download_play_description": "แทนที่จะสตรีมเสียง ดาวน์โหลดข้อมูลและเล่นแทน (แนะนำสำหรับผู้ใช้แบนด์วิดธ์สูง)", - "skip_non_music": "ข้ามส่วนที่ไม่ใช่เพลง (SponsorBlock)", - "blacklist_description": "แทร็กและศิลปินที่บล็อก", - "wait_for_download_to_finish": "โปรดรอให้การดาวน์โหลดปัจจุบันเสร็จสิ้น", - "desktop": "เดสก์ท็อป", - "close_behavior": "ปิดพฤติกรรม", - "close": "ปิด", - "minimize_to_tray": "ลดขนาดลงถาด", - "show_tray_icon": "แสดงไอคอนถาดระบบ", - "about": "เกี่ยวกับ", - "u_love_spotube": "เรารู้ว่าคุณรัก Spotube", - "check_for_updates": "ตรวจสอบการปรับปรุง", - "about_spotube": "เกี่ยวกับ Spotube", - "blacklist": "แบล็กลิสต์", - "please_sponsor": "กรุณาสนับสนุน/บริจาค", - "spotube_description": "Spotube โปรแกรมเล่น Spotify ฟรีสำหรับทุกคน น้ำหนักเบา รองรับหลายแพลตฟอร์ม", - "version": "รุ่น", - "build_number": "หมายเลขบิลด์", - "founder": "ผู้ก่อตั้ง", - "repository": "ที่เก็บ", - "bug_issues": "ข้อผิดพลาด+ปัญหา", - "made_with": "ทำด้วย❤️ใน บังคลาเทศ🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "ใบอนุญาต", - "add_spotify_credentials": "เพิ่มข้อมูลรับรอง Spotify ของคุณเพื่อเริ่มต้น", - "credentials_will_not_be_shared_disclaimer": "ไม่ต้องกังวล ข้อมูลรับรองใดๆ ของคุณจะไม่ถูกเก็บรวบรวมหรือแชร์กับใคร", - "know_how_to_login": "ไม่รู้จักวิธีดำเนินการนี้ใช่ไหม", - "follow_step_by_step_guide": "ทำตามคู่มือทีละขั้น", - "spotify_cookie": "คุกกี้ Spotify {name}", - "cookie_name_cookie": "คุกกี้ {name}", - "fill_in_all_fields": "กรุณากรอกข้อมูลทุกช่อง", - "submit": "ยื่น", - "exit": "ออก", - "previous": "ย้อนกลับ", - "next": "ถัดไป", - "done": "เสร็จ", - "step_1": "ขั้นที่ 1", - "first_go_to": "ก่อนอื่น ไปที่", - "login_if_not_logged_in": "ยังไม่ได้เข้าสู่ระบบ ให้เข้าสู่ระบบ/ลงทะเบียน", - "step_2": "ขั้นที่ 2", - "step_2_steps": "1. หลังจากเข้าสู่ระบบแล้ว กด F12 หรือ คลิกขวาที่เมาส์ > ตรวจสอบเพื่อเปิด Devtools เบราว์เซอร์\n2. จากนั้นไปที่แท็บ \"แอปพลิเคชัน\" (Chrome, Edge, Brave เป็นต้น) หรือแท็บ \"ที่เก็บข้อมูล\" (Firefox, Palemoon เป็นต้น)\n3. ไปที่ส่วน \"คุกกี้\" แล้วไปที่ subsection \"https: //accounts.spotify.com\"", - "step_3": "ขั้นที่ 3", - "step_3_steps": "คัดลอกค่าคุกกี้ \"sp_dc\"", - "success_emoji": "สำเร็จ", - "success_message": "ตอนนี้คุณเข้าสู่ระบบด้วยบัญชี Spotify ของคุณเรียบร้อยแล้ว ยอดเยี่ยม!", - "step_4": "ขั้นที่ 4", - "step_4_steps": "วางค่า \"sp_dc\" ที่คัดลอกมา", - "something_went_wrong": "มีอะไรผิดพลาด", - "piped_instance": "อินสแตนซ์เซิร์ฟเวอร์แบบ Pipe", - "piped_description": "อินสแตนซ์เซิร์ฟเวอร์แบบ Pipe ที่ใช้สำหรับการจับคู่แทร็ก", - "piped_warning": "บางอย่างอาจใช้งานไม่ได้ผล คุณจึงต้องรับความเสี่ยงเอง", - "generate_playlist": "สร้างเพลย์ลิสต์", - "track_exists": "แทร็ก {track} มีอยู่แล้ว", - "replace_downloaded_tracks": "แทนที่แทร็กที่ดาวน์โหลดทั้งหมด", - "skip_download_tracks": "ข้ามการดาวน์โหลดแทร็กที่ดาวน์โหลดทั้งหมด", - "do_you_want_to_replace": "คุณต้องการแทนที่แทร็กที่มีอยู่หรือไม่", - "replace": "แทนที่", - "skip": "ข้าม", - "select_up_to_count_type": "เลือกสูงสุด {count} {type}", - "select_genres": "เลือกประเภท", - "add_genres": "เพิ่มประเภท", - "country": "ประเทศ", - "number_of_tracks_generate": "จำนวนแทร็กที่จะสร้าง", - "acousticness": "อะคูสติก", - "danceability": "ความสามารถในการเต้น", - "energy": "พลัง", - "instrumentalness": "บรรเลง", - "liveness": "ความสด", - "loudness": "ความดัง", - "speechiness": "การพูด", - "valence": "ความสุข", - "popularity": "ความนิยม", - "key": "คีย์", - "duration": "ระยะเวลา (วินาที)", - "tempo": "ความเร็ว (BPM)", - "mode": "โหมด", - "time_signature": "ลายเซ็นเวลา", - "short": "สั้น", - "medium": "กลาง", - "long": "ยาว", - "min": "ต่ำสุด", - "max": "สูงสุด", - "target": "เป้าหมาย", - "moderate": "ปานกลาง", - "deselect_all": "ยกเลิกการเลือกทั้งหมด", - "select_all": "เลือกทั้งหมด", - "are_you_sure": "คุณแน่ใจไหม?", - "generating_playlist": "กำลังสร้างเพลย์ลิสต์ที่คุณกำหนดเอง...", - "selected_count_tracks": "เลือก {count} แทร็ก", - "download_warning": "ถ้าคุณดาวน์โหลดเพลงทั้งหมดเป็นจำนวนมาก คุณกำลังละเมิดลิขสิทธิ์เพลงและสร้างความเสียหายให้กับสังคมดนตรี สร้างสรรค์ หวังว่าคุณจะรับรู้เรื่องนี้ เสมอ พยายามเคารพและสนับสนุนผลงานหนักของศิลปิน", - "download_ip_ban_warning": "นอกเหนือจากนั้น IP ของคุณอาจถูกบล็อกบน YouTube เนื่องจากคำขอดาวน์โหลดมากเกินกว่าปกติ การบล็อก IP หมายความว่าคุณไม่สามารถใช้ YouTube (แม้ว่าคุณจะล็อกอินอยู่) เป็นเวลาอย่างน้อย 2-3 เดือนจากอุปกรณ์ IP นั้น และ Spotube จะไม่รับผิดชอบใด ๆ หากสิ่งนี้เกิดขึ้น", - "by_clicking_accept_terms": "คลิก 'ยอมรับ' คุณยินยอมตามเงื่อนไขต่อไปนี้:", - "download_agreement_1": "ฉันรู้ว่าฉันกำลังละเมิดลิขสิทธิ์เพลง ฉันเลว", - "download_agreement_2": "ฉันจะสนับสนุนศิลปินทุกที่ที่ฉันทำได้และฉันทำสิ่งนี้เพียงเพราะฉันไม่มีเงินซื้อผลงานศิลปะของพวกเขา", - "download_agreement_3": "ฉันรับทราบอย่างสมบูรณ์ว่า IP ของฉันอาจถูกบล็อกบน YouTube และฉันจะไม่ถือ Spotube หรือเจ้าของ/ผู้มีส่วนร่วมใด ๆ รับผิดชอบต่ออุบัติเหตุใด ๆ ที่เกิดจากการกระทำปัจจุบันของฉัน", - "decline": "ปฏิเสธ", - "accept": "ยอมรับ", - "details": "รายละเอียด", - "youtube": "youtube", - "channel": "ช่อง", - "likes": "ถูกใจ", - "dislikes": "ไม่ชอบ", - "views": "วิว", - "streamUrl": "สตรีม URL", - "stop": "หยุด", - "sort_newest": "เรียงตามการเพิ่มใหม่ล่าสุด", - "sort_oldest": "เรียงตามการเพิ่มเก่าสุด", - "sleep_timer": "ตั้งเวลาปิด", - "mins": "{minutes} นาที", - "hours": "{hours} ชั่วโมง", - "hour": "{hours} ชั่วโมง", - "custom_hours": "ชั่วโมงที่กำหนดเอง", - "logs": "บันทึก", - "developers": "นักพัฒนา", - "not_logged_in": "คุณไม่ได้เข้าสู่ระบบ", - "search_mode": "โหมดการค้นหา", - "audio_source": "แหล่งที่มาของเสียง", - "ok": "ตกลง", - "failed_to_encrypt": "เข้ารหัสล้มเหลว", - "encryption_failed_warning": "Spotube ใช้การเข้ารหัสเพื่อเก็บข้อมูลของคุณอย่างปลอดภัย แต่ไม่สามารถทำได้ ดังนั้นจะเปลี่ยนเป็นการจัดเก็บที่ไม่ปลอดภัย\nหากคุณใช้ Linux โปรดตรวจสอบว่าคุณได้ติดตั้งบริการลับ (gnome-keyring, kde-wallet, keepassxc เป็นต้น)", - "querying_info": "กำลังดึงข้อมูล...", - "piped_api_down": "Piped API ไม่ทำงาน", - "piped_down_error_instructions": "Piped instance {pipedInstance} ไม่ทำงานขณะนี้\n\nเปลี่ยนอินสแตนซ์หรือเปลี่ยน 'ประเภท API' เป็น YouTube API อย่างเป็นทางการ\n\nอย่าลืมรีสตาร์ทแอปหลังจากเปลี่ยน", - "you_are_offline": "คุณออฟไลน์อยู่", - "connection_restored": "การเชื่อมต่ออินเทอร์เน็ตของคุณได้รับการกู้คืน", - "use_system_title_bar": "ใช้แถบชื่อระบบ", - "crunching_results": "กำลังประมวลผล...", - "search_to_get_results": "ค้นหาเพื่อดูผลลัพธ์", - "use_amoled_mode": "ธีมมืดสนิท", - "pitch_dark_theme": "โหมด AMOLED", - "normalize_audio": "ปรับระดับเสียง", - "change_cover": "เปลี่ยนปก", - "add_cover": "เพิ่มปก", - "restore_defaults": "คืนค่าเริ่มต้น", - "download_music_codec": "ดาวน์โหลดโคเดคเพลง", - "streaming_music_codec": "สตรีมมิ่งโคเดคเพลง", - "login_with_lastfm": "เข้าสู่ระบบด้วย Last.fm", - "connect": "เชื่อมต่อ", - "disconnect_lastfm": "ตัดการเชื่อมต่อ Last.fm", - "disconnect": "ตัดการเชื่อมต่อ", - "username": "ชื่อผู้ใช้", - "password": "รหัสผ่าน", - "login": "เข้าสู่ระบบ", - "login_with_your_lastfm": "เข้าสู่ระบบด้วย Last.fm", - "scrobble_to_lastfm": "Scrobble ไปเป็น Last.fm", - "go_to_album": "ไปที่อัลบั้ม", - "discord_rich_presence": "Discord Rich Presence", - "browse_all": "เรียกดูทั้งหมด", - "genres": "ประเภท", - "explore_genres": "สำรวจประเภท", - "friends": "เพื่อน", - "no_lyrics_available": "ขออภัย ไม่พบเนื้อเพลงสำหรับเพลงนี้", - "start_a_radio": "เปิดวิทยุ", - "how_to_start_radio": "หากต้องการเปิดวิทยุฟังยังไง?", - "replace_queue_question": "คุณต้องการแทนที่คิวปัจจุบันหรือเพิ่มเข้าไปหรือไม่", - "endless_playback": "เล่นซ้ำ", - "delete_playlist": "ลบเพลย์ลิสต์", - "delete_playlist_confirmation": "คุณแน่ใจที่จะลบเพลย์ลิสต์นี้หรือไม่", - "local_tracks": "เพลงในเครื่อง", - "song_link": "ลิงค์เพลง", - "skip_this_nonsense": "ข้ามสิ่งไร้สาระนี้", - "freedom_of_music": "“เสรีภาพแห่งเสียงเพลง”", - "freedom_of_music_palm": "“เสรีภาพแห่งเสียงเพลง ในมือของคุณ”", - "get_started": "เริ่มต้น", - "youtube_source_description": "แนะนำและใช้งานได้ดีที่สุด", - "piped_source_description": "รู้สึกอิสระ? เหมือน YouTube แต่ฟรีกว่าเยอะ", - "jiosaavn_source_description": "ดีที่สุดสำหรับภูมิภาคเอเชียใต้", - "highest_quality": "คุณภาพสูงสุด: {quality}", - "select_audio_source": "เลือกแหล่งเสียง", - "endless_playback_description": "เพิ่มเพลงใหม่ลงในคิวโดยอัตโนมัติ", - "choose_your_region": "เลือกภูมิภาคของคุณ", - "choose_your_region_description": "สิ่งนี้จะช่วยให้ Spotube แสดงเนื้อหาที่เหมาะสมสำหรับคุณ", - "choose_ your_language": "เลือกภาษาของคุณ", - "help_project_grow": "ช่วยให้โครงการนี้เติบโต", - "help_project_grow_description": "Spotube เป็นโครงการโอเพนซอร์ส คุณสามารถช่วยให้โครงการนี้เติบโตได้โดยการมีส่วนร่วมในโครงการ รายงานข้อบกพร่อง หรือเสนอคุณสมบัติใหม่", - "contribute_on_github": "มีส่วนร่วมบน GitHub", - "donate_on_open_collective": "บริจาคบน Open Collective", - "browse_anonymously": "เรียกดูแบบไม่ระบุตัวตน", - "choose_your_language": "เลือกภาษาของคุณ", - "enable_connect": "เปิดใช้งานการเชื่อมต่อ", - "enable_connect_description": "ควบคุม Spotube จากอุปกรณ์อื่น", - "devices": "อุปกรณ์", - "select": "เลือก", - "connect_client_alert": "คุณกำลังถูกควบคุมโดย {client}", - "this_device": "อุปกรณ์นี้", - "remote": "ระยะไกล", - "local_library": "ห้องสมุดท้องถิ่น", - "add_library_location": "เพิ่มในห้องสมุด", - "remove_library_location": "ลบออกจากห้องสมุด", - "local_tab": "ท้องถิ่น", - "stats": "สถิติ", - "and_n_more": "และ {count} อีก", - "recently_played": "เพลงที่เพิ่งเล่น", - "browse_more": "ดูเพิ่มเติม", - "no_title": "ไม่มีชื่อ", - "not_playing": "ไม่เล่น", - "epic_failure": "ล้มเหลวอย่างยิ่ง!", - "added_num_tracks_to_queue": "เพิ่ม {tracks_length} เพลงในคิว", - "spotube_has_an_update": "Spotube มีการอัปเดต", - "download_now": "ดาวน์โหลดตอนนี้", - "nightly_version": "Spotube Nightly {nightlyBuildNum} ได้รับการปล่อยออกมา", - "release_version": "Spotube v{version} ได้รับการปล่อยออกมา", - "read_the_latest": "อ่านข่าวสารล่าสุด ", - "release_notes": "บันทึกการปล่อย", - "pick_color_scheme": "เลือกธีมสี", - "save": "บันทึก", - "choose_the_device": "เลือกอุปกรณ์:", - "multiple_device_connected": "มีอุปกรณ์เชื่อมต่อหลายเครื่อง\nเลือกอุปกรณ์ที่คุณต้องการให้การดำเนินการนี้เกิดขึ้น", - "nothing_found": "ไม่พบข้อมูล", - "the_box_is_empty": "กล่องว่างเปล่า", - "top_artists": "ศิลปินยอดนิยม", - "top_albums": "อัลบั้มยอดนิยม", - "this_week": "สัปดาห์นี้", - "this_month": "เดือนนี้", - "last_6_months": "6 เดือนที่ผ่านมา", - "this_year": "ปีนี้", - "last_2_years": "2 ปีที่ผ่านมา", - "all_time": "ตลอดกาล", - "powered_by_provider": "ขับเคลื่อนโดย {providerName}", - "email": "อีเมล", - "profile_followers": "ผู้ติดตาม", - "birthday": "วันเกิด", - "subscription": "การสมัครสมาชิก", - "not_born": "ยังไม่เกิด", - "hacker": "แฮ็กเกอร์", - "profile": "โปรไฟล์", - "no_name": "ไม่มีชื่อ", - "edit": "แก้ไข", - "user_profile": "โปรไฟล์ผู้ใช้", - "count_plays": "{count} การเล่น", - "streaming_fees_hypothetical": "*คำนวณจากการจ่ายเงินต่อการสตรีมของ Spotify\nระหว่าง $0.003 ถึง $0.005 นี่เป็นการคำนวณสมมุติ\nเพื่อให้ข้อมูลแก่ผู้ใช้เกี่ยวกับจำนวนเงินที่พวกเขา\nอาจจะจ่ายให้กับศิลปินหากพวกเขาฟังเพลงของพวกเขาใน Spotify", - "count_mins": "{minutes} นาที", - "summary_minutes": "นาที", - "summary_listened_to_music": "ฟังเพลง", - "summary_songs": "เพลง", - "summary_streamed_overall": "สตรีมทั้งหมด", - "summary_owed_to_artists": "ค้างชำระให้ศิลปิน\nในเดือนนี้", - "summary_artists": "ศิลปิน", - "summary_music_reached_you": "เพลงมาถึงคุณ", - "summary_full_albums": "อัลบั้มเต็ม", - "summary_got_your_love": "ได้รับความรักของคุณ", - "summary_playlists": "เพลย์ลิสต์", - "summary_were_on_repeat": "อยู่ในโหมดซ้ำ", - "total_money": "รวม {money}", - "minutes_listened": "เวลาที่ฟัง", - "streamed_songs": "เพลงที่สตรีม", - "count_streams": "{count} สตรีม", - "owned_by_you": "เป็นเจ้าของโดยคุณ", - "copied_shareurl_to_clipboard": "{shareUrl} คัดลอกไปที่คลิปบอร์ดแล้ว", - "spotify_hipotetical_calculation": "*คำนวณตามการจ่ายต่อสตรีมของ Spotify\nซึ่งอยู่ในช่วง $0.003 ถึง $0.005 นี่เป็นการคำนวณสมมุติ\nเพื่อให้ผู้ใช้ทราบว่าพวกเขาจะจ่ายเงินให้ศิลปินเท่าไหร่\nหากพวกเขาฟังเพลงของพวกเขาใน Spotify.", - "webview_not_found": "ไม่พบ Webview", - "webview_not_found_description": "ไม่พบ runtime ของ Webview บนอุปกรณ์ของคุณ\nหากติดตั้งแล้วตรวจสอบให้แน่ใจว่าอยู่ใน environment PATH\n\nหลังจากติดตั้งแล้ว ให้รีสตาร์ทแอป", - "unsupported_platform": "แพลตฟอร์มไม่รองรับ", - "invidious_instance": "อินสแตนซ์เซิร์ฟเวอร์ Invidious", - "invidious_description": "อินสแตนซ์เซิร์ฟเวอร์ Invidious ที่ใช้สำหรับการจับคู่เพลง", - "invidious_warning": "บางอันอาจใช้งานไม่ดี ใช้ด้วยความเสี่ยงของคุณเอง", - "invidious_source_description": "คล้ายกับ Piped แต่มีความพร้อมใช้งานสูงกว่า", - "cache_music": "แคชเพลง", - "open": "เปิด", - "cache_folder": "โฟลเดอร์แคช", - "export": "ส่งออก", - "clear_cache": "ล้างแคช", - "clear_cache_confirmation": "คุณต้องการล้างแคชหรือไม่?", - "export_cache_files": "ส่งออกไฟล์แคช", - "found_n_files": "พบ {count} ไฟล์", - "export_cache_confirmation": "คุณต้องการส่งออกไฟล์เหล่านี้ไปยัง", - "exported_n_out_of_m_files": "ส่งออก {filesExported} จาก {files} ไฟล์", - "playlist": "เพลย์ลิสต์", - "no_loop": "ไม่มีการวนซ้ำ", - "generate": "สร้าง", - "undo": "ย้อนกลับ", - "download_all": "ดาวน์โหลดทั้งหมด", - "add_all_to_playlist": "เพิ่มทั้งหมดในเพลย์ลิสต์", - "add_all_to_queue": "เพิ่มทั้งหมดในคิว", - "play_all_next": "เล่นทั้งหมดถัดไป", - "pause": "หยุดชั่วคราว", - "view_all": "ดูทั้งหมด", - "no_tracks_added_yet": "ดูเหมือนคุณยังไม่ได้เพิ่มเพลงใด ๆ", - "no_tracks": "ดูเหมือนจะไม่มีเพลงที่นี่", - "no_tracks_listened_yet": "ดูเหมือนคุณยังไม่ได้ฟังอะไรเลย", - "not_following_artists": "คุณไม่ได้ติดตามศิลปินใด ๆ", - "no_favorite_albums_yet": "ดูเหมือนคุณยังไม่ได้เพิ่มอัลบัมใด ๆ ในรายการโปรด", - "no_logs_found": "ไม่พบบันทึก", - "youtube_engine": "เครื่องมือ YouTube", - "youtube_engine_not_installed_title": "{engine} ยังไม่ได้ติดตั้ง", - "youtube_engine_not_installed_message": "{engine} ยังไม่ได้ติดตั้งในระบบของคุณ", - "youtube_engine_set_path": "ตรวจสอบให้แน่ใจว่ามันมีอยู่ในตัวแปร PATH หรือ\nตั้งค่าพาธที่แท้จริงของไฟล์ที่สามารถทำงานได้ {engine} ด้านล่าง", - "youtube_engine_unix_issue_message": "ใน macOS/Linux/Unix อย่าง OS การตั้งค่าพาธใน .zshrc/.bashrc/.bash_profile เป็นต้น จะไม่ทำงาน\nคุณต้องตั้งค่าพาธในไฟล์การกำหนดค่า shell", - "download": "ดาวน์โหลด", - "file_not_found": "ไม่พบไฟล์", - "custom": "กำหนดเอง", - "add_custom_url": "เพิ่ม URL แบบกำหนดเอง", - "edit_port": "แก้ไขพอร์ต", - "port_helper_msg": "ค่าเริ่มต้นคือ -1 ซึ่งหมายถึงหมายเลขสุ่ม หากคุณได้กำหนดค่าไฟร์วอลล์แล้ว แนะนำให้ตั้งค่านี้", - "connect_request": "อนุญาตให้ {client} เชื่อมต่อหรือไม่?", - "connection_request_denied": "การเชื่อมต่อล้มเหลว ผู้ใช้ปฏิเสธการเข้าถึง", - "hipotetical_calculation": "*การคำนวณนี้อิงจากค่าเฉลี่ยการจ่ายเงินต่อสตรีมของแพลตฟอร์มสตรีมมิ่งเพลงออนไลน์ที่ $0.003 ถึง $0.005 นี่เป็นการคำนวณสมมติฐานเพื่อให้ผู้ใช้เข้าใจว่าพวกเขาจะต้องจ่ายเงินให้ศิลปินเท่าไหร่หากพวกเขาฟังเพลงบนแพลตฟอร์มสตรีมมิ่งเพลงที่แตกต่างกัน", - "an_error_occurred": "เกิดข้อผิดพลาด", - "copy_to_clipboard": "คัดลอกไปยังคลิปบอร์ด", - "view_logs": "ดูบันทึก", - "retry": "ลองใหม่", - "no_default_metadata_provider_selected": "คุณไม่ได้ตั้งค่าผู้ให้บริการเมตาดาต้าเริ่มต้น", - "manage_metadata_providers": "จัดการผู้ให้บริการเมตาดาต้า", - "open_link_in_browser": "เปิดลิงก์ในเบราว์เซอร์หรือไม่?", - "do_you_want_to_open_the_following_link": "คุณต้องการเปิดลิงก์ต่อไปนี้หรือไม่", - "unsafe_url_warning": "การเปิดลิงก์จากแหล่งที่ไม่น่าเชื่อถืออาจไม่ปลอดภัย โปรดระมัดระวัง!\nคุณยังสามารถคัดลอกลิงก์ไปยังคลิปบอร์ดของคุณได้", - "copy_link": "คัดลอกลิงก์", - "building_your_timeline": "กำลังสร้างไทม์ไลน์ของคุณตามการฟังของคุณ...", - "official": "อย่างเป็นทางการ", - "author_name": "ผู้เขียน: {author}", - "third_party": "บุคคลที่สาม", - "plugin_requires_authentication": "ปลั๊กอินต้องมีการรับรองความถูกต้อง", - "update_available": "มีการอัปเดต", - "supports_scrobbling": "รองรับการ scrobbling", - "plugin_scrobbling_info": "ปลั๊กอินนี้จะ scrobble เพลงของคุณเพื่อสร้างประวัติการฟังของคุณ", - "default_plugin": "ค่าเริ่มต้น", - "set_default": "ตั้งค่าเริ่มต้น", - "support": "สนับสนุน", - "support_plugin_development": "สนับสนุนการพัฒนาปลั๊กอิน", - "can_access_name_api": "- สามารถเข้าถึง API **{name}**", - "do_you_want_to_install_this_plugin": "คุณต้องการติดตั้งปลั๊กอินนี้หรือไม่?", - "third_party_plugin_warning": "ปลั๊กอินนี้มาจากที่เก็บของบุคคลที่สาม โปรดตรวจสอบให้แน่ใจว่าคุณเชื่อถือแหล่งที่มาก่อนทำการติดตั้ง", - "author": "ผู้เขียน", - "this_plugin_can_do_following": "ปลั๊กอินนี้สามารถทำสิ่งต่อไปนี้", - "install": "ติดตั้ง", - "install_a_metadata_provider": "ติดตั้งผู้ให้บริการเมตาดาต้า", - "no_tracks_playing": "ขณะนี้ไม่มีเพลงที่กำลังเล่นอยู่", - "synced_lyrics_not_available": "ไม่มีเนื้อเพลงที่ซิงค์สำหรับเพลงนี้ กรุณาใช้แท็บ", - "plain_lyrics": "เนื้อเพลงธรรมดา", - "tab_instead": "แทน", - "disclaimer": "ข้อสงวนสิทธิ์", - "third_party_plugin_dmca_notice": "ทีม Spotube ไม่รับผิดชอบใดๆ (รวมถึงทางกฎหมาย) สำหรับปลั๊กอิน \"บุคคลที่สาม\" ใดๆ\nโปรดใช้งานด้วยความเสี่ยงของคุณเอง สำหรับข้อบกพร่อง/ปัญหาใดๆ โปรดรายงานไปยังที่เก็บปลั๊กอิน\n\nหากปลั๊กอิน \"บุคคลที่สาม\" ใดๆ ละเมิด ToS/DMCA ของบริการ/นิติบุคคลใดๆ โปรดขอให้ผู้เขียนปลั๊กอิน \"บุคคลที่สาม\" หรือแพลตฟอร์มโฮสติ้ง เช่น GitHub/Codeberg ดำเนินการ ที่ระบุไว้ข้างต้น (ที่ติดป้าย \"บุคคลที่สาม\") เป็นปลั๊กอินสาธารณะ/ที่ดูแลโดยชุมชนทั้งหมด เราไม่ได้จัดการดูแล ดังนั้นเราจึงไม่สามารถดำเนินการใดๆ กับพวกเขาได้\n\n", - "input_does_not_match_format": "อินพุตไม่ตรงกับรูปแบบที่ต้องการ", - "metadata_provider_plugins": "ปลั๊กอินผู้ให้บริการเมตาดาต้า", - "paste_plugin_download_url": "วาง url ดาวน์โหลดหรือ url ที่เก็บ GitHub/Codeberg หรือลิงก์โดยตรงไปยังไฟล์ .smplug", - "download_and_install_plugin_from_url": "ดาวน์โหลดและติดตั้งปลั๊กอินจาก url", - "failed_to_add_plugin_error": "ไม่สามารถเพิ่มปลั๊กอินได้: {error}", - "upload_plugin_from_file": "อัปโหลดปลั๊กอินจากไฟล์", - "installed": "ติดตั้งแล้ว", - "available_plugins": "ปลั๊กอินที่มีอยู่", - "configure_your_own_metadata_plugin": "กำหนดค่าผู้ให้บริการเมตาดาต้าเพลย์ลิสต์/อัลบั้ม/ศิลปิน/ฟีดของคุณเอง", - "audio_scrobblers": "เครื่อง scrobbler เสียง", - "scrobbling": "Scrobbling", - "download_music_format": "รูปแบบการดาวน์โหลดเพลง", - "streaming_music_format": "รูปแบบการสตรีมเพลง", - "download_music_quality": "คุณภาพการดาวน์โหลด", - "streaming_music_quality": "คุณภาพการสตรีม", - "default_metadata_source": "แหล่งเมตาดาต้าพื้นฐาน", - "set_default_metadata_source": "ตั้งค่าแหล่งเมตาดาต้าพื้นฐาน", - "default_audio_source": "แหล่งเสียงพื้นฐาน", - "set_default_audio_source": "ตั้งค่าแหล่งเสียงพื้นฐาน", - "plugins": "ปลั๊กอิน", - "configure_plugins": "กำหนดค่าปลั๊กอินผู้ให้บริการเมตาดาต้าและแหล่งเสียงของคุณเอง", - "source": "แหล่งที่มา: ", - "uncompressed": "ไม่บีบอัด", - "dab_music_source_description": "สำหรับคนรักเสียงเพลง ให้สตรีมเสียงคุณภาพสูง/ไร้การสูญเสียการบีบอัด การจับคู่แทร็กแม่นยำตาม ISRC" -} \ No newline at end of file diff --git a/lib/l10n/app_tl.arb b/lib/l10n/app_tl.arb deleted file mode 100644 index bf1f174c..00000000 --- a/lib/l10n/app_tl.arb +++ /dev/null @@ -1,492 +0,0 @@ -{ - "guest": "Bisita", - "browse": "Mag-browse", - "search": "Maghanap", - "library": "Silid-aklatan", - "lyrics": "Mga Liriko", - "settings": "Mga Setting", - "genre_categories_filter": "I-filter ang mga kategorya o genre...", - "genre": "Genre", - "personalized": "Naka-personalize", - "featured": "Tampok", - "new_releases": "Mga Bagong Paglabas", - "songs": "Mga Kanta", - "playing_track": "Tumutugtog ang {track}", - "queue_clear_alert": "Ito ay magbubura ng kasalukuyang pila. {track_length} na mga track ang tatanggalin\nGusto mo bang magpatuloy?", - "load_more": "Mag-load pa", - "playlists": "Mga Playlist", - "artists": "Mga Artista", - "albums": "Mga Album", - "tracks": "Mga Track", - "downloads": "Mga Download", - "filter_playlists": "I-filter ang iyong mga playlist...", - "liked_tracks": "Mga Nagustuhang Track", - "liked_tracks_description": "Lahat ng mga track na iyong nagustuhan", - "playlist": "Playlist", - "create_a_playlist": "Gumawa ng playlist", - "update_playlist": "I-update ang playlist", - "create": "Lumikha", - "cancel": "Ikansela", - "update": "I-update", - "playlist_name": "Pangalan ng Playlist", - "name_of_playlist": "Pangalan ng playlist", - "description": "Paglalarawan", - "public": "Pampubliko", - "collaborative": "Pakikipagtulungan", - "search_local_tracks": "Maghanap ng mga lokal na track...", - "play": "I-play", - "delete": "Burahin", - "none": "Wala", - "sort_a_z": "Ayusin ayon sa A-Z", - "sort_z_a": "Ayusin ayon sa Z-A", - "sort_artist": "Ayusin ayon sa Artista", - "sort_album": "Ayusin ayon sa Album", - "sort_duration": "Ayusin ayon sa Tagal", - "sort_tracks": "Ayusin ang mga Track", - "currently_downloading": "Kasalukuyang Nagda-download ({tracks_length})", - "cancel_all": "Kanselahin Lahat", - "filter_artist": "I-filter ang mga artista...", - "followers": "{followers} na mga Tagasunod", - "add_artist_to_blacklist": "Idagdag ang artista sa blacklist", - "top_tracks": "Mga Nangungunang Track", - "fans_also_like": "Gusto rin ng mga tagahanga", - "loading": "Naglo-load...", - "artist": "Artista", - "blacklisted": "Naka-blacklist", - "following": "Sinusundan", - "follow": "Sundan", - "artist_url_copied": "Na-copy sa clipboard ang URL ng artista", - "added_to_queue": "Idinagdag ang {tracks} na mga track sa pila", - "filter_albums": "I-filter ang mga album...", - "synced": "Naka-sync", - "plain": "Simpleng", - "shuffle": "I-shuffle", - "search_tracks": "Maghanap ng mga track...", - "released": "Inilabas", - "error": "Error {error}", - "title": "Pamagat", - "time": "Oras", - "more_actions": "Higit pang mga aksyon", - "download_count": "I-download ({count})", - "add_count_to_playlist": "Idagdag ({count}) sa Playlist", - "add_count_to_queue": "Idagdag ({count}) sa Pila", - "play_count_next": "I-play ({count}) kasunod", - "album": "Album", - "copied_to_clipboard": "Na-copy ang {data} sa clipboard", - "add_to_following_playlists": "Idagdag ang {track} sa mga sumusunod na Playlist", - "add": "Idagdag", - "added_track_to_queue": "Idinagdag ang {track} sa pila", - "add_to_queue": "Idagdag sa pila", - "track_will_play_next": "Ang {track} ay tutugtog susunod", - "play_next": "I-play susunod", - "removed_track_from_queue": "Tinanggal ang {track} mula sa pila", - "remove_from_queue": "Alisin mula sa pila", - "remove_from_favorites": "Alisin mula sa mga paborito", - "save_as_favorite": "I-save bilang paborito", - "add_to_playlist": "Idagdag sa playlist", - "remove_from_playlist": "Alisin mula sa playlist", - "add_to_blacklist": "Idagdag sa blacklist", - "remove_from_blacklist": "Alisin mula sa blacklist", - "share": "Ibahagi", - "mini_player": "Mini Player", - "slide_to_seek": "I-slide para mag-seek pasulong o pabalik", - "shuffle_playlist": "I-shuffle ang playlist", - "unshuffle_playlist": "I-unshuffle ang playlist", - "previous_track": "Nakaraang track", - "next_track": "Susunod na track", - "pause_playback": "I-pause ang Playback", - "resume_playback": "Ipagpatuloy ang Playback", - "loop_track": "I-loop ang track", - "no_loop": "Walang loop", - "repeat_playlist": "Ulitin ang playlist", - "queue": "Pila", - "alternative_track_sources": "Alternatibong mga pinagmulan ng track", - "download_track": "I-download ang track", - "tracks_in_queue": "{tracks} na mga track sa pila", - "clear_all": "Burahin lahat", - "show_hide_ui_on_hover": "Ipakita/Itago ang UI sa hover", - "always_on_top": "Palaging nasa ibabaw", - "exit_mini_player": "Lumabas sa Mini player", - "download_location": "Lokasyon ng pag-download", - "local_library": "Lokal na silid-aklatan", - "add_library_location": "Idagdag sa silid-aklatan", - "remove_library_location": "Alisin mula sa silid-aklatan", - "account": "Account", - "login_with_spotify": "Mag-login gamit ang iyong Spotify account", - "connect_with_spotify": "Kumonekta sa Spotify", - "logout": "Mag-logout", - "logout_of_this_account": "Mag-logout sa account na ito", - "language_region": "Wika at Rehiyon", - "language": "Wika", - "system_default": "Default ng Sistema", - "market_place_region": "Rehiyon ng Marketplace", - "recommendation_country": "Bansang Inirerekomenda", - "appearance": "Hitsura", - "layout_mode": "Mode ng Layout", - "override_layout_settings": "I-override ang mga setting ng responsive layout mode", - "adaptive": "Umaangkop", - "compact": "Kompakto", - "extended": "Pinalawig", - "theme": "Tema", - "dark": "Madilim", - "light": "Maliwanag", - "system": "Sistema", - "accent_color": "Kulay ng Accent", - "sync_album_color": "I-sync ang kulay ng album", - "sync_album_color_description": "Ginagamit ang pangunahing kulay ng album art bilang kulay ng accent", - "playback": "Playback", - "audio_quality": "Kalidad ng Audio", - "high": "Mataas", - "low": "Mababa", - "pre_download_play": "Mag-pre-download at i-play", - "pre_download_play_description": "Sa halip na mag-stream ng audio, mag-download ng bytes at i-play sa halip (Inirerekomenda para sa mga gumagamit ng mataas na bandwidth)", - "skip_non_music": "Laktawan ang mga segment na hindi musika (SponsorBlock)", - "blacklist_description": "Mga track at artista na nasa blacklist", - "wait_for_download_to_finish": "Mangyaring maghintay para matapos ang kasalukuyang pag-download", - "desktop": "Desktop", - "close_behavior": "Pag-uugali ng Pagsara", - "close": "Isara", - "minimize_to_tray": "I-minimize sa tray", - "show_tray_icon": "Ipakita ang icon ng System tray", - "about": "Tungkol sa", - "u_love_spotube": "Alam naming gusto mo ang Spotube", - "check_for_updates": "Maghanap ng mga update", - "about_spotube": "Tungkol sa Spotube", - "blacklist": "Blacklist", - "please_sponsor": "Mangyaring Mag-sponsor/Mag-donate", - "spotube_description": "Spotube, isang magaan, cross-platform, libreng-para-sa-lahat na spotify client", - "version": "Bersyon", - "build_number": "Build Number", - "founder": "Nagtatag", - "repository": "Repository", - "bug_issues": "Bug+Mga Isyu", - "made_with": "Ginawa nang may ❤️ sa Bangladesh🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Lisensya", - "add_spotify_credentials": "Idagdag ang iyong mga kredensyal sa spotify para makapagsimula", - "credentials_will_not_be_shared_disclaimer": "Huwag mag-alala, ang alinman sa iyong mga kredensyal ay hindi kokolektahin o ibabahagi sa sinuman", - "know_how_to_login": "Hindi mo alam kung paano gawin ito?", - "follow_step_by_step_guide": "Sundin ang Hakbang-hakbang na gabay", - "spotify_cookie": "Spotify {name} Cookie", - "cookie_name_cookie": "{name} Cookie", - "fill_in_all_fields": "Mangyaring punan ang lahat ng field", - "submit": "Isumite", - "exit": "Lumabas", - "previous": "Nakaraan", - "next": "Susunod", - "done": "Tapos na", - "step_1": "Hakbang 1", - "first_go_to": "Una, Pumunta sa", - "login_if_not_logged_in": "at Mag-login/Mag-signup kung hindi ka naka-log in", - "step_2": "Hakbang 2", - "step_2_steps": "1. Kapag naka-log in ka na, pindutin ang F12 o i-right click ang Mouse > Inspect para Buksan ang Browser devtools.\n2. Pagkatapos ay pumunta sa \"Application\" Tab (Chrome, Edge, Brave atbp..) o \"Storage\" Tab (Firefox, Palemoon atbp..)\n3. Pumunta sa \"Cookies\" na seksyon at pagkatapos sa \"https://accounts.spotify.com\" na subseksyon", - "step_3": "Hakbang 3", - "step_3_steps": "Kopyahin ang halaga ng \"sp_dc\" Cookie", - "success_emoji": "Tagumpay🥳", - "success_message": "Ngayon ay matagumpay kang Naka-log in gamit ang iyong Spotify account. Magaling, kaibigan!", - "step_4": "Hakbang 4", - "step_4_steps": "I-paste ang na-kopyang halaga ng \"sp_dc\"", - "something_went_wrong": "May nangyaring mali", - "piped_instance": "Instance ng Piped Server", - "piped_description": "Ang instance ng Piped server na gagamitin para sa pagtutugma ng track", - "piped_warning": "Maaaring hindi gumagana nang mabuti ang ilan sa mga ito. Kaya gamitin sa sarili mong peligro", - "invidious_instance": "Instance ng Invidious Server", - "invidious_description": "Ang instance ng Invidious server na gagamitin para sa pagtutugma ng track", - "invidious_warning": "Maaaring hindi gumagana nang mabuti ang ilan sa mga ito. Kaya gamitin sa sarili mong peligro", - "generate": "Gumawa", - "track_exists": "Ang Track na {track} ay umiiral na", - "replace_downloaded_tracks": "Palitan ang lahat ng na-download na mga track", - "skip_download_tracks": "Laktawan ang pag-download ng lahat ng na-download na mga track", - "do_you_want_to_replace": "Gusto mo bang palitan ang umiiral na track??", - "replace": "Palitan", - "skip": "Laktawan", - "select_up_to_count_type": "Pumili ng hanggang {count} {type}", - "select_genres": "Pumili ng mga Genre", - "add_genres": "Magdagdag ng mga Genre", - "country": "Bansa", - "number_of_tracks_generate": "Bilang ng mga track na gagawin", - "acousticness": "Acoustic-ness", - "danceability": "Kakayahang Sayawin", - "energy": "Enerhiya", - "instrumentalness": "Instrumental-ness", - "liveness": "Liveness", - "loudness": "Lakas", - "speechiness": "Pagsasalita", - "valence": "Valence", - "popularity": "Popularidad", - "key": "Key", - "duration": "Tagal (s)", - "tempo": "Tempo (BPM)", - "mode": "Mode", - "time_signature": "Time Signature", - "short": "Maikli", - "medium": "Katamtaman", - "long": "Mahaba", - "min": "Min", - "max": "Max", - "target": "Target", - "moderate": "Katamtaman", - "deselect_all": "Alisin ang Pagkakapili sa Lahat", - "select_all": "Piliin Lahat", - "are_you_sure": "Sigurado ka ba?", - "generating_playlist": "Gumagawa ng iyong custom na playlist...", - "selected_count_tracks": "Napili ang {count} na mga track", - "download_warning": "Kung nag-download ka ng lahat ng Track sa maramihan, malinaw na nagpa-pirate ka ng Musika at nagsasanhi ng pinsala sa creative society ng Musika. Sana ay alam mo ito. Palaging, subukang igalang at suportahan ang masipag na paggawa ng Artist", - "download_ip_ban_warning": "Sa nga pala, ang iyong IP ay maaaring ma-block sa YouTube dahil sa sobrang mga kahilingan sa pag-download kaysa sa karaniwan. Ang IP block ay nangangahulugang hindi mo magagamit ang YouTube (kahit na naka-log in ka) sa loob ng hindi bababa sa 2-3 buwan mula sa device na may IP na iyon. At hindi pinanghahawakan ng Spotube ang anumang responsibilidad kung mangyayari ito", - "by_clicking_accept_terms": "Sa pamamagitan ng pag-click sa 'tanggapin', sumasang-ayon ka sa mga sumusunod na tuntunin:", - "download_agreement_1": "Alam kong nagpa-pirate ako ng Musika. Masama ako", - "download_agreement_2": "Susuportahan ko ang Artist saan man ako maaari at ginagawa ko lang ito dahil wala akong pera para bumili ng kanilang sining", - "download_agreement_3": "Lubos kong nauunawaan na ang aking IP ay maaaring ma-block sa YouTube at hindi ko pinanghahawakan ang Spotube o ang kanyang mga may-ari/nag-ambag na responsable para sa anumang aksidente na sanhi ng aking kasalukuyang aksyon", - "decline": "Tanggihan", - "accept": "Tanggapin", - "details": "Mga Detalye", - "youtube": "YouTube", - "channel": "Channel", - "likes": "Mga Like", - "dislikes": "Mga Dislike", - "views": "Mga View", - "streamUrl": "Stream URL", - "stop": "Ihinto", - "sort_newest": "Ayusin ayon sa pinakabagong idinagdag", - "sort_oldest": "Ayusin ayon sa pinakalumang idinagdag", - "sleep_timer": "Sleep Timer", - "mins": "{minutes} Minuto", - "hours": "{hours} Oras", - "hour": "{hours} Oras", - "custom_hours": "Custom na Oras", - "logs": "Mga Log", - "developers": "Mga Developer", - "not_logged_in": "Hindi ka naka-log in", - "search_mode": "Mode ng Paghahanap", - "audio_source": "Pinagmulan ng Audio", - "ok": "Ok", - "failed_to_encrypt": "Nabigong i-encrypt", - "encryption_failed_warning": "Gumagamit ng encryption ang Spotube para ligtas na i-store ang iyong data. Ngunit nabigo. Kaya babalik ito sa hindi secure na storage\nKung gumagamit ka ng linux, mangyaring tiyakin na mayroon kang anumang secret-service na naka-install (gnome-keyring, kde-wallet, keepassxc atbp)", - "querying_info": "Kinukuha ang impormasyon...", - "piped_api_down": "Ang Piped API ay hindi gumagana", - "piped_down_error_instructions": "Ang instance ng Piped na {pipedInstance} ay kasalukuyang hindi gumagana\n\nMaaari mong baguhin ang instance o baguhin ang 'Uri ng API' sa opisyal na YouTube API\n\nSiguraduhing i-restart ang app pagkatapos ng pagbabago", - "you_are_offline": "Kasalukuyan kang offline", - "connection_restored": "Naibalik na ang iyong koneksyon sa internet", - "use_system_title_bar": "Gamitin ang title bar ng system", - "crunching_results": "Pinaproseso ang mga resulta...", - "search_to_get_results": "Maghanap para makakuha ng mga resulta", - "use_amoled_mode": "Matingkad na itim na madilim na tema", - "pitch_dark_theme": "AMOLED Mode", - "normalize_audio": "I-normalize ang audio", - "change_cover": "Baguhin ang cover", - "add_cover": "Magdagdag ng cover", - "restore_defaults": "Ibalik ang mga default", - "download_music_codec": "Codec para sa pag-download ng musika", - "streaming_music_codec": "Codec para sa pag-stream ng musika", - "login_with_lastfm": "Mag-login gamit ang Last.fm", - "connect": "Kumonekta", - "disconnect_lastfm": "Idiskonekta ang Last.fm", - "disconnect": "Idiskonekta", - "username": "Username", - "password": "Password", - "login": "Mag-login", - "login_with_your_lastfm": "Mag-login gamit ang iyong Last.fm account", - "scrobble_to_lastfm": "I-scrobble sa Last.fm", - "go_to_album": "Pumunta sa Album", - "discord_rich_presence": "Discord Rich Presence", - "browse_all": "I-browse Lahat", - "genres": "Mga Genre", - "explore_genres": "Tuklasin ang mga Genre", - "friends": "Mga Kaibigan", - "no_lyrics_available": "Paumanhin, hindi mahanap ang lyrics para sa track na ito", - "start_a_radio": "Magsimula ng Radio", - "how_to_start_radio": "Paano mo gustong simulan ang radio?", - "replace_queue_question": "Gusto mo bang palitan ang kasalukuyang pila o idagdag dito?", - "endless_playback": "Walang Hanggang Playback", - "delete_playlist": "Burahin ang Playlist", - "delete_playlist_confirmation": "Sigurado ka bang gusto mong burahin ang playlist na ito?", - "local_tracks": "Mga Lokal na Track", - "local_tab": "Lokal", - "song_link": "Link ng Kanta", - "skip_this_nonsense": "Laktawan ang kalokohan na ito", - "freedom_of_music": "\"Kalayaan ng Musika\"", - "freedom_of_music_palm": "\"Kalayaan ng Musika sa iyong palad\"", - "get_started": "Magsimula na tayo", - "youtube_source_description": "Inirerekomenda at pinakamahusay na gumagana.", - "piped_source_description": "Gusto ng kalayaan? Kapareho ng YouTube ngunit mas malaya.", - "jiosaavn_source_description": "Pinakamahusay para sa rehiyon ng South Asia.", - "invidious_source_description": "Katulad ng Piped ngunit may mas mataas na availability.", - "highest_quality": "Pinakamataas na Kalidad: {quality}", - "select_audio_source": "Pumili ng Pinagmulan ng Audio", - "endless_playback_description": "Awtomatikong magdagdag ng mga bagong kanta\nsa dulo ng pila", - "choose_your_region": "Piliin ang iyong rehiyon", - "choose_your_region_description": "Ito ay tutulong sa Spotube na ipakita sa iyo ang tamang content\npara sa iyong lokasyon.", - "choose_your_language": "Piliin ang iyong wika", - "help_project_grow": "Tulungan ang proyektong ito na lumago", - "help_project_grow_description": "Ang Spotube ay isang open-source na proyekto. Maaari mong tulungan ang proyektong ito na lumago sa pamamagitan ng pag-contribute sa proyekto, pag-ulat ng mga bug, o pagmungkahi ng mga bagong feature.", - "contribute_on_github": "Mag-contribute sa GitHub", - "donate_on_open_collective": "Mag-donate sa Open Collective", - "browse_anonymously": "Mag-browse nang Anonymous", - "enable_connect": "I-enable ang Connect", - "enable_connect_description": "Kontrolin ang Spotube mula sa ibang mga device", - "devices": "Mga Device", - "select": "Pumili", - "connect_client_alert": "Ikaw ay kontrolado ng {client}", - "this_device": "Ang Device na ito", - "remote": "Remote", - "stats": "Mga Stat", - "and_n_more": "at {count} pa", - "recently_played": "Kamakailan Lang na Ni-play", - "browse_more": "Mag-browse pa", - "no_title": "Walang Pamagat", - "not_playing": "Hindi tumutugtog", - "epic_failure": "Epic na pagkabigo!", - "added_num_tracks_to_queue": "Nagdagdag ng {tracks_length} na mga track sa pila", - "spotube_has_an_update": "Ang Spotube ay may update", - "download_now": "I-download Ngayon", - "nightly_version": "Ang Spotube Nightly {nightlyBuildNum} ay inilabas na", - "release_version": "Ang Spotube v{version} ay inilabas na", - "read_the_latest": "Basahin ang pinakabagong ", - "release_notes": "release notes", - "pick_color_scheme": "Pumili ng color scheme", - "save": "I-save", - "choose_the_device": "Piliin ang device:", - "multiple_device_connected": "Mayroong maraming device na nakakonekta.\nPiliin ang device kung saan mo gustong maganap ang aksyon na ito", - "nothing_found": "Walang nahanap", - "the_box_is_empty": "Ang kahon ay walang laman", - "top_artists": "Nangungunang mga Artista", - "top_albums": "Nangungunang mga Album", - "this_week": "Ngayong linggo", - "this_month": "Ngayong buwan", - "last_6_months": "Nakaraang 6 na buwan", - "this_year": "Ngayong taon", - "last_2_years": "Nakaraang 2 taon", - "all_time": "Lahat ng panahon", - "powered_by_provider": "Pinapagana ng {providerName}", - "email": "Email", - "profile_followers": "Mga Tagasunod", - "birthday": "Kaarawan", - "subscription": "Subscription", - "not_born": "Hindi pa ipinanganak", - "hacker": "Hacker", - "profile": "Profile", - "no_name": "Walang Pangalan", - "edit": "I-edit", - "user_profile": "Profile ng User", - "count_plays": "{count} na mga play", - "streaming_fees_hypothetical": "Mga bayarin sa streaming (hypothetical)", - "minutes_listened": "Mga minutong pinapakinggan", - "streamed_songs": "Mga na-stream na kanta", - "count_streams": "{count} na mga stream", - "owned_by_you": "Pag-aari mo", - "copied_shareurl_to_clipboard": "Na-kopya ang {shareUrl} sa clipboard", - "spotify_hipotetical_calculation": "*Ito ay kinalkula batay sa bawat stream\nna bayad ng Spotify na $0.003 hanggang $0.005. Ito ay isang hypothetical\nna pagkalkula para bigyan ang user ng ideya kung magkano\nang kanilang ibabayad sa mga artista kung sila ay nakikinig\nng kanilang kanta sa Spotify.", - "count_mins": "{minutes} minuto", - "summary_minutes": "minuto", - "summary_listened_to_music": "Nakinig sa musika", - "summary_songs": "mga kanta", - "summary_streamed_overall": "Na-stream sa kabuuan", - "summary_owed_to_artists": "Utang sa mga artista\nngayong buwan", - "summary_artists": "artista", - "summary_music_reached_you": "Umabot sa iyo ang musika", - "summary_full_albums": "buong album", - "summary_got_your_love": "Nakuha ang iyong pagmamahal", - "summary_playlists": "mga playlist", - "summary_were_on_repeat": "Pinu-playlst muli", - "total_money": "Kabuuang {money}", - "webview_not_found": "Hindi nahanap ang Webview", - "webview_not_found_description": "Walang webview runtime na naka-install sa iyong device.\nKung naka-install ito, siguraduhing nasa Environment PATH\n\nPagkatapos mag-install, i-restart ang app", - "unsupported_platform": "Hindi suportadong platform", - "cache_music": "I-cache ang musika", - "open": "Buksan", - "cache_folder": "Folder ng cache", - "export": "I-export", - "clear_cache": "Burahin ang cache", - "clear_cache_confirmation": "Gusto mo bang burahin ang cache?", - "export_cache_files": "I-export ang mga Naka-cache na File", - "found_n_files": "Nahanap ang {count} na mga file", - "export_cache_confirmation": "Gusto mo bang i-export ang mga file na ito sa", - "exported_n_out_of_m_files": "Na-export ang {filesExported} mula sa {files} na mga file", - "undo": "I-undo", - "download_all": "I-download lahat", - "add_all_to_playlist": "Idagdag lahat sa playlist", - "add_all_to_queue": "Idagdag lahat sa pila", - "play_all_next": "I-play lahat susunod", - "pause": "Pause", - "view_all": "Tingnan lahat", - "no_tracks_added_yet": "Mukhang wala ka pang idinaragdag na mga track", - "no_tracks": "Mukhang walang mga track dito", - "no_tracks_listened_yet": "Mukhang wala ka pang pinakikinggan", - "not_following_artists": "Hindi ka sumusunod sa anumang mga artista", - "no_favorite_albums_yet": "Mukhang wala ka pang idinagdag na anumang mga album sa iyong mga paborito", - "no_logs_found": "Walang nahanap na mga log", - "youtube_engine": "YouTube Engine", - "youtube_engine_not_installed_title": "Hindi naka-install ang {engine}", - "youtube_engine_not_installed_message": "Hindi naka-install ang {engine} sa iyong sistema.", - "youtube_engine_set_path": "Siguraduhing available ito sa PATH variable o\ni-set ang absolute path sa {engine} executable sa ibaba", - "youtube_engine_unix_issue_message": "Sa macOS/Linux/unix tulad ng OS, ang pag-set ng path sa .zshrc/.bashrc/.bash_profile atbp. ay hindi gagana.\nKailangan mong i-set ang path sa configuration file ng shell", - "download": "I-download", - "file_not_found": "Hindi nahanap ang file", - "custom": "Custom", - "add_custom_url": "Magdagdag ng custom URL", - "edit_port": "I-edit ang port", - "port_helper_msg": "Ang default ay -1 na nagpapahiwatig ng random na numero. Kung na-configure mo ang firewall, inirerekomenda na itakda ito.", - "connect_request": "Payagan ang {client} na kumonekta?", - "connection_request_denied": "Tanggihan ang koneksyon. Tinanggihan ng gumagamit ang pag-access.", - "hipotetical_calculation": "*Ito ay kinakalkula batay sa average na payout ng online music streaming platform na $0.003 hanggang $0.005 kada stream. Ito ay isang hypothetical na kalkulasyon upang bigyan ang user ng insight kung magkano ang babayaran nila sa mga artist kung sakaling makinig sila ng kanilang kanta sa iba't ibang music streaming platform.", - "an_error_occurred": "May naganap na error", - "copy_to_clipboard": "Kopyahin sa clipboard", - "view_logs": "Tingnan ang mga log", - "retry": "Subukang muli", - "no_default_metadata_provider_selected": "Wala kang nakatakdang default na metadata provider", - "manage_metadata_providers": "Pamahalaan ang mga metadata provider", - "open_link_in_browser": "Buksan ang Link sa Browser?", - "do_you_want_to_open_the_following_link": "Gusto mo bang buksan ang sumusunod na link", - "unsafe_url_warning": "Maaaring hindi ligtas ang pagbukas ng mga link mula sa hindi pinagkakatiwalaang pinagmulan. Mag-ingat!\nMaaari mo ring kopyahin ang link sa iyong clipboard.", - "copy_link": "Kopyahin ang Link", - "building_your_timeline": "Binubuo ang iyong timeline batay sa iyong mga pinakinggan...", - "official": "Opisyal", - "author_name": "May-akda: {author}", - "third_party": "Third-party", - "plugin_requires_authentication": "Nangangailangan ng authentication ang plugin", - "update_available": "May available na update", - "supports_scrobbling": "Sinusuportahan ang scrobbling", - "plugin_scrobbling_info": "Sinis-scrobble ng plugin na ito ang iyong musika upang mabuo ang iyong kasaysayan ng pakikinig.", - "default_plugin": "Default", - "set_default": "Itakda bilang default", - "support": "Suporta", - "support_plugin_development": "Suportahan ang pagbuo ng plugin", - "can_access_name_api": "- Maaaring i-access ang **{name}** API", - "do_you_want_to_install_this_plugin": "Gusto mo bang i-install ang plugin na ito?", - "third_party_plugin_warning": "Ang plugin na ito ay mula sa third-party na repository. Mangyaring tiyakin na pinagkakatiwalaan mo ang pinagmulan bago mag-install.", - "author": "May-akda", - "this_plugin_can_do_following": "Maaaring gawin ng plugin na ito ang sumusunod", - "install": "I-install", - "install_a_metadata_provider": "Mag-install ng Metadata Provider", - "no_tracks_playing": "Walang Track na kasalukuyang tumutugtog", - "synced_lyrics_not_available": "Hindi available ang mga naka-sync na lyrics para sa kantang ito. Mangyaring gamitin ang", - "plain_lyrics": "Simpleng Lyrics", - "tab_instead": "na tab sa halip.", - "disclaimer": "Disclaimer", - "third_party_plugin_dmca_notice": "Ang Spotube team ay walang hawak na anumang responsibilidad (kabilang ang legal) para sa anumang \"Third-party\" plugins.\nMangyaring gamitin ang mga ito sa iyong sariling peligro. Para sa anumang mga bug/isyu, mangyaring iulat ang mga ito sa repository ng plugin.\n\nKung ang anumang \"Third-party\" plugin ay lumalabag sa ToS/DMCA ng anumang serbisyo/legal na entity, mangyaring hilingin sa \"Third-party\" plugin author o sa hosting platform e.g. GitHub/Codeberg na gumawa ng aksyon. Ang nakalista sa itaas (\"Third-party\" na may label) ay lahat ng pampubliko/komunidad na pinananatiling mga plugin. Hindi namin sila kinukurusado, kaya hindi kami makakagawa ng anumang aksyon sa kanila.\n\n", - "input_does_not_match_format": "Ang input ay hindi tumutugma sa kinakailangang format", - "metadata_provider_plugins": "Mga Plugin ng Metadata Provider", - "paste_plugin_download_url": "I-paste ang download url o GitHub/Codeberg repo url o direktang link sa .smplug file", - "download_and_install_plugin_from_url": "I-download at i-install ang plugin mula sa url", - "failed_to_add_plugin_error": "Nabigo ang pagdagdag ng plugin: {error}", - "upload_plugin_from_file": "I-upload ang plugin mula sa file", - "installed": "Naka-install", - "available_plugins": "Mga available na plugin", - "configure_your_own_metadata_plugin": "I-configure ang iyong sariling playlist/album/artist/feed metadata provider", - "audio_scrobblers": "Mga Audio Scrobbler", - "scrobbling": "Scrobbling", - "download_music_format": "I-download na format ng musika", - "streaming_music_format": "Format ng streaming ng musika", - "download_music_quality": "Kalidad ng i-download na musika", - "streaming_music_quality": "Kalidad ng streaming ng musika", - "default_metadata_source": "Default na pinagmulan ng metadata", - "set_default_metadata_source": "Itakda ang default na pinagmulan ng metadata", - "default_audio_source": "Default na pinagmulan ng audio", - "set_default_audio_source": "Itakda ang default na pinagmulan ng audio", - "plugins": "Mga plugin", - "configure_plugins": "I-configure ang sarili mong metadata provider at mga audio source plugin", - "source": "Pinagmulan: ", - "uncompressed": "Hindi naka-compress", - "dab_music_source_description": "Para sa mga audiophile. Nagbibigay ng de-kalidad/walang loss na audio streams. Tumpak na pagtutugma ng track batay sa ISRC." -} \ No newline at end of file diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb deleted file mode 100644 index 72734d3b..00000000 --- a/lib/l10n/app_tr.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "Misafir", - "browse": "Göz at", - "search": "Ara", - "library": "Kütüphane", - "lyrics": "Şarkı sözleri", - "settings": "Ayarlar", - "genre_categories_filter": "Kategorileri veya türleri filtreleyin...", - "genre": "Tür", - "personalized": "Kişiselleştirilmiş", - "featured": "Öne çıkanlar", - "new_releases": "Yeni çıkanlar", - "songs": "Şarkılar", - "playing_track": "{track} oynatılıyor", - "queue_clear_alert": "Bu, mevcut kuyruğu temizleyecektir. {track_length} parça kaldırılacak\nDevam etmek istiyor musunuz?", - "load_more": "Daha fazlasını yükle", - "playlists": "Oynatma listeleri", - "artists": "Sanatçılar", - "albums": "Albümler", - "tracks": "Parçalar", - "downloads": "İndirilenler", - "filter_playlists": "Oynatma listelerinizi filtreleyin...", - "liked_tracks": "Beğenilen parçalar", - "liked_tracks_description": "Beğendiğiniz tüm parçalar", - "create_playlist": "Oynatma listesi oluştur", - "create_a_playlist": "Bir oynatma listesi oluştur", - "update_playlist": "Oynatma listesini güncelle", - "create": "Oluştur", - "cancel": "İptal", - "update": "Güncelle", - "playlist_name": "Oynatma listesi adı", - "name_of_playlist": "Oynatma listesinin adı", - "description": "Açıklama", - "public": "Halka açık", - "collaborative": "İşbirliği", - "search_local_tracks": "Yerel parçaları ara...", - "play": "Oynat", - "delete": "Sil", - "none": "Yok", - "sort_a_z": "A - Z'ye göre sırala", - "sort_z_a": "Z - A'ya göre sırala", - "sort_artist": "Sanatçıya göre sırala", - "sort_album": "Albüme göre sırala", - "sort_duration": "Süreye göre sırala", - "sort_tracks": "Parçaları sırala", - "currently_downloading": "Şu anda indirilenler ({tracks_length})", - "cancel_all": "Tümünü iptal et", - "filter_artist": "Sanatçıları filtreleyin...", - "followers": "{followers} Takipçiler", - "add_artist_to_blacklist": "Sanatçıyı kara listeye ekle", - "top_tracks": "En iyi parçalar", - "fans_also_like": "Hayranlar ayrıca şunları da beğendi", - "loading": "Yükleniyor...", - "artist": "Sanatçı", - "blacklisted": "Kara listeye alındı", - "following": "Takip ediliyor", - "follow": "Takip et", - "artist_url_copied": "Sanatçı bağlantısı panoya kopyalandı", - "added_to_queue": "Kuyruğa {tracks} parçası eklendi", - "filter_albums": "Albümleri filtreleyin...", - "synced": "Senkronize edildi", - "plain": "Sade", - "shuffle": "Karıştır", - "search_tracks": "Parça ara...", - "released": "Yayınlandı", - "error": "Hata {error}", - "title": "Başlık", - "time": "Zaman", - "more_actions": "Daha fazla eylem", - "download_count": "İndir ({count})", - "add_count_to_playlist": "Oynatma Listesine ekle ({count})", - "add_count_to_queue": "Kuyruğa ekle ({count})", - "play_count_next": "Sonrakini oynat ({count})", - "album": "Albüm", - "copied_to_clipboard": "{data} panoya kopyalandı", - "add_to_following_playlists": "{track} parçasını aşağıdaki oynatma listelerine ekle", - "add": "Ekle", - "added_track_to_queue": "{track} kuyruğa eklendi", - "add_to_queue": "Kuyruğa ekle", - "track_will_play_next": "{track} bir sonraki çalacak", - "play_next": "Sonrakini oynat", - "removed_track_from_queue": "{track} kuyruktan kaldırıldı", - "remove_from_queue": "Kuyruktan kaldır", - "remove_from_favorites": "Favorilerden kaldır", - "save_as_favorite": "Favori olarak kaydet", - "add_to_playlist": "Oynatma listesine ekle", - "remove_from_playlist": "Oynatma listesinden kaldır", - "add_to_blacklist": "Kara listeye ekle", - "remove_from_blacklist": "Kara listeden kaldır", - "share": "Paylaş", - "mini_player": "Mini oynatıcı", - "slide_to_seek": "İleri veya geri arama yapmak için kaydırın", - "shuffle_playlist": "Oynatma listesini karıştır", - "unshuffle_playlist": "Oynatma listesinin karışıklığını kaldır", - "previous_track": "Önceki parça", - "next_track": "Sonraki parça", - "pause_playback": "Oynatmayı duraklat", - "resume_playback": "Oynatmayı sürdür", - "loop_track": "Döngü parçası", - "repeat_playlist": "Oynatma listesini tekrarla", - "queue": "Kuyruk", - "alternative_track_sources": "Alternatif parça kaynakları", - "download_track": "Parçayı indir", - "tracks_in_queue": "{tracks} parça kuyrukta", - "clear_all": "Tümünü temizle", - "show_hide_ui_on_hover": "Fareyle üzerine gelindiğinde kullanıcı arayüzünü göster/gizle", - "always_on_top": "Her zaman üstte", - "exit_mini_player": "Mini oynatıcıdan çık", - "download_location": "İndirme konumu", - "account": "Hesap", - "login_with_spotify": "Spotify hesabı ile giriş yap", - "connect_with_spotify": "Spotify ile bağlan", - "logout": "Çıkış yap", - "logout_of_this_account": "Hesaptan çıkış yap", - "language_region": "Dil ve bölge", - "language": "Tercih edilen dil", - "system_default": "Sistem varsayılanı", - "market_place_region": "Tercih edilen bölge", - "recommendation_country": "Tavsiye edilen ülke", - "appearance": "Görünüm", - "layout_mode": "Düzen modu", - "override_layout_settings": "Duyarlı düzen modu ayarlarını geçersiz kıl", - "adaptive": "Uyarlanabilir", - "compact": "Sıkıştırılmış", - "extended": "Genişletilmiş", - "theme": "Tema", - "dark": "Koyu", - "light": "Açık", - "system": "Sistem", - "accent_color": "Vurgu rengi", - "sync_album_color": "Albüm rengini senkronize et", - "sync_album_color_description": "Vurgu rengi olarak albüm resminin baskın rengini kullanır", - "playback": "Oynatma", - "audio_quality": "Ses kalitesi", - "high": "Yüksek", - "low": "Düşük", - "pre_download_play": "Önceden indir ve oynat", - "pre_download_play_description": "Ses akışı yerine baytları indir ve oynat (Daha yüksek bant genişliğine sahip kullanıcılar için önerilir)", - "skip_non_music": "Müzik olmayan bölümleri atlat (SponsorBlock)", - "blacklist_description": "Kara listeye alınan parçalar ve sanatçılar", - "wait_for_download_to_finish": "Lütfen mevcut indirme işleminin tamamlanmasını bekleyin", - "desktop": "Masaüstü", - "close_behavior": "Kapatma davranışı", - "close": "Kapat", - "minimize_to_tray": "Tepsiye küçült", - "show_tray_icon": "Sistem tepsisi simgesini göster", - "about": "Hakkında", - "u_love_spotube": "Spotube'u sevdiğinizi biliyoruz", - "check_for_updates": "Güncellemeleri kontrol et", - "about_spotube": "Spotube hakkında", - "blacklist": "Kara liste", - "please_sponsor": "Sponsor Ol/Bağış Yap", - "spotube_description": "Spotube, hafif, platformlar arası uyumlu ve herkes için ücretsiz bir Spotify istemcisidir.", - "version": "Sürüm", - "build_number": "Derleme numarası", - "founder": "Geliştirici", - "repository": "Depo", - "bug_issues": "Hata + Sorunlar", - "made_with": "❤️ ile Bangladeş'te yapıldı", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Lisans", - "add_spotify_credentials": "Başlamak için spotify kimlik bilgilerinizi ekleyin", - "credentials_will_not_be_shared_disclaimer": "Endişelenmeyin, kimlik bilgilerinizden hiçbiri toplanmayacak veya kimseyle paylaşılmayacak", - "know_how_to_login": "Bunu nasıl yapacağınızı bilmiyor musunuz?", - "follow_step_by_step_guide": "Adım adım kılavuzu takip edin", - "spotify_cookie": "Spotify {name} çerezi", - "cookie_name_cookie": "{name} çerezi", - "fill_in_all_fields": "Lütfen tüm alanları doldurun", - "submit": "Başvur", - "exit": "Çık", - "previous": "Önceki", - "next": "Sonraki", - "done": "Bitti", - "step_1": "1. Adım", - "first_go_to": "İlk olarak şuraya gidin:", - "login_if_not_logged_in": "ve oturum açmadıysanız Oturum açın/Kaydolun", - "step_2": "2. Adım", - "step_2_steps": "1. Oturum açtıktan sonra, tarayıcı geliştirme araçlarını açmak için F12'ye veya fareye sağ tıklayın > İncele'ye basın.\n2. Daha sonra \"Uygulama\" sekmesine (Chrome, Edge, Brave vb..) veya \"Depolama\" sekmesine (Firefox, Palemoon vb..) gidin\n3. \"Çerezler\" bölümüne, ardından \"https://accounts.spotify.com\" alt bölümüne gidin", - "step_3": "3. Adım", - "step_3_steps": "\"sp_dc\" Çerezinin değerini kopyalayın", - "success_emoji": "Başarılı🥳", - "success_message": "Artık Spotify hesabınızla başarıyla giriş yaptınız. Tebrik ederim!", - "step_4": "4. Adım", - "step_4_steps": "Kopyalanan \"sp_dc\" değerini yapıştırın", - "something_went_wrong": "Bir hata oluştu", - "piped_instance": "Piped sunucu örneği", - "piped_description": "Parça eşleştirme için kullanılacak Piped sunucu örneği", - "piped_warning": "Bazıları iyi çalışmayabilir. Yani riski size ait olmak üzere kullanın", - "generate_playlist": "Oynatma listesi oluştur", - "track_exists": "{track} parçası zaten var", - "replace_downloaded_tracks": "İndirilen tüm parçaları değiştir", - "skip_download_tracks": "İndirilen tüm parçaları indirmeyi atla", - "do_you_want_to_replace": "Mevcut parçayı değiştirmek istiyor musunuz?", - "replace": "Değiştir", - "skip": "Atla", - "select_up_to_count_type": "En fazla {count} {type} seçin", - "select_genres": "Türleri seç", - "add_genres": "Tür ekle", - "country": "Ülke", - "number_of_tracks_generate": "Oluşturulacak parça sayısı", - "acousticness": "Akustiklik", - "danceability": "Dans Edilebilirlik", - "energy": "Enerji", - "instrumentalness": "Araçsallık", - "liveness": "Canlılık", - "loudness": "Ses yüksekliği", - "speechiness": "Konuşkanlık", - "valence": "Değerlik", - "popularity": "Popülerlik", - "key": "Anahtar", - "duration": "Süre (sn)", - "tempo": "Tempo (BPM)", - "mode": "Mod", - "time_signature": "Zaman imzası", - "short": "Kısa", - "medium": "Orta", - "long": "Uzun", - "min": "Min", - "max": "Maks", - "target": "Hedef", - "moderate": "Orta", - "deselect_all": "Tüm seçimleri kaldır", - "select_all": "Tümünü seç", - "are_you_sure": "Emin misiniz?", - "generating_playlist": "Özel oynatma listeniz oluşturuluyor...", - "selected_count_tracks": "{count} parça seçildi", - "download_warning": "Tüm şarkıları toplu olarak indiriyorsanız, açıkça müzik korsanlığı yapıyorsunuz ve müzik dünyasının yaratıcı topluluğuna zarar veriyorsunuz demektir. Umuyorum bunun farkındasınızdır. Her zaman, sanatçıların emeğine saygı göstermeyi ve desteklemeyi deneyin.", - "download_ip_ban_warning": "Ayrıca, normalden fazla indirme istekleri nedeniyle YouTube'da IP'niz engellenebilir. IP engeli, en az 2-3 ay boyunca YouTube'u (hatta oturum açmış olsanız bile) o IP cihazından kullanamayacağınız anlamına gelir. Ve eğer böyle bir durum yaşanırsa, Spotube bundan hiçbir sorumluluk kabul etmez.", - "by_clicking_accept_terms": "\"Kabul et\" e tıklayarak aşağıdaki şartları kabul etmiş olursunuz:", - "download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben fakir biriyim.", - "download_agreement_2": "Sanatçıyı elimden geldiğince destekleyeceğim ve bunu sadece sanatını satın alacak param olmadığı için yapıyorum", - "download_agreement_3": "YouTube'da IP'min engellenebileceğinin tamamen farkındayım ve mevcut eylemlerimden kaynaklanan herhangi bir kaza için Spotube'u veya sahiplerini/katkıda bulunanları sorumlu tutmuyorum.", - "decline": "Reddet", - "accept": "Kabul et", - "details": "Detaylar", - "youtube": "YouTube", - "channel": "Kanal", - "likes": "Beğenenler", - "dislikes": "Beğenmeyenler", - "views": "İzlenmeler", - "streamUrl": "Akış bağlantısı", - "stop": "Durdur", - "sort_newest": "En yeni eklenene göre sırala.", - "sort_oldest": "En eski eklenene göre sırala", - "sleep_timer": "Uyku Zamanlayıcısı", - "mins": "{minutes} Dakika", - "hours": "{hours} Saatler", - "hour": "{hours} Saat", - "custom_hours": "Özel Saatler", - "logs": "Günlükler", - "developers": "Geliştiriciler", - "not_logged_in": "Giriş yapmadınız", - "search_mode": "Arama modu", - "audio_source": "Ses kaynağı", - "ok": "Tamam", - "failed_to_encrypt": "Şifreleme başarısız oldu", - "encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde depolamak için şifreleme kullanır. Ancak bunu başaramadı. Bu nedenle, güvensiz depolamaya geri dönecektir\nLinux kullanıyorsanız, lütfen gnome-keyring, kde-wallet, keepassxc vb. herhangi bir gizli servisin yüklü olduğundan emin olun.", - "querying_info": "Bilgi sorgulanıyor...", - "piped_api_down": "Piped API kapalı", - "piped_down_error_instructions": "Piped örneği {pipedInstance} şu anda kapalı\n\nÖrneği değiştirin veya 'API türünü' resmi YouTube API'si olarak değiştirin\n\nDeğişiklikten sonra uygulamayı yeniden başlattığınızdan emin olun", - "you_are_offline": "Şu anda çevrimdışısınız", - "connection_restored": "İnternet bağlantınız geri yüklendi", - "use_system_title_bar": "Sistem başlık çubuğunu kullan", - "crunching_results": "Sonuçlar...", - "search_to_get_results": "Sonuç almak için arayın", - "use_amoled_mode": "AMOLED modu kullan", - "pitch_dark_theme": "Zifiri karanlık koyu tema", - "normalize_audio": "Sesi normalleştir", - "change_cover": "Kapağı değiştir", - "add_cover": "Kapak ekle", - "restore_defaults": "Varsayılanları geri yükle", - "download_music_codec": "Müzik codec bileşenini indir", - "streaming_music_codec": "Müzik codec'i akışı", - "login_with_lastfm": "Last.fm ile giriş yap", - "connect": "Bağlan", - "disconnect_lastfm": "Last.fm bağlantısını kes", - "disconnect": "Bağlantıyı kes", - "username": "Kullanıcı adı", - "password": "Şifre", - "login": "Giriş yap", - "login_with_your_lastfm": "Last.fm hesabınızla giriş yapın", - "scrobble_to_lastfm": "Last.fm için Scrobble", - "go_to_album": "Albüme git", - "discord_rich_presence": "Discord zengin varlığı", - "browse_all": "Tümüne göz at", - "genres": "Müzik türleri", - "explore_genres": "Türleri keşfet", - "friends": "Arkadaşlar", - "no_lyrics_available": "Üzgünüz, bu parçanın sözleri bulunamıyor", - "start_a_radio": "Radyo başlat", - "how_to_start_radio": "Radyoyu nasıl başlatmak istersiniz?", - "replace_queue_question": "Mevcut kuyruğu değiştirmek mi yoksa eklemek mi istersiniz?", - "endless_playback": "Sonsuz olarak oynat", - "delete_playlist": "Oynatma listesini sil", - "delete_playlist_confirmation": "Bu oynatma listesini silmek istediğinizden emin misiniz?", - "local_tracks": "Yerel parçalar", - "song_link": "Şarkı bağlantısı", - "skip_this_nonsense": "Bu saçmalığı atla", - "freedom_of_music": "“Müzik özgürlüğü”", - "freedom_of_music_palm": "“Müzik özgürlüğü avucunuzun içinde”", - "get_started": "Haydi başlayalım", - "youtube_source_description": "Tavsiye edilir ve en iyi şekilde çalışır.", - "piped_source_description": "Özgür hissediyor musunuz? YouTube ile aynı, ama çok daha özgür.", - "jiosaavn_source_description": "Güney Asya bölgesi için en iyisi.", - "highest_quality": "En yüksek kalite: {quality}", - "select_audio_source": "Ses kaynağını seçin", - "endless_playback_description": "Yeni şarkıları otomatik olarak\nkuyruğun sonuna ekle", - "choose_your_region": "Bölgenizi seçin", - "choose_your_region_description": "Bu, Spotube'un konumunuza uygun içerikleri göstermesine yardımcı olacaktır.", - "choose_your_language": "Dilinizi seçin", - "help_project_grow": "Bu projenin büyümesine yardımcı olun", - "help_project_grow_description": "Spotube açık kaynaklı bir projedir. Projeye katkıda bulunarak, hataları bildirerek veya yeni özellikler önererek bu projenin büyümesine yardımcı olabilirsiniz.", - "contribute_on_github": "GitHub'da katkıda bulun", - "donate_on_open_collective": "Open Collective'de bağış yap", - "browse_anonymously": "Anonim olarak giriş yap", - "enable_connect": "Bağlanmayı etkinleştir", - "enable_connect_description": "Spotube'u diğer cihazlardan kontrol edin", - "devices": "Cihazlar", - "select": "Seç", - "connect_client_alert": "{client} tarafından kontrol ediliyorsun.", - "this_device": "Bu cihaz", - "remote": "Yönet", - "local_library": "Yerel kütüphane", - "add_library_location": "Kütüphaneye ekle", - "remove_library_location": "Kütüphaneden çıkar", - "local_tab": "Yerel", - "stats": "İstatistikler", - "and_n_more": "ve {count} daha", - "recently_played": "Son Çalınanlar", - "browse_more": "Daha Fazla Göz At", - "no_title": "Başlık Yok", - "not_playing": "Çalmıyor", - "epic_failure": "Efsanevi başarısızlık!", - "added_num_tracks_to_queue": "{tracks_length} şarkı sıraya eklendi", - "spotube_has_an_update": "Spotube bir güncelleme aldı", - "download_now": "Şimdi İndir", - "nightly_version": "Spotube Nightly {nightlyBuildNum} yayımlandı", - "release_version": "Spotube v{version} yayımlandı", - "read_the_latest": "Son haberleri oku", - "release_notes": "sürüm notları", - "pick_color_scheme": "Renk şeması seç", - "save": "Kaydet", - "choose_the_device": "Cihazı seçin:", - "multiple_device_connected": "Birden fazla cihaz bağlı.\nBu işlemi gerçekleştirmek istediğiniz cihazı seçin", - "nothing_found": "Hiçbir şey bulunamadı", - "the_box_is_empty": "Kutu boş", - "top_artists": "En İyi Sanatçılar", - "top_albums": "En İyi Albümler", - "this_week": "Bu hafta", - "this_month": "Bu ay", - "last_6_months": "Son 6 ay", - "this_year": "Bu yıl", - "last_2_years": "Son 2 yıl", - "all_time": "Tüm zamanlar", - "powered_by_provider": "{providerName} tarafından desteklenmektedir", - "email": "E-posta", - "profile_followers": "Takipçiler", - "birthday": "Doğum Günü", - "subscription": "Abonelik", - "not_born": "Henüz doğmadı", - "hacker": "Hacker", - "profile": "Profil", - "no_name": "İsim Yok", - "edit": "Düzenle", - "user_profile": "Kullanıcı Profili", - "count_plays": "{count} çalma", - "streaming_fees_hypothetical": "*Spotify'ın akış başına ödeme miktarına\n$0.003 ile $0.005 arasında hesaplanmıştır. Bu, kullanıcıya\nSpotify'da şarkılarını dinlerse sanatçılara ne kadar ödeme\nyapmış olabileceğini göstermek için hipotetik bir hesaplamadır.", - "count_mins": "{minutes} dk", - "summary_minutes": "dakika", - "summary_listened_to_music": "Dinlenen müzik", - "summary_songs": "şarkılar", - "summary_streamed_overall": "Genel olarak akış", - "summary_owed_to_artists": "Sanatçılara borç\nbu ay", - "summary_artists": "sanatçının", - "summary_music_reached_you": "Müzik sana ulaştı", - "summary_full_albums": "tam albümler", - "summary_got_your_love": "Sevgini aldı", - "summary_playlists": "çalma listeleri", - "summary_were_on_repeat": "Tekrarda vardı", - "total_money": "Toplam {money}", - "minutes_listened": "Dinlenilen Dakikalar", - "streamed_songs": "Yayınlanan Şarkılar", - "count_streams": "{count} yayın", - "owned_by_you": "Sahip olduğunuz", - "copied_shareurl_to_clipboard": "{shareUrl} panoya kopyalandı", - "spotify_hipotetical_calculation": "*Bu, Spotify'ın her yayın başına ödemenin\n$0.003 ile $0.005 arasında olduğu varsayımıyla hesaplanmıştır. Bu\nhipotetik bir hesaplamadır, kullanıcıya şarkılarını Spotify'da dinlediklerinde\nsanatçılara ne kadar ödeme yapacaklarını gösterir.", - "webview_not_found": "Webview bulunamadı", - "webview_not_found_description": "Cihazınızda herhangi bir Webview çalışma zamanı yüklü değil.\nEğer kuruluysa, ortam YOLUNDA olduğundan emin olun\n\nKurulumdan sonra uygulamayı yeniden başlatın", - "unsupported_platform": "Desteklenmeyen platform", - "invidious_instance": "Invidious Sunucu Örneği", - "invidious_description": "Parça eşleştirmesi için kullanılacak Invidious sunucu örneği", - "invidious_warning": "Bazıları iyi çalışmayabilir. Kendi riskinizde kullanın", - "invidious_source_description": "Piped'a benzer, ancak daha yüksek kullanılabilirliğe sahip.", - "cache_music": "Müziği önbellekle", - "open": "Aç", - "cache_folder": "Önbellek klasörü", - "export": "Dışa aktar", - "clear_cache": "Önbelleği temizle", - "clear_cache_confirmation": "Önbelleği temizlemek istiyor musunuz?", - "export_cache_files": "Önbelleğe Alınmış Dosyaları Dışa Aktar", - "found_n_files": "{count} dosya bulundu", - "export_cache_confirmation": "Bu dosyaları dışa aktarmak istiyor musunuz", - "exported_n_out_of_m_files": "{filesExported} / {files} dosya dışa aktarıldı", - "playlist": "Çalma Listesi", - "no_loop": "Dönüş Yok", - "generate": "Oluştur", - "undo": "Geri Al", - "download_all": "Tümünü İndir", - "add_all_to_playlist": "Hepsini çalma listesine ekle", - "add_all_to_queue": "Hepsini kuyruğa ekle", - "play_all_next": "Hepsini bir sonraki çal", - "pause": "Duraklat", - "view_all": "Tümünü Gör", - "no_tracks_added_yet": "Henüz hiçbir şarkı eklemediniz gibi görünüyor", - "no_tracks": "Burada hiç şarkı yok gibi görünüyor", - "no_tracks_listened_yet": "Henüz hiçbir şey dinlemediniz gibi görünüyor", - "not_following_artists": "Hiçbir sanatçıyı takip etmiyorsunuz", - "no_favorite_albums_yet": "Henüz favorilerinize herhangi bir albüm eklemediniz gibi görünüyor", - "no_logs_found": "Log bulunamadı", - "youtube_engine": "YouTube Motoru", - "youtube_engine_not_installed_title": "{engine} Yüklü değil", - "youtube_engine_not_installed_message": "{engine} sisteminizde yüklü değil.", - "youtube_engine_set_path": "PATH değişkeninde kullanılabilir olduğundan emin olun veya\n{engine} çalıştırılabilir dosyasının mutlak yolunu aşağıda ayarlayın", - "youtube_engine_unix_issue_message": "macOS/Linux/Unix benzeri işletim sistemlerinde, .zshrc/.bashrc/.bash_profile gibi dosyalarda yol ayarlamak işe yaramaz.\nYolunuzu kabuk yapılandırma dosyasına ayarlamanız gerekir", - "download": "İndir", - "file_not_found": "Dosya bulunamadı", - "custom": "Özel", - "add_custom_url": "Özel URL ekle", - "edit_port": "Portu düzenle", - "port_helper_msg": "Varsayılan -1'dir, bu da rastgele bir sayıyı gösterir. Bir güvenlik duvarınız varsa, bunu ayarlamanız önerilir.", - "connect_request": "{client} bağlantısına izin verilsin mi?", - "connection_request_denied": "Bağlantı reddedildi. Kullanıcı erişimi reddetti.", - "hipotetical_calculation": "*Bu, çevrimiçi müzik akışı platformlarının ortalama akış başına $0,003 ile $0,005 arasındaki ödemesine göre hesaplanmıştır. Bu, kullanıcının farklı müzik akışı platformlarında şarkılarını dinleselerdi sanatçılara ne kadar ödeme yapacaklarına dair fikir vermek için yapılan varsayımsal bir hesaplamadır.", - "an_error_occurred": "Bir hata oluştu", - "copy_to_clipboard": "Panoya kopyala", - "view_logs": "Günlükleri görüntüle", - "retry": "Tekrar dene", - "no_default_metadata_provider_selected": "Varsayılan bir meta veri sağlayıcısı ayarlanmadı", - "manage_metadata_providers": "Meta veri sağlayıcılarını yönet", - "open_link_in_browser": "Bağlantıyı Tarayıcıda Aç?", - "do_you_want_to_open_the_following_link": "Aşağıdaki bağlantıyı açmak istiyor musunuz", - "unsafe_url_warning": "Güvenilmeyen kaynaklardan bağlantı açmak güvensiz olabilir. Dikkatli olun!\nBağlantıyı panonuza da kopyalayabilirsiniz.", - "copy_link": "Bağlantıyı Kopyala", - "building_your_timeline": "Dinlemelerinize göre zaman çizelgeniz oluşturuluyor...", - "official": "Resmi", - "author_name": "Yazar: {author}", - "third_party": "Üçüncü taraf", - "plugin_requires_authentication": "Eklenti kimlik doğrulama gerektirir", - "update_available": "Güncelleme mevcut", - "supports_scrobbling": "Scrobbling'i destekler", - "plugin_scrobbling_info": "Bu eklenti, dinleme geçmişinizi oluşturmak için müziğinizi scrobble eder.", - "default_plugin": "Varsayılan", - "set_default": "Varsayılan olarak ayarla", - "support": "Destek", - "support_plugin_development": "Eklenti geliştirmeyi destekle", - "can_access_name_api": "- **{name}** API'ye erişebilir", - "do_you_want_to_install_this_plugin": "Bu eklentiyi yüklemek istiyor musunuz?", - "third_party_plugin_warning": "Bu eklenti üçüncü taraf bir depodan gelmektedir. Lütfen yüklemeden önce kaynağa güvendiğinizden emin olun.", - "author": "Yazar", - "this_plugin_can_do_following": "Bu eklenti aşağıdakileri yapabilir", - "install": "Yükle", - "install_a_metadata_provider": "Bir Meta Veri Sağlayıcısı Yükle", - "no_tracks_playing": "Şu anda çalınan bir Parça yok", - "synced_lyrics_not_available": "Bu şarkı için senkronize şarkı sözleri mevcut değil. Lütfen", - "plain_lyrics": "Düz Şarkı Sözleri", - "tab_instead": "sekmesini kullanın.", - "disclaimer": "Sorumluluk Reddi", - "third_party_plugin_dmca_notice": "Spotube ekibi, herhangi bir \"Üçüncü taraf\" eklentisi için herhangi bir sorumluluk (yasal olanlar dahil) kabul etmez.\nLütfen bunları kendi riskinizde kullanın. Herhangi bir hata/sorun için lütfen bunları eklenti deposuna bildirin.\n\nHerhangi bir \"Üçüncü taraf\" eklentisi bir hizmetin/yasal varlığın ToS/DMCA'sını ihlal ediyorsa, lütfen \"Üçüncü taraf\" eklenti yazarından veya barındırma platformundan, örneğin GitHub/Codeberg'den harekete geçmesini isteyin. Yukarıda listelenen (\"Üçüncü taraf\" olarak etiketlenen) eklentilerin tümü genel/topluluk tarafından sürdürülen eklentilerdir. Biz bunları küratörlüğünü yapmıyoruz, bu yüzden onlar üzerinde herhangi bir işlem yapamayız.\n\n", - "input_does_not_match_format": "Girdi, gerekli biçimle eşleşmiyor", - "metadata_provider_plugins": "Meta Veri Sağlayıcısı Eklentileri", - "paste_plugin_download_url": "İndirme url'sini veya GitHub/Codeberg repo url'sini veya .smplug dosyasına doğrudan bağlantıyı yapıştırın", - "download_and_install_plugin_from_url": "url'den eklentiyi indir ve yükle", - "failed_to_add_plugin_error": "Eklenti eklenemedi: {error}", - "upload_plugin_from_file": "Dosyadan eklenti yükle", - "installed": "Yüklü", - "available_plugins": "Mevcut eklentiler", - "configure_your_own_metadata_plugin": "Kendi çalma listenizi/albümünüzü/sanatçınızı/akış meta veri sağlayıcınızı yapılandırın", - "audio_scrobblers": "Ses Scrobbler'lar", - "scrobbling": "Scrobbling", - "download_music_format": "Müzik indirme formatı", - "streaming_music_format": "Müzik akış formatı", - "download_music_quality": "İndirilen müzik kalitesi", - "streaming_music_quality": "Yayınlanan müzik kalitesi", - "default_metadata_source": "Varsayılan meta veri kaynağı", - "set_default_metadata_source": "Varsayılan meta veri kaynağını ayarla", - "default_audio_source": "Varsayılan ses kaynağı", - "set_default_audio_source": "Varsayılan ses kaynağını ayarla", - "plugins": "Eklentiler", - "configure_plugins": "Kendi meta veri sağlayıcı ve ses kaynağı eklentilerinizi yapılandırın", - "source": "Kaynak: ", - "uncompressed": "Sıkıştırılmamış", - "dab_music_source_description": "Audiophile'ler için. Yüksek kaliteli/kayıpsız ses akışları sağlar. Doğru ISRC tabanlı parça eşleştirme." -} \ No newline at end of file diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb deleted file mode 100644 index bdb723ad..00000000 --- a/lib/l10n/app_uk.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "Гість", - "browse": "Огляд", - "search": "Пошук", - "library": "Медіатека", - "lyrics": "Тексти пісень", - "settings": "Налаштування", - "genre_categories_filter": "Фільтрувати категорії або жанри...", - "genre": "Жанр", - "personalized": "Персоналізовані", - "featured": "Рекомендовані", - "new_releases": "Нові релізи", - "songs": "Пісні", - "playing_track": "Відтворюється {track}", - "queue_clear_alert": "Це очистить поточну чергу. Буде видалено {track_length} треків\nПродовжити?", - "load_more": "Завантажити більше", - "playlists": "Плейлисти", - "artists": "Виконавці", - "albums": "Альбоми", - "tracks": "Треки", - "downloads": "Завантаження", - "filter_playlists": "Фільтрувати плейлисти...", - "liked_tracks": "Сподобалися треки", - "liked_tracks_description": "Усі ваші сподобалися треки", - "create_playlist": "Створити плейлист", - "create_a_playlist": "Створити плейлист", - "update_playlist": "Оновити плейлист", - "create": "Створити", - "cancel": "Скасувати", - "update": "Оновити", - "playlist_name": "Назва плейлиста", - "name_of_playlist": "Назва плейлиста", - "description": "Опис", - "public": "Публічний", - "collaborative": "Спільний", - "search_local_tracks": "Пошук локальних треків...", - "play": "Відтворити", - "delete": "Видалити", - "none": "Немає", - "sort_a_z": "Сортувати за алфавітом A-Я", - "sort_z_a": "Сортувати за алфавітом Я-А", - "sort_artist": "Сортувати за виконавцем", - "sort_album": "Сортувати за альбомом", - "sort_tracks": "Сортувати треки", - "currently_downloading": "Завантажується ({tracks_length})", - "cancel_all": "Скасувати все", - "filter_artist": "Фільтрувати виконавців...", - "followers": "{followers} підписників", - "add_artist_to_blacklist": "Додати виконавця до чорного списку", - "top_tracks": "Топ треки", - "fans_also_like": "Шанувальникам також подобається", - "loading": "Завантаження...", - "artist": "Виконавець", - "blacklisted": "У чорному списку", - "following": "Стежу", - "follow": "Стежити", - "artist_url_copied": "URL виконавця скопійовано до буфера обміну", - "added_to_queue": "Додано {tracks} треків до черги", - "filter_albums": "Фільтрувати альбоми...", - "synced": "Синхронізовано", - "plain": "Звичайний", - "shuffle": "Випадковий порядок", - "search_tracks": "Пошук треків...", - "released": "Випущено", - "error": "Помилка {error}", - "title": "Назва", - "time": "Час", - "more_actions": "Більше дій", - "download_count": "Завантажено ({count})", - "add_count_to_playlist": "Додати ({count}) до плейлиста", - "add_count_to_queue": "Додати ({count}) до черги", - "play_count_next": "Відтворити ({count}) наступними", - "album": "Альбом", - "copied_to_clipboard": "Скопійовано {data} до буфера обміну", - "add_to_following_playlists": "Додати {track} до наступних плейлистів", - "add": "Додати", - "added_track_to_queue": "Додано {track} до черги", - "add_to_queue": "Додати до черги", - "track_will_play_next": "{track} буде відтворено наступним", - "play_next": "Відтворити наступним", - "removed_track_from_queue": "Видалено {track} з черги", - "remove_from_queue": "Видалити з черги", - "remove_from_favorites": "Видалити з обраних", - "save_as_favorite": "Зберегти як обране", - "add_to_playlist": "Додати до плейлиста", - "remove_from_playlist": "Видалити з плейлиста", - "add_to_blacklist": "Додати до чорного списку", - "remove_from_blacklist": "Видалити з чорного списку", - "share": "Поділитися", - "mini_player": "Міні-плеєр", - "slide_to_seek": "Проведіть пальцем, щоб перемотати вперед або назад", - "shuffle_playlist": "Випадковий порядок відтворення плейлиста", - "unshuffle_playlist": "Відключити випадковий порядок відтворення плейлиста", - "previous_track": "Попередній трек", - "next_track": "Наступний трек", - "pause_playback": "Призупинити відтворення", - "resume_playback": "Відновити відтворення", - "loop_track": "Повторювати трек", - "repeat_playlist": "Повторювати плейлист", - "queue": "Черга", - "alternative_track_sources": "Альтернативні джерела треків", - "download_track": "Завантажити трек", - "tracks_in_queue": "{tracks} треків у черзі", - "clear_all": "Очистити все", - "show_hide_ui_on_hover": "Показувати/приховувати інтерфейс при наведенні курсору", - "always_on_top": "Завжди зверху", - "exit_mini_player": "Вийти з міні-плеєра", - "download_location": "Шлях завантаження", - "account": "Обліковий запис", - "login_with_spotify": "Увійти за допомогою облікового запису Spotify", - "connect_with_spotify": "Підключитися до Spotify", - "logout": "Вийти", - "logout_of_this_account": "Вийти з цього облікового запису", - "language_region": "Мова та регіон", - "language": "Мова", - "system_default": "Системна мова", - "market_place_region": "Регіон маркетплейсу", - "recommendation_country": "Країна рекомендацій", - "appearance": "Зовнішній вигляд", - "layout_mode": "Режим макета", - "override_layout_settings": "Перезаписати налаштування адаптивного режиму макета", - "adaptive": "Адаптивний", - "compact": "Компактний", - "extended": "Розширений", - "theme": "Тема", - "dark": "Темна", - "light": "Світла", - "system": "Системна", - "accent_color": "Колір акценту", - "sync_album_color": "Синхронізувати колір альбому", - "sync_album_color_description": "Використовує домінуючий колір обкладинки альбому як колір акценту", - "playback": "Відтворення", - "audio_quality": "Якість аудіо", - "high": "Висока", - "low": "Низька", - "pre_download_play": "Попереднє завантаження та відтворення", - "pre_download_play_description": "Замість потокового відтворення аудіо завантажте байти та відтворіть їх (рекомендовано для користувачів з високою пропускною здатністю)", - "skip_non_music": "Пропустити не музичні сегменти", - "blacklist_description": "Треки та виконавці в чорному списку", - "wait_for_download_to_finish": "Зачекайте, поки завершиться поточна загрузка", - "desktop": "Робочий стіл", - "close_behavior": "Поведінка при закритті", - "close": "Закрити", - "minimize_to_tray": "Згорнути в трей", - "show_tray_icon": "Показувати значок у системному треї", - "about": "Про", - "u_love_spotube": "Ми знаємо, що ви любите Spotube", - "check_for_updates": "Перевірити наявність оновлень", - "about_spotube": "Про Spotube", - "blacklist": "Чорний список", - "please_sponsor": "Будь ласка, станьте спонсором/зробіть пожертву", - "spotube_description": "Spotube, легкий, кросплатформовий, безкоштовний клієнт Spotify", - "version": "Версія", - "build_number": "Номер збірки", - "founder": "Засновник", - "repository": "Репозиторій", - "bug_issues": "Помилки та проблеми", - "made_with": "Зроблено з ❤️ в Бангладеш 🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Ліцензія", - "add_spotify_credentials": "Додайте свої облікові дані Spotify, щоб почати", - "credentials_will_not_be_shared_disclaimer": "Не хвилюйтеся, жодні ваші облікові дані не будуть зібрані або передані кому-небудь", - "know_how_to_login": "Не знаєте, як це зробити?", - "follow_step_by_step_guide": "Дотримуйтесь покрокової інструкції", - "spotify_cookie": "Кукі-файл Spotify {name}", - "cookie_name_cookie": "Кукі-файл {name}", - "fill_in_all_fields": "Будь ласка, заповніть усі поля", - "submit": "Надіслати", - "exit": "Вийти", - "previous": "Попередній", - "next": "Наступний", - "done": "Готово", - "step_1": "Крок 1", - "first_go_to": "Спочатку перейдіть на", - "login_if_not_logged_in": "та Увійдіть/Зареєструйтесь, якщо ви не ввійшли", - "step_2": "Крок 2", - "step_2_steps": "1. Після входу натисніть F12 або клацніть правою кнопкою миші > Інспектувати, щоб відкрити інструменти розробки браузера.\n2. Потім перейдіть на вкладку 'Програма' (Chrome, Edge, Brave тощо) або вкладку 'Сховище' (Firefox, Palemoon тощо).\n3. Перейдіть до розділу 'Кукі-файли', а потім до підрозділу 'https://accounts.spotify.com'", - "step_3": "Крок 3", - "success_emoji": "Успіх🥳", - "success_message": "Тепер ви успішно ввійшли у свій обліковий запис Spotify. Гарна робота, друже!", - "step_4": "Крок 4", - "something_went_wrong": "Щось пішло не так", - "piped_instance": "Примірник сервера Piped", - "piped_description": "Примірник сервера Piped, який використовуватиметься для зіставлення треків", - "piped_warning": "Деякі з них можуть працювати неправильно. Тому використовуйте на свій страх і ризик", - "generate_playlist": "Створити плейлист", - "track_exists": "Трек {track} вже існує", - "replace_downloaded_tracks": "Замінити всі завантажені треки", - "skip_download_tracks": "Пропустити завантаження всіх завантажених треків", - "do_you_want_to_replace": "Ви хочете замінити існуючий трек?", - "replace": "Замінити", - "skip": "Пропустити", - "select_up_to_count_type": "Виберіть до {count} {type}", - "select_genres": "Виберіть жанри", - "add_genres": "Додати жанри", - "country": "Країна", - "number_of_tracks_generate": "Кількість треків для створення", - "acousticness": "Акустичність", - "danceability": "Танцювальність", - "energy": "Енергія", - "instrumentalness": "Інструментальність", - "liveness": "Живість", - "loudness": "Гучність", - "speechiness": "Розмовність", - "valence": "Валентність", - "popularity": "Популярність", - "key": "Тональність", - "duration": "Тривалість (с)", - "tempo": "Темп (BPM)", - "mode": "Режим", - "time_signature": "Розмір", - "short": "Короткий", - "medium": "Середній", - "long": "Довгий", - "min": "Мін", - "max": "Макс", - "target": "Цільовий", - "moderate": "Помірний", - "deselect_all": "Зняти вибір з усіх", - "select_all": "Вибрати всі", - "are_you_sure": "Ви впевнені?", - "generating_playlist": "Створення вашого персонального плейлиста...", - "selected_count_tracks": "Вибрано {count} треків", - "download_warning": "Якщо ви завантажуєте всі треки масово, ви явно піратствуєте і завдаєте шкоди музичному творчому співтовариству. Сподіваюся, ви усвідомлюєте це. Завжди намагайтеся поважати і підтримувати важку працю артиста", - "download_ip_ban_warning": "До речі, ваш IP може бути заблокований на YouTube через надмірну кількість запитів на завантаження, ніж зазвичай. Блокування IP-адреси означає, що ви не зможете користуватися YouTube (навіть якщо ви увійшли в систему) протягом щонайменше 2-3 місяців з цього пристрою. І Spotube не несе жодної відповідальності, якщо це станеться", - "by_clicking_accept_terms": "Натискаючи 'прийняти', ви погоджуєтеся з наступними умовами:", - "download_agreement_1": "Я знаю, що краду музику. Я поганий.", - "download_agreement_2": "Я підтримаю автора, де тільки зможу, і роблю це лише тому, що не маю грошей, щоб купити його роботи.", - "download_agreement_3": "Я повністю усвідомлюю, що мій IP може бути заблокований на YouTube, і я не покладаю на Spotube або його власників/контрибуторів відповідальність за будь-які нещасні випадки, спричинені моїми діями.", - "decline": "Відхилити", - "accept": "Прийняти", - "details": "Деталі", - "youtube": "YouTube", - "channel": "Канал", - "likes": "Подобається", - "dislikes": "Не подобається", - "views": "Переглядів", - "streamUrl": "Посилання на стрімінг", - "stop": "Зупинити", - "sort_newest": "Сортувати за датою додавання (новіші першими)", - "sort_oldest": "Сортувати за датою додавання (старіші першими)", - "sleep_timer": "Таймер сну", - "mins": "{minutes} хвилин", - "hours": "{hours} годин", - "hour": "{hours} година", - "custom_hours": "Кількість годин на замовлення", - "logs": "Логи", - "developers": "Розробники", - "not_logged_in": "Ви не ввійшли в обліковий запис", - "search_mode": "Режим пошуку", - "audio_source": "Джерело аудіо", - "ok": "Гаразд", - "failed_to_encrypt": "Не вдалося зашифрувати", - "encryption_failed_warning": "Spotube використовує шифрування для безпечного зберігання ваших даних. Але не вдалося цього зробити. Тому він перейде до небезпечного зберігання\nЯкщо ви використовуєте Linux, переконайтеся, що у вас встановлено будь-який секретний сервіс (gnome-keyring, kde-wallet, keepassxc тощо)", - "querying_info": "Запит інформації...", - "piped_api_down": "API Piped не працює", - "piped_down_error_instructions": "Поточний екземпляр Piped {pipedInstance} не працює\n\nЗмініть екземпляр або змініть 'Тип API' на офіційний YouTube API\n\nОбов'язково перезапустіть програму після зміни", - "you_are_offline": "Ви зараз не в мережі", - "connection_restored": "Ваше інтернет-з'єднання відновлено", - "use_system_title_bar": "Використовувати системний заголовок", - "crunching_results": "Опрацювання результатів...", - "search_to_get_results": "Почніть пошук, щоб отримати результати", - "use_amoled_mode": "Режим AMOLED", - "pitch_dark_theme": "Темна тема", - "normalize_audio": "Нормалізувати звук", - "change_cover": "Змінити обкладинку", - "add_cover": "Додати обкладинку", - "restore_defaults": "Відновити налаштування за замовчуванням", - "download_music_codec": "Завантажити кодек для музики", - "streaming_music_codec": "Кодек потокової передачі музики", - "login_with_lastfm": "Увійти з Last.fm", - "connect": "Підключити", - "disconnect_lastfm": "Відключитися від Last.fm", - "disconnect": "Відключити", - "username": "Ім'я користувача", - "password": "Пароль", - "login": "Увійти", - "login_with_your_lastfm": "Увійти в свій обліковий запис Last.fm", - "scrobble_to_lastfm": "Скробблінг на Last.fm", - "go_to_album": "Перейти до альбому", - "discord_rich_presence": "Багата присутність у Discord", - "browse_all": "Переглянути все", - "genres": "Жанри", - "explore_genres": "Досліджувати жанри", - "step_3_steps": "Скопіюйте значення cookie \"sp_dc\"", - "step_4_steps": "Вставте скопійоване значення \"sp_dc\"", - "friends": "Друзі", - "no_lyrics_available": "Вибачте, не вдалося знайти текст для цього треку", - "sort_duration": "Сортувати за тривалістю", - "start_a_radio": "Запустити радіо", - "how_to_start_radio": "Як ви хочете запустити радіо?", - "replace_queue_question": "Ви хочете замінити поточну чергу чи додати до неї?", - "endless_playback": "Безкінечне відтворення", - "delete_playlist": "Видалити плейлист", - "delete_playlist_confirmation": "Ви впевнені, що хочете видалити цей плейлист?", - "local_tracks": "Місцеві треки", - "song_link": "Посилання на пісню", - "skip_this_nonsense": "Пропустити цей бред", - "freedom_of_music": "“Свобода музики”", - "freedom_of_music_palm": "“Свобода музики у вашій долоні”", - "get_started": "Давайте почнемо", - "youtube_source_description": "Рекомендовано та працює краще за все.", - "piped_source_description": "Чи почуваєте себе вільно? Те саме, що і на YouTube, але набагато безкоштовно.", - "jiosaavn_source_description": "Найкраще для регіону Південної Азії.", - "highest_quality": "Найвища якість: {quality}", - "select_audio_source": "Виберіть джерело аудіо", - "endless_playback_description": "Автоматично додавати нові пісні\nв кінець черги", - "choose_your_region": "Виберіть ваш регіон", - "choose_your_region_description": "Це допоможе Spotube показати вам правильний контент\nдля вашого місцезнаходження.", - "choose_your_language": "Виберіть свою мову", - "help_project_grow": "Допоможіть цьому проекту рости", - "help_project_grow_description": "Spotube - це проект з відкритим кодом. Ви можете допомогти цьому проекту зростати, вносячи свій внесок у проект, повідомляючи про помилки або пропонуючи нові функції.", - "contribute_on_github": "Долучайтесь на GitHub", - "donate_on_open_collective": "Пожертвуйте на Open Collective", - "browse_anonymously": "Анонімно переглядати", - "enable_connect": "Увімкнути підключення", - "enable_connect_description": "Керуйте Spotube з інших пристроїв", - "devices": "Пристрої", - "select": "Вибрати", - "connect_client_alert": "Вас керує {client}", - "this_device": "Цей пристрій", - "remote": "Віддалений", - "local_library": "Місцева бібліотека", - "add_library_location": "Додати до бібліотеки", - "remove_library_location": "Видалити з бібліотеки", - "local_tab": "Місцевий", - "stats": "Статистика", - "and_n_more": "і {count} більше", - "recently_played": "Нещодавно Відтворене", - "browse_more": "Переглянути Більше", - "no_title": "Без Назви", - "not_playing": "Не Відтворюється", - "epic_failure": "Епічний провал!", - "added_num_tracks_to_queue": "Додано {tracks_length} треків до черги", - "spotube_has_an_update": "Spotube має оновлення", - "download_now": "Завантажити Зараз", - "nightly_version": "Spotube Nightly {nightlyBuildNum} було випущено", - "release_version": "Spotube v{version} було випущено", - "read_the_latest": "Читати останні новини", - "release_notes": "ноти про випуск", - "pick_color_scheme": "Оберіть кольорову схему", - "save": "Зберегти", - "choose_the_device": "Виберіть пристрій:", - "multiple_device_connected": "Підключено кілька пристроїв.\nВиберіть пристрій, на якому ви хочете виконати цю дію", - "nothing_found": "Нічого не знайдено", - "the_box_is_empty": "Коробка порожня", - "top_artists": "Топ Артисти", - "top_albums": "Топ Альбоми", - "this_week": "Цього тижня", - "this_month": "Цього місяця", - "last_6_months": "Останні 6 місяців", - "this_year": "Цього року", - "last_2_years": "Останні 2 роки", - "all_time": "Усі часи", - "powered_by_provider": "Забезпечено {providerName}", - "email": "Електронна пошта", - "profile_followers": "Підписники", - "birthday": "День народження", - "subscription": "Підписка", - "not_born": "Ще не народжений", - "hacker": "Хакер", - "profile": "Профіль", - "no_name": "Без імені", - "edit": "Редагувати", - "user_profile": "Профіль користувача", - "count_plays": "{count} відтворень", - "streaming_fees_hypothetical": "*Розраховано на основі виплат Spotify за стримінг\nвід $0.003 до $0.005. Це гіпотетичний\nрозрахунок, щоб дати уявлення користувачу про те, скільки б він\nзаплатив артистам, якби слухав їхні пісні на Spotify.", - "count_mins": "{minutes} хв", - "summary_minutes": "хвилини", - "summary_listened_to_music": "Прослухана музика", - "summary_songs": "пісні", - "summary_streamed_overall": "Загалом стримів", - "summary_owed_to_artists": "Заборгованість артистам\nцього місяця", - "summary_artists": "артистів", - "summary_music_reached_you": "Музика досягла вас", - "summary_full_albums": "повні альбоми", - "summary_got_your_love": "Отримав вашу любов", - "summary_playlists": "плейлисти", - "summary_were_on_repeat": "Були на повторі", - "total_money": "Загалом {money}", - "minutes_listened": "Хвилини прослуховування", - "streamed_songs": "Стримлені пісні", - "count_streams": "{count} стримів", - "owned_by_you": "Ваша власність", - "copied_shareurl_to_clipboard": "{shareUrl} скопійовано в буфер обміну", - "spotify_hipotetical_calculation": "*Це розраховано на основі виплат Spotify за стрім\nвід $0.003 до $0.005. Це гіпотетичний розрахунок,\nщоб дати користувачеві уявлення про те, скільки б він заплатив\nартистам, якби слухав їхні пісні на Spotify.", - "webview_not_found": "Webview не знайдено", - "webview_not_found_description": "На вашому пристрої не встановлено виконуване середовище Webview.\nЯкщо воно встановлено, переконайтеся, що воно знаходиться в environment PATH\n\nПісля встановлення перезапустіть програму", - "unsupported_platform": "Непідтримувана платформа", - "invidious_instance": "Екземпляр сервера Invidious", - "invidious_description": "Екземпляр сервера Invidious для зіставлення треків", - "invidious_warning": "Деякі можуть працювати не дуже добре. Використовуйте на власний ризик", - "invidious_source_description": "Подібний до Piped, але з вищою доступністю.", - "cache_music": "Кешувати музику", - "open": "Відкрити", - "cache_folder": "Тека кешу", - "export": "Експорт", - "clear_cache": "Очистити кеш", - "clear_cache_confirmation": "Ви хочете очистити кеш?", - "export_cache_files": "Експортувати кешовані файли", - "found_n_files": "Знайдено {count} файлів", - "export_cache_confirmation": "Ви хочете експортувати ці файли до", - "exported_n_out_of_m_files": "Експортовано {filesExported} з {files} файлів", - "playlist": "Плейлист", - "no_loop": "Без повтору", - "generate": "Генерувати", - "undo": "Скасувати", - "download_all": "Завантажити все", - "add_all_to_playlist": "Додати все до плейлиста", - "add_all_to_queue": "Додати все в чергу", - "play_all_next": "Відтворити все наступне", - "pause": "Пауза", - "view_all": "Переглянути все", - "no_tracks_added_yet": "Здається, ви ще не додали жодної пісні", - "no_tracks": "Здається, тут немає пісень", - "no_tracks_listened_yet": "Здається, ви ще нічого не слухали", - "not_following_artists": "Ви не підписані на жодного артиста", - "no_favorite_albums_yet": "Здається, ви ще не додали жодного альбому в улюблені", - "no_logs_found": "Жодних журналів не знайдено", - "youtube_engine": "YouTube Двигун", - "youtube_engine_not_installed_title": "{engine} не встановлено", - "youtube_engine_not_installed_message": "{engine} не встановлено на вашій системі.", - "youtube_engine_set_path": "Переконайтесь, що він доступний у змінній PATH або\nвстановіть абсолютний шлях до виконуваного файлу {engine} нижче", - "youtube_engine_unix_issue_message": "У macOS/Linux/Unix-подібних ОС, встановлення шляху в .zshrc/.bashrc/.bash_profile тощо не працює.\nВам потрібно налаштувати шлях у файлі конфігурації оболонки", - "download": "Завантажити", - "file_not_found": "Файл не знайдено", - "custom": "Користувацький", - "add_custom_url": "Додати користувацький URL", - "edit_port": "Редагувати порт", - "port_helper_msg": "За замовчуванням -1, що означає випадкове число. Якщо у вас налаштований брандмауер, рекомендується це налаштувати.", - "connect_request": "Дозволити {client} підключення?", - "connection_request_denied": "Підключення відхилено. Користувач відмовив у доступі.", - "hipotetical_calculation": "*Це розраховано на основі середньої виплати за стрім онлайн-платформ для потокового відтворення музики, що становить від $0,003 до $0,005. Це гіпотетичний розрахунок, щоб дати користувачеві уявлення про те, скільки б вони заплатили артистам, якщо б слухали їхні пісні на різних музичних стрімінгових платформах.", - "an_error_occurred": "Сталася помилка", - "copy_to_clipboard": "Копіювати в буфер обміну", - "view_logs": "Переглянути логи", - "retry": "Повторити", - "no_default_metadata_provider_selected": "Ви не встановили провайдера метаданих за замовчуванням", - "manage_metadata_providers": "Керувати провайдерами метаданих", - "open_link_in_browser": "Відкрити посилання в браузері?", - "do_you_want_to_open_the_following_link": "Ви хочете відкрити наступне посилання", - "unsafe_url_warning": "Відкриття посилань з ненадійних джерел може бути небезпечним. Будьте обережні!\nВи також можете скопіювати посилання в буфер обміну.", - "copy_link": "Копіювати посилання", - "building_your_timeline": "Створення вашої часової шкали на основі ваших прослуховувань...", - "official": "Офіційний", - "author_name": "Автор: {author}", - "third_party": "Сторонній", - "plugin_requires_authentication": "Плагін вимагає автентифікації", - "update_available": "Доступне оновлення", - "supports_scrobbling": "Підтримує скроблінг", - "plugin_scrobbling_info": "Цей плагін скроббить вашу музику, щоб створити вашу історію прослуховувань.", - "default_plugin": "За замовчуванням", - "set_default": "Встановити за замовчуванням", - "support": "Підтримка", - "support_plugin_development": "Підтримати розробку плагіна", - "can_access_name_api": "- Може отримати доступ до **{name}** API", - "do_you_want_to_install_this_plugin": "Ви хочете встановити цей плагін?", - "third_party_plugin_warning": "Цей плагін із стороннього репозиторію. Будь ласка, переконайтеся, що ви довіряєте джерелу перед встановленням.", - "author": "Автор", - "this_plugin_can_do_following": "Цей плагін може робити наступне", - "install": "Встановити", - "install_a_metadata_provider": "Встановити провайдера метаданих", - "no_tracks_playing": "Наразі не відтворюється жоден трек", - "synced_lyrics_not_available": "Синхронізовані тексти недоступні для цієї пісні. Будь ласка, використовуйте вкладку", - "plain_lyrics": "Звичайні тексти", - "tab_instead": "замість цього.", - "disclaimer": "Відмова від відповідальності", - "third_party_plugin_dmca_notice": "Команда Spotube не несе жодної відповідальності (включно з юридичною) за будь-які плагіни \"третіх сторін\".\nБудь ласка, використовуйте їх на свій страх і ризик. Про будь-які помилки/проблеми повідомляйте в репозиторій плагіна.\n\nЯкщо якийсь плагін \"третьої сторони\" порушує ToS/DMCA будь-якої служби/юридичної особи, будь ласка, попросіть автора плагіна \"третьої сторони\" або хостингову платформу, наприклад, GitHub/Codeberg, вжити заходів. Усі перераховані вище (позначені як \"треті сторони\") є плагінами, які підтримуються публічно/спільнотою. Ми не куруємо їх, тому не можемо вжити жодних заходів щодо них.\n\n", - "input_does_not_match_format": "Введені дані не відповідають необхідному формату", - "metadata_provider_plugins": "Плагіни провайдера метаданих", - "paste_plugin_download_url": "Вставте URL-адресу для завантаження або URL-адресу репозиторію GitHub/Codeberg або пряме посилання на файл .smplug", - "download_and_install_plugin_from_url": "Завантажити та встановити плагін з URL-адреси", - "failed_to_add_plugin_error": "Не вдалося додати плагін: {error}", - "upload_plugin_from_file": "Завантажити плагін з файлу", - "installed": "Встановлено", - "available_plugins": "Доступні плагіни", - "configure_your_own_metadata_plugin": "Налаштуйте свій власний провайдер метаданих для плейлиста/альбому/виконавця/стрічки", - "audio_scrobblers": "Аудіо скробблери", - "scrobbling": "Скроблінг", - "download_music_format": "Формат завантаження музики", - "streaming_music_format": "Формат потокової музики", - "download_music_quality": "Якість завантаженої музики", - "streaming_music_quality": "Якість потокової музики", - "default_metadata_source": "Джерело метаданих за замовчуванням", - "set_default_metadata_source": "Встановити джерело метаданих за замовчуванням", - "default_audio_source": "Джерело аудіо за замовчуванням", - "set_default_audio_source": "Встановити джерело аудіо за замовчуванням", - "plugins": "Плагіни", - "configure_plugins": "Налаштуйте власні плагіни метаданих і аудіоджерела", - "source": "Джерело: ", - "uncompressed": "Без стиснення", - "dab_music_source_description": "Для аудіофілів. Забезпечує високоякісні/без втрат аудіопотоки. Точна відповідність треків на основі ISRC." -} \ No newline at end of file diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb deleted file mode 100644 index 5733963e..00000000 --- a/lib/l10n/app_vi.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "Khách", - "browse": "Khám phá", - "search": "Tìm kiếm", - "library": "Thư viên", - "lyrics": "Lời bài hát", - "settings": "Cài đặt", - "genre_categories_filter": "Lọc theo thể loại nhạc...", - "genre": "Thể loại nhạc", - "personalized": "Cá nhân hóa", - "featured": "Nổi bật", - "new_releases": "Bản phát hành mới", - "songs": "Bài hát", - "playing_track": "Đang phát {track}", - "queue_clear_alert": "Điều này sẽ xóa hàng đợi hiện tại. {track_length} bài hát sẽ bị xóa\nBạn có muốn tiếp tục không?", - "load_more": "Tải thêm", - "playlists": "Danh sách phát", - "artists": "Nghệ sĩ", - "albums": "Album", - "tracks": "Bài hát", - "downloads": "Tải về", - "filter_playlists": "Lọc danh sách phát...", - "liked_tracks": "Bài hát được thích", - "liked_tracks_description": "Tất cả bài hát bạn đã thích", - "create_playlist": "Tạo danh sách phát", - "create_a_playlist": "Tạo danh sách phát", - "update_playlist": "Cập nhật danh sách phát", - "create": "Tạo", - "cancel": "Hủy", - "update": "Cập nhật", - "playlist_name": "Tên danh sách phát", - "name_of_playlist": "Tên của danh sách phát", - "description": "Mô tả", - "public": "Công khai", - "collaborative": "Hợp tác", - "search_local_tracks": "Tìm kiếm bài hát trong máy...", - "play": "Phát", - "delete": "Xóa", - "none": "Không có", - "sort_a_z": "Sắp xếp theo A-Z", - "sort_z_a": "Sắp xếp theo Z-A", - "sort_artist": "Sắp xếp theo Nghệ sĩ", - "sort_album": "Sắp xếp theo Album", - "sort_tracks": "Sắp xếp các bài hát", - "currently_downloading": "Đang tải về ({tracks_length} bài hát)", - "cancel_all": "Hủy tất cả", - "filter_artist": "Lọc nghệ sĩ...", - "followers": "{followers} Người theo dõi", - "add_artist_to_blacklist": "Thêm nghệ sĩ vào blacklist", - "top_tracks": "Bài hát nổi bật", - "fans_also_like": "Người hâm mộ cũng thích", - "loading": "Đang tải...", - "artist": "Nghệ sĩ", - "blacklisted": "Đã đưa vào blacklist", - "following": "Đang theo dõi", - "follow": "Theo dõi", - "artist_url_copied": "Đã sao chép URL nghệ sĩ", - "added_to_queue": "Đã thêm {tracks} bài hát vào hàng đợi", - "filter_albums": "Lọc album...", - "synced": "Đồng bộ", - "plain": "Bình thường", - "shuffle": "Trộn", - "search_tracks": "Tìm kiếm bài hát...", - "released": "Phát hành", - "error": "Lỗi {error}", - "title": "Đề mục", - "time": "Thời gian", - "more_actions": "Thao tác khác", - "download_count": "Tải xuống ({count})", - "add_count_to_playlist": "Thêm ({count}) vào danh sách phát", - "add_count_to_queue": "Thêm ({count}) vào hàng đợi", - "play_count_next": "Phát ({count}) tiếp theo", - "album": "Album", - "copied_to_clipboard": "Đã sao chép {data} vào clipboard", - "add_to_following_playlists": "Thêm {track} vào danh sách phát đang theo dõi", - "add": "Thêm", - "added_track_to_queue": "Đã thêm {track} vào hàng đợi", - "add_to_queue": "Thêm vào hàng đợi", - "track_will_play_next": "{track} sẽ được phát tiếp theo", - "play_next": "Phát tiếp theo", - "removed_track_from_queue": "Đã xóa {track} khỏi hàng đợi", - "remove_from_queue": "Xóa khỏi hàng đợi", - "remove_from_favorites": "Xóa khỏi bài hát yêu thích", - "save_as_favorite": "Thêm vào bài hát yêu thích", - "add_to_playlist": "Thêm vào danh sách phát", - "remove_from_playlist": "Xóa khỏi danh sách phát", - "add_to_blacklist": "Thêm vào blacklist", - "remove_from_blacklist": "Xóa khỏi blacklist", - "share": "Chia sẻ", - "mini_player": "Trình phát thu nhỏ", - "slide_to_seek": "Trượt để tìm kiếm tiến hoặc lùi", - "shuffle_playlist": "Xáo trộn bài hát", - "unshuffle_playlist": "Hủy xáo trộn bài hát", - "previous_track": "Bài hát trước", - "next_track": "Bài hát tiếp theo", - "pause_playback": "Tạm dừng phát", - "resume_playback": "Tiếp tục phát", - "loop_track": "Lặp lại bài hát", - "repeat_playlist": "Lặp lại danh sách phát", - "queue": "Hàng đợi", - "alternative_track_sources": "Đổi nguồn bài hát", - "download_track": "Tải xuống", - "tracks_in_queue": "{tracks} bài hát trong hàng đợi", - "clear_all": "Xóa tất cả", - "show_hide_ui_on_hover": "Hiển thị/Ẩn giao diện người dùng khi di chuột qua", - "always_on_top": "Luôn ở trên cùng", - "exit_mini_player": "Thoát khỏi trình phát thu nhỏ", - "download_location": "Vị trí tải xuống", - "account": "Tài khoản", - "login_with_spotify": "Đăng nhập bằng tài khoản Spotify của bạn", - "connect_with_spotify": "Liên kết với Spotify", - "logout": "Đăng xuất", - "logout_of_this_account": "Đăng xuất khỏi tài khoản này", - "language_region": "Ngôn ngữ và Khu vực", - "language": "Ngôn ngữ", - "system_default": "Mặc định hệ thống", - "market_place_region": "Khu vực Marketplace", - "recommendation_country": "Quốc gia gợi ý", - "appearance": "Giao diện", - "layout_mode": "Chế độ layout", - "override_layout_settings": "Ghi đè cài đặt layout", - "adaptive": "Tương thích", - "compact": "Nhỏ gọn", - "extended": "Mở rộng", - "theme": "Chủ đề", - "dark": "Tối", - "light": "Sáng", - "system": "Hệ thống", - "accent_color": "Màu nhấn", - "sync_album_color": "Đồng bộ màu album", - "sync_album_color_description": "Sử dụng màu chủ đạo của hình ảnh album làm màu nhấn", - "playback": "Phát", - "audio_quality": "Chất lượng âm thanh", - "high": "Cao", - "low": "Thấp", - "pre_download_play": "Tải xuống và phát", - "pre_download_play_description": "Thay vì stream âm thanh, tải xuống trước và phát (Khuyến nghị cho người dùng có băng thông cao)", - "skip_non_music": "Bỏ qua các đoạn không phải nhạc (SponsorBlock)", - "blacklist_description": "Các bài hát và nghệ sĩ trong blacklist", - "wait_for_download_to_finish": "Vui lòng đợi quá trình tải xuống hiện tại hoàn thành", - "desktop": "Máy tính", - "close_behavior": "Thao tác đóng", - "close": "Đóng", - "minimize_to_tray": "Thu nhỏ vào khay hệ thống", - "show_tray_icon": "Hiển thị biểu tượng trên khay hệ thống", - "about": "Về chúng tôi", - "u_love_spotube": "Chúng tôi biết bạn yêu Spotube", - "check_for_updates": "Kiểm tra cập nhật", - "about_spotube": "Về Spotube", - "blacklist": "blacklist", - "please_sponsor": "Vui lòng tài trợ/ủng hộ", - "spotube_description": "Spotube, một ứng dụng Spotify nhẹ, đa nền tảng và miễn phí", - "version": "Phiên bản", - "build_number": "Số phiên bản", - "founder": "Người sáng lập", - "repository": "Mã nguồn", - "bug_issues": "Báo cáo lỗi", - "made_with": "Được làm bằng ❤️ ở Băng-la-đét", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Giấy phép", - "add_spotify_credentials": "Điền thông tin đăng nhập Spotify của bạn", - "credentials_will_not_be_shared_disclaimer": "Đừng lo, thông tin đăng nhập của bạn sẽ không được thu thập hoặc chia sẻ với bất kỳ ai", - "know_how_to_login": "Không biết cách lấy thông tin đăng nhập?", - "follow_step_by_step_guide": "Các bước lấy thông tin đăng nhập", - "spotify_cookie": "Cookie Spotify {name}", - "cookie_name_cookie": "Cookie {name}", - "fill_in_all_fields": "Vui lòng điền đầy đủ thông tin", - "submit": "Gửi", - "exit": "Thoát", - "previous": "Trước", - "next": "Tiếp", - "done": "Hoàn tất", - "step_1": "Bước 1", - "first_go_to": "Đầu tiên, truy cập", - "login_if_not_logged_in": "và Đăng nhập/Đăng ký nếu chưa có tài khoản", - "step_2": "Bước 2", - "step_2_steps": "1. Sau khi đăng nhập, nhấn F12 hoặc Chuột phải > Mở devtools của trình duyệt.\n2. Sau đó, chuyển đến Tab \"Ứng dụng/Application\" (Chrome, Edge, Brave, v.v.) hoặc Tab \"Lưu trữ/Storage\" (Firefox, Palemoon, v.v.)\n3. Chuyển đến phần \"Cookie\" sau đó phần con \"https://accounts.spotify.com\"", - "step_3": "Bước 3", - "step_3_steps": "Sao chép giá trị của Cookie \"sp_dc\" và \"sp_key\" (hoặc sp_gaid)", - "success_emoji": "Thành công🥳", - "success_message": "Bây giờ bạn đã đăng nhập thành công bằng tài khoản Spotify của mình. Làm tốt lắm!", - "step_4": "Bước 4", - "step_4_steps": "Dán giá trị đã sao chép của Cookie \"sp_dc\" và \"sp_key\" (hoặc sp_gaid) vào các trường tương ứng", - "something_went_wrong": "Đã xảy ra lỗi", - "piped_instance": "Phiên bản Server Piped", - "piped_description": "Phiên bản Piped để sử dụng cho Track matching", - "piped_warning": "Một số phiên bản Piped có thể không hoạt động tốt", - "generate_playlist": "Tạo danh sách phát", - "track_exists": "Bài hát {track} đã tồn tại", - "replace_downloaded_tracks": "Thay thế tất cả các bài hát đã tải", - "skip_download_tracks": "Bỏ qua tải xuống tất cả các bài hát đã tải", - "do_you_want_to_replace": "Bạn có muốn thay thế bài hát hiện có không?", - "replace": "Thay thế", - "skip": "Bỏ qua", - "select_up_to_count_type": "Chọn tối đa {count} {type}", - "select_genres": "Chọn Thể loại", - "add_genres": "Thêm Thể loại", - "country": "Quốc gia", - "number_of_tracks_generate": "Số lượng bài hát để tạo", - "acousticness": "Độ âm thanh", - "danceability": "Khả năng nhảy", - "energy": "Năng lượng", - "instrumentalness": "Độ nhạc cụ", - "liveness": "Sống động", - "loudness": "Độ ồn", - "speechiness": "Độ nói", - "valence": "Tính tích cực", - "popularity": "Độ phổ biến", - "key": "Tông", - "duration": "Thời lượng (giây)", - "tempo": "Nhịp độ (BPM)", - "mode": "Chế độ", - "time_signature": "Chữ ký thời gian", - "short": "Ngắn", - "medium": "Trung bình", - "long": "Dài", - "min": "Tối thiểu", - "max": "Tối đa", - "target": "Mục tiêu", - "moderate": "Trung bình", - "deselect_all": "Bỏ chọn tất cả", - "select_all": "Chọn tất cả", - "are_you_sure": "Bạn có chắc chắn?", - "generating_playlist": "Đang tạo danh sách phát tùy chỉnh của bạn...", - "selected_count_tracks": "Đã chọn {count} bài hát", - "download_warning": "Tải xuống tất cả các bài hát một lần, sẽ vi phạm bản quyền âm nhạc và gây thiệt hại cho xã hội sáng tạo âm nhạc. Hy vọng bạn nhận thức được điều này. Hãy luôn tôn trọng và ủng hộ công sức của nghệ sĩ", - "download_ip_ban_warning": "Địa chỉ IP của bạn có thể bị chặn trên YouTube do yêu cầu tải xuống quá mức so với bình thường. Chặn IP có nghĩa là bạn không thể sử dụng YouTube (ngay cả khi bạn đã đăng nhập) ít nhất 2-3 tháng từ thiết bị IP đó. Và Spotube không chịu trách nhiệm nếu điều này xảy ra", - "by_clicking_accept_terms": "Bằng cách nhấp vào 'Chấp nhận', bạn đồng ý với các điều khoản sau:", - "download_agreement_1": "Tôi biết mình đang vi phạm bản quyền âm nhạc. Đó là không tốt.", - "download_agreement_2": "Tôi sẽ ủng hộ nghệ sĩ bất cứ nơi nào tôi có thể và tôi chỉ làm điều này vì tôi không có tiền để mua tác phẩm của họ", - "download_agreement_3": "Tôi hoàn toàn nhận thức được rằng địa chỉ IP của tôi có thể bị chặn trên YouTube và tôi không đổ lỗi cho Spotube hoặc chủ sở hữu/người đóng góp của nó về bất kỳ tai nạn nào do hành động này của tôi", - "decline": "Từ chối", - "accept": "Chấp nhận", - "details": "Chi tiết", - "youtube": "YouTube", - "channel": "Kênh", - "likes": "Thích", - "dislikes": "Không thích", - "views": "Lượt xem", - "streamUrl": "URL phát trực tiếp", - "stop": "Dừng", - "sort_newest": "Sắp xếp theo mới nhất", - "sort_oldest": "Sắp xếp theo cũ nhất", - "sleep_timer": "Hẹn giờ tắt", - "mins": "{minutes} Phút", - "hours": "{hours} Giờ", - "hour": "{hours} Giờ", - "custom_hours": "Giờ Tùy chỉnh", - "logs": "Nhật ký", - "developers": "Nhà phát triển", - "not_logged_in": "Bạn chưa đăng nhập", - "search_mode": "Chế độ tìm kiếm", - "audio_source": "Nguồn âm thanh", - "ok": "Ok", - "failed_to_encrypt": "Mã hóa không thành công", - "encryption_failed_warning": "Spotube không thành công trong việc mã hóa nhằm lưu trữ dữ liêu an toàn. vậy nên sẽ chuyển về lưu trữ không an toàn\nNếu bạn đang sử dụng Linux, đảm bảo rằng bạn có sử dụng dịch vụ bảo mật (gnome-keyring, kde-wallet, keepassxc, v.v.)", - "querying_info": "Đang truy vấn thông tin...", - "piped_api_down": "API Piped đang gặp sự cố", - "piped_down_error_instructions": "Phiên bản Piped {pipedInstance} hiện đang gặp sự cố\n\nThay đổi phiên bản hoặc thay đổi 'Loại API' thành API YouTube official\n\nKhởi động lai ứng dụng sau khi thay đổi.", - "you_are_offline": "Bạn đang ngoại tuyến", - "connection_restored": "Kết nối internet của bạn đã được khôi phục", - "use_system_title_bar": "Sử dụng thanh tiêu đề hệ thống", - "crunching_results": "Đang tìm kiếm...", - "search_to_get_results": "Chưa tìm kiếm", - "use_amoled_mode": "Chủ đề tối hoàn toàn", - "pitch_dark_theme": "Chế độ AMOLED", - "normalize_audio": "Bình thường hóa âm thanh", - "change_cover": "Thay đổi ảnh bìa", - "add_cover": "Thêm ảnh bìa", - "restore_defaults": "Khôi phục mặc định", - "download_music_codec": "Định dạng tải xuống", - "streaming_music_codec": "Định dạng nghe", - "login_with_lastfm": "Đăng nhập bằng tài khoản Last.fm", - "connect": "Liên kết", - "disconnect_lastfm": "Dừng liên kết Last.fm", - "disconnect": "Ngắt kết nối", - "username": "Tên người dùng", - "password": "Mật khẩu", - "login": "Đăng nhập", - "login_with_your_lastfm": "Đăng nhập bằng tài khoản Last.fm của bạn", - "scrobble_to_lastfm": "Scrobble đến Last.fm", - "go_to_album": "Đi đến Album", - "discord_rich_presence": "Hiển thị trạng thái Discord", - "browse_all": "Duyệt tất cả", - "genres": "Thể loại", - "explore_genres": "Khám phá Thể loại", - "sort_duration": "Sắp xếp theo Thời lượng", - "start_a_radio": "Bắt đầu Một Đài phát thanh", - "how_to_start_radio": "Bạn muốn bắt đầu đài phát thanh như thế nào?", - "replace_queue_question": "Bạn muốn thay thế hàng đợi hiện tại hay thêm vào?", - "endless_playback": "Phát không giới hạn", - "delete_playlist": "Xóa Danh sách phát", - "delete_playlist_confirmation": "Bạn có chắc chắn muốn xóa danh sách phát này không?", - "local_tracks": "Bài hát Địa phương", - "song_link": "Liên kết Bài hát", - "skip_this_nonsense": "Bỏ qua bớt rối này", - "freedom_of_music": "“Sự Tự do của Âm nhạc”", - "freedom_of_music_palm": "“Sự Tự do của Âm nhạc trong lòng bàn tay của bạn”", - "get_started": "Bắt đầu thôi", - "youtube_source_description": "Được đề xuất và hoạt động tốt nhất.", - "piped_source_description": "Cảm thấy tự do? Giống như YouTube nhưng miễn phí hơn rất nhiều.", - "jiosaavn_source_description": "Tốt nhất cho khu vực Nam Á.", - "highest_quality": "Chất lượng Tốt nhất: {quality}", - "select_audio_source": "Chọn Nguồn Âm thanh", - "endless_playback_description": "Tự động thêm các bài hát mới\nvào cuối hàng đợi", - "choose_your_region": "Chọn khu vực của bạn", - "choose_your_region_description": "Điều này sẽ giúp Spotube hiển thị nội dung phù hợp cho vị trí của bạn.", - "choose_your_language": "Chọn ngôn ngữ của bạn", - "help_project_grow": "Hãy giúp dự án này phát triển", - "help_project_grow_description": "Spotube là một dự án mã nguồn mở. Bạn có thể giúp dự án này phát triển bằng cách đóng góp vào dự án, báo cáo lỗi hoặc đề xuất tính năng mới.", - "contribute_on_github": "Đóng góp trên GitHub", - "donate_on_open_collective": "Quyên góp trên Open Collective", - "browse_anonymously": "Duyệt Anonymously", - "friends": "Bạn bè", - "no_lyrics_available": "Xin lỗi, không tìm thấy lời cho bài hát này", - "enable_connect": "Kích hoạt kết nối", - "enable_connect_description": "Điều khiển Spotube từ các thiết bị khác", - "devices": "Thiết bị", - "select": "Chọn", - "connect_client_alert": "Bạn đang được điều khiển bởi {client}", - "this_device": "Thiết bị này", - "remote": "Từ xa", - "local_library": "Thư viện địa phương", - "add_library_location": "Thêm vào thư viện", - "remove_library_location": "Xóa khỏi thư viện", - "local_tab": "Địa phương", - "stats": "Thống kê", - "and_n_more": "và {count} cái khác", - "recently_played": "Gần đây đã phát", - "browse_more": "Xem thêm", - "no_title": "Không có tiêu đề", - "not_playing": "Không phát", - "epic_failure": "Thất bại hoàn toàn!", - "added_num_tracks_to_queue": "Đã thêm {tracks_length} bài hát vào danh sách phát", - "spotube_has_an_update": "Spotube có bản cập nhật", - "download_now": "Tải về ngay", - "nightly_version": "Spotube Nightly {nightlyBuildNum} đã được phát hành", - "release_version": "Spotube v{version} đã được phát hành", - "read_the_latest": "Đọc tin mới nhất", - "release_notes": "ghi chú phát hành", - "pick_color_scheme": "Chọn chủ đề màu sắc", - "save": "Lưu", - "choose_the_device": "Chọn thiết bị:", - "multiple_device_connected": "Có nhiều thiết bị kết nối.\nChọn thiết bị mà bạn muốn thực hiện hành động này", - "nothing_found": "Không tìm thấy gì", - "the_box_is_empty": "Hộp trống", - "top_artists": "Những Nghệ Sĩ Hàng Đầu", - "top_albums": "Những Album Hàng Đầu", - "this_week": "Tuần này", - "this_month": "Tháng này", - "last_6_months": "6 tháng qua", - "this_year": "Năm nay", - "last_2_years": "2 năm qua", - "all_time": "Mọi thời đại", - "powered_by_provider": "Cung cấp bởi {providerName}", - "email": "Email", - "profile_followers": "Người theo dõi", - "birthday": "Ngày sinh", - "subscription": "Gói cước", - "not_born": "Chưa sinh", - "hacker": "Tin tặc", - "profile": "Hồ sơ", - "no_name": "Không có tên", - "edit": "Chỉnh sửa", - "user_profile": "Hồ sơ người dùng", - "count_plays": "{count} lần phát", - "streaming_fees_hypothetical": "*Tính toán dựa trên thanh toán của Spotify cho mỗi lần phát\ntừ $0.003 đến $0.005. Đây là một tính toán giả định để\ngive người dùng cái nhìn về số tiền họ sẽ chi trả cho các nghệ sĩ nếu họ nghe\nbài hát của họ trên Spotify.", - "count_mins": "{minutes} phút", - "summary_minutes": "phút", - "summary_listened_to_music": "Đã nghe nhạc", - "summary_songs": "bài hát", - "summary_streamed_overall": "Stream tổng cộng", - "summary_owed_to_artists": "Nợ nghệ sĩ\ntrong tháng này", - "summary_artists": "nghệ sĩ", - "summary_music_reached_you": "Âm nhạc đã đến với bạn", - "summary_full_albums": "album đầy đủ", - "summary_got_your_love": "Nhận được tình yêu của bạn", - "summary_playlists": "danh sách phát", - "summary_were_on_repeat": "Đã được phát lại", - "total_money": "Tổng cộng {money}", - "minutes_listened": "Thời gian nghe", - "streamed_songs": "Bài hát đã phát", - "count_streams": "{count} lượt phát", - "owned_by_you": "Thuộc sở hữu của bạn", - "copied_shareurl_to_clipboard": "{shareUrl} đã sao chép vào bảng tạm", - "spotify_hipotetical_calculation": "*Được tính toán dựa trên khoản thanh toán của Spotify cho mỗi lượt phát\ntừ $0.003 đến $0.005. Đây là một tính toán giả định để\ncung cấp cho người dùng cái nhìn về số tiền họ sẽ phải trả\ncho các nghệ sĩ nếu họ nghe bài hát của họ trên Spotify.", - "webview_not_found": "Không tìm thấy Webview", - "webview_not_found_description": "Không có runtime Webview nào được cài đặt trên thiết bị của bạn.\nNếu đã cài đặt, hãy đảm bảo rằng nó nằm trong environment PATH\n\nSau khi cài đặt, hãy khởi động lại ứng dụng", - "unsupported_platform": "Nền tảng không được hỗ trợ", - "invidious_instance": "Phiên bản máy chủ Invidious", - "invidious_description": "Phiên bản máy chủ Invidious để sử dụng để so khớp bản nhạc", - "invidious_warning": "Một số có thể sẽ không hoạt động tốt. Vì vậy hãy sử dụng với rủi ro của riêng bạn", - "invidious_source_description": "Tương tự như Piped nhưng có tính khả dụng cao hơn.", - "cache_music": "Lưu nhạc vào bộ nhớ đệm", - "open": "Mở", - "cache_folder": "Thư mục bộ nhớ đệm", - "export": "Xuất", - "clear_cache": "Xóa bộ nhớ đệm", - "clear_cache_confirmation": "Bạn có muốn xóa bộ nhớ đệm không?", - "export_cache_files": "Xuất các tệp được lưu trong bộ nhớ đệm", - "found_n_files": "Tìm thấy {count} tệp", - "export_cache_confirmation": "Bạn có muốn xuất các tệp này đến", - "exported_n_out_of_m_files": "Đã xuất {filesExported} trên {files} tệp", - "playlist": "Danh sách phát", - "no_loop": "Không lặp lại", - "generate": "Tạo", - "undo": "Hoàn tác", - "download_all": "Tải xuống tất cả", - "add_all_to_playlist": "Thêm tất cả vào danh sách phát", - "add_all_to_queue": "Thêm tất cả vào danh sách chờ", - "play_all_next": "Chơi tất cả tiếp theo", - "pause": "Tạm dừng", - "view_all": "Xem tất cả", - "no_tracks_added_yet": "Có vẻ bạn chưa thêm bất kỳ bài hát nào", - "no_tracks": "Có vẻ không có bài hát nào ở đây", - "no_tracks_listened_yet": "Có vẻ bạn chưa nghe gì cả", - "not_following_artists": "Bạn không đang theo dõi bất kỳ nghệ sĩ nào", - "no_favorite_albums_yet": "Có vẻ bạn chưa thêm album nào vào danh sách yêu thích", - "no_logs_found": "Không tìm thấy nhật ký", - "youtube_engine": "Công cụ YouTube", - "youtube_engine_not_installed_title": "{engine} chưa được cài đặt", - "youtube_engine_not_installed_message": "{engine} chưa được cài đặt trong hệ thống của bạn.", - "youtube_engine_set_path": "Đảm bảo nó có sẵn trong biến PATH hoặc\nđặt đường dẫn tuyệt đối đến tệp thực thi {engine} dưới đây", - "youtube_engine_unix_issue_message": "Trên macOS/Linux/Unix, việc thiết lập đường dẫn trong .zshrc/.bashrc/.bash_profile v.v. sẽ không hoạt động.\nBạn cần thiết lập đường dẫn trong tệp cấu hình shell", - "download": "Tải xuống", - "file_not_found": "Không tìm thấy tệp", - "custom": "Tùy chỉnh", - "add_custom_url": "Thêm URL tùy chỉnh", - "edit_port": "Chỉnh sửa cổng", - "port_helper_msg": "Mặc định là -1, có nghĩa là số ngẫu nhiên. Nếu bạn đã cấu hình tường lửa, nên đặt điều này.", - "connect_request": "Cho phép {client} kết nối?", - "connection_request_denied": "Kết nối bị từ chối. Người dùng đã từ chối quyền truy cập.", - "hipotetical_calculation": "*Điều này được tính toán dựa trên khoản thanh toán trung bình mỗi luồng của nền tảng phát nhạc trực tuyến là $0,003 đến $0,005. Đây là một phép tính giả định để cung cấp cho người dùng cái nhìn sâu sắc về số tiền họ đã trả cho các nghệ sĩ nếu họ nghe bài hát của họ trên các nền tảng phát nhạc trực tuyến khác nhau.", - "an_error_occurred": "Đã xảy ra lỗi", - "copy_to_clipboard": "Sao chép vào khay nhớ tạm", - "view_logs": "Xem nhật ký", - "retry": "Thử lại", - "no_default_metadata_provider_selected": "Bạn chưa đặt nhà cung cấp siêu dữ liệu mặc định nào", - "manage_metadata_providers": "Quản lý nhà cung cấp siêu dữ liệu", - "open_link_in_browser": "Mở liên kết trong Trình duyệt?", - "do_you_want_to_open_the_following_link": "Bạn có muốn mở liên kết sau không", - "unsafe_url_warning": "Việc mở các liên kết từ các nguồn không đáng tin cậy có thể không an toàn. Hãy thận trọng!\nBạn cũng có thể sao chép liên kết vào khay nhớ tạm của mình.", - "copy_link": "Sao chép liên kết", - "building_your_timeline": "Đang xây dựng dòng thời gian của bạn dựa trên những gì bạn đã nghe...", - "official": "Chính thức", - "author_name": "Tác giả: {author}", - "third_party": "Bên thứ ba", - "plugin_requires_authentication": "Plugin yêu cầu xác thực", - "update_available": "Có bản cập nhật", - "supports_scrobbling": "Hỗ trợ scrobbling", - "plugin_scrobbling_info": "Plugin này scrobble nhạc của bạn để tạo lịch sử nghe của bạn.", - "default_plugin": "Mặc định", - "set_default": "Đặt làm mặc định", - "support": "Hỗ trợ", - "support_plugin_development": "Hỗ trợ phát triển plugin", - "can_access_name_api": "- Có thể truy cập API **{name}**", - "do_you_want_to_install_this_plugin": "Bạn có muốn cài đặt plugin này không?", - "third_party_plugin_warning": "Plugin này đến từ một kho lưu trữ của bên thứ ba. Vui lòng đảm bảo rằng bạn tin tưởng nguồn trước khi cài đặt.", - "author": "Tác giả", - "this_plugin_can_do_following": "Plugin này có thể làm những việc sau", - "install": "Cài đặt", - "install_a_metadata_provider": "Cài đặt một Nhà cung cấp siêu dữ liệu", - "no_tracks_playing": "Hiện không có bản nhạc nào đang phát", - "synced_lyrics_not_available": "Lời bài hát được đồng bộ hóa không có sẵn cho bài hát này. Vui lòng sử dụng", - "plain_lyrics": "Lời bài hát thuần túy", - "tab_instead": "thay thế.", - "disclaimer": "Miễn trừ trách nhiệm", - "third_party_plugin_dmca_notice": "Nhóm Spotube không chịu bất kỳ trách nhiệm nào (bao gồm cả pháp lý) đối với bất kỳ plugin \"Bên thứ ba\" nào.\nVui lòng sử dụng chúng với rủi ro của riêng bạn. Đối với bất kỳ lỗi/vấn đề nào, vui lòng báo cáo chúng cho kho lưu trữ plugin.\n\nNếu bất kỳ plugin \"Bên thứ ba\" nào vi phạm ToS/DMCA của bất kỳ dịch vụ/thực thể pháp lý nào, vui lòng yêu cầu tác giả plugin \"Bên thứ ba\" hoặc nền tảng lưu trữ, ví dụ: GitHub/Codeberg, thực hiện hành động. Tất cả các plugin được liệt kê ở trên (được gắn nhãn \"Bên thứ ba\") đều là các plugin công cộng/do cộng đồng duy trì. Chúng tôi không quản lý chúng, vì vậy chúng tôi không thể thực hiện bất kỳ hành động nào đối với chúng.\n\n", - "input_does_not_match_format": "Đầu vào không khớp với định dạng yêu cầu", - "metadata_provider_plugins": "Plugin Nhà cung cấp siêu dữ liệu", - "paste_plugin_download_url": "Dán url tải xuống hoặc url kho lưu trữ GitHub/Codeberg hoặc liên kết trực tiếp đến tệp .smplug", - "download_and_install_plugin_from_url": "Tải xuống và cài đặt plugin từ url", - "failed_to_add_plugin_error": "Không thể thêm plugin: {error}", - "upload_plugin_from_file": "Tải lên plugin từ tệp", - "installed": "Đã cài đặt", - "available_plugins": "Các plugin có sẵn", - "configure_your_own_metadata_plugin": "Cấu hình nhà cung cấp siêu dữ liệu danh sách phát/album/nghệ sĩ/nguồn cấp dữ liệu của riêng bạn", - "audio_scrobblers": "Bộ scrobbler âm thanh", - "scrobbling": "Scrobbling", - "download_music_format": "Định dạng nhạc tải về", - "streaming_music_format": "Định dạng nhạc phát trực tuyến", - "download_music_quality": "Chất lượng nhạc tải về", - "streaming_music_quality": "Chất lượng nhạc phát trực tuyến", - "default_metadata_source": "Nguồn siêu dữ liệu mặc định", - "set_default_metadata_source": "Đặt nguồn siêu dữ liệu mặc định", - "default_audio_source": "Nguồn âm thanh mặc định", - "set_default_audio_source": "Đặt nguồn âm thanh mặc định", - "plugins": "Tiện ích bổ sung", - "configure_plugins": "Cấu hình nhà cung cấp siêu dữ liệu và tiện ích nguồn âm thanh riêng", - "source": "Nguồn: ", - "uncompressed": "Không nén", - "dab_music_source_description": "Dành cho người yêu âm nhạc chất lượng cao. Cung cấp luồng âm thanh chất lượng cao/không nén. Phù hợp bài hát dựa trên ISRC chính xác." -} \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb deleted file mode 100644 index 44f7d38c..00000000 --- a/lib/l10n/app_zh.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "访客", - "browse": "浏览", - "search": "搜索", - "library": "音乐库", - "lyrics": "歌词", - "settings": "设置", - "genre_categories_filter": "筛选类别...", - "genre": "探索歌单", - "personalized": "为你打造", - "featured": "推荐", - "new_releases": "新歌热播", - "songs": "歌曲", - "playing_track": "播放 {track}", - "queue_clear_alert": "这将清空当前的播放队列。{track_length} 首歌曲将被移除\n你确定要继续吗?", - "load_more": "加载更多", - "playlists": "歌单", - "artists": "艺人", - "albums": "专辑", - "tracks": "歌曲", - "downloads": "下载", - "filter_playlists": "筛选歌单...", - "liked_tracks": "已点赞的歌曲", - "liked_tracks_description": "你点赞过的所有歌曲", - "create_playlist": "创建歌单", - "create_a_playlist": "创建一个歌单", - "create": "创建", - "cancel": "取消", - "playlist_name": "歌单名称", - "name_of_playlist": "歌单的名称", - "description": "描述", - "public": "公开", - "collaborative": "共享协作", - "search_local_tracks": "搜索本地歌曲...", - "play": "播放", - "delete": "删除", - "none": "无", - "sort_a_z": "按字母正序", - "sort_z_a": "按字母倒序", - "sort_artist": "按艺人", - "sort_album": "按专辑", - "sort_tracks": "排序方式", - "currently_downloading": "正在下载 ({tracks_length})", - "cancel_all": "取消全部", - "filter_artist": "筛选艺人...", - "followers": "{followers} 名关注者", - "add_artist_to_blacklist": "屏蔽该艺人", - "top_tracks": "热门歌曲", - "fans_also_like": "粉丝也喜欢", - "loading": "加载中...", - "artist": "艺人", - "blacklisted": "已屏蔽", - "following": "关注中", - "follow": "关注", - "artist_url_copied": "艺人的分享链接已复制至剪贴板", - "added_to_queue": "已添加 {tracks} 首歌曲到播放队列", - "filter_albums": "筛选专辑...", - "synced": "同步", - "plain": "无同步", - "shuffle": "随机播放", - "search_tracks": "搜索歌曲...", - "released": "发行时间", - "error": "错误 {error}", - "title": "标题", - "time": "时长", - "more_actions": "更多操作", - "download_count": "下载 ({count}) 首歌曲", - "add_count_to_playlist": "添加 ({count}) 首歌曲到歌单中", - "add_count_to_queue": "添加 ({count}) 首歌曲到播放队列中", - "play_count_next": "接下来播放 ({count}) 首歌曲", - "album": "专辑", - "copied_to_clipboard": "已将 {data} 复制至剪贴板", - "add_to_following_playlists": "添加 {track} 到以下播放列表", - "add": "添加", - "added_track_to_queue": "添加 {track} 到播放队列", - "add_to_queue": "添加到播放队列", - "track_will_play_next": "{track} 将在下一首播放", - "play_next": "下一首播放", - "removed_track_from_queue": "将 {track} 从播放队列中移除", - "remove_from_queue": "从播放队列移除", - "remove_from_favorites": "取消点赞", - "save_as_favorite": "点赞", - "add_to_playlist": "添加到歌单", - "remove_from_playlist": "从歌单中移除", - "add_to_blacklist": "添加到屏蔽列表", - "remove_from_blacklist": "从屏蔽列表中移除", - "share": "分享", - "mini_player": "小窗模式", - "slide_to_seek": "滑动以前进或后退", - "shuffle_playlist": "随机播放歌单", - "unshuffle_playlist": "取消随机播放歌单", - "previous_track": "上一首歌曲", - "next_track": "下一首歌曲", - "pause_playback": "暂停播放", - "resume_playback": "恢复播放", - "loop_track": "单曲循环", - "repeat_playlist": "歌单循环", - "queue": "播放队列", - "alternative_track_sources": "其它音源", - "download_track": "下载歌曲", - "tracks_in_queue": "{tracks} 首歌曲在播放队列中", - "clear_all": "清除全部", - "show_hide_ui_on_hover": "悬停时显示/隐藏控制栏", - "always_on_top": "置顶", - "exit_mini_player": "退出小窗模式", - "download_location": "下载路径", - "account": "账户", - "login_with_spotify": "使用 Spotify 登录", - "connect_with_spotify": "与 Spotify 账户连接", - "logout": "退出", - "logout_of_this_account": "退出该账户", - "language_region": "语言和地区", - "language": "语言", - "system_default": "系统默认", - "market_place_region": "市场地区", - "recommendation_country": "选择国家与地区以获取对应推荐", - "appearance": "外观", - "layout_mode": "布局类型", - "override_layout_settings": "将覆盖响应式布局设置", - "adaptive": "自适应", - "compact": "紧凑", - "extended": "宽广", - "theme": "主题", - "dark": "深色", - "light": "浅色", - "system": "系统", - "accent_color": "主色调", - "sync_album_color": "匹配封面颜色", - "sync_album_color_description": "选取专辑封面主题色作为主色调", - "playback": "播放", - "audio_quality": "音质", - "high": "高", - "low": "低", - "pre_download_play": "先下后播", - "pre_download_play_description": "先下载歌曲后再播放而非流式播放(推荐带宽较高用户使用)", - "skip_non_music": "跳过非音乐片段(屏蔽赞助商)", - "blacklist_description": "已屏蔽的歌曲与艺人", - "wait_for_download_to_finish": "请等待当前下载任务完成", - "desktop": "桌面端设置", - "close_behavior": "点击关闭按钮行为", - "close": "关闭", - "minimize_to_tray": "最小化到托盘", - "show_tray_icon": "显示托盘图标", - "about": "关于", - "u_love_spotube": "我们明白你喜欢 Spotube", - "check_for_updates": "检查更新", - "about_spotube": "关于 Spotube", - "blacklist": "屏蔽列表", - "please_sponsor": "请赞助/捐赠", - "spotube_description": "Spotube,一个轻量、跨平台且完全免费的 Spotify 客户端。", - "version": "版本", - "build_number": "构建代码", - "founder": "发起人", - "repository": "源码", - "bug_issues": "缺陷和问题报告", - "made_with": "于孟加拉🇧🇩用 ❤️ 发电", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "许可证", - "add_spotify_credentials": "添加你的 Spotify 登录信息以开始使用", - "credentials_will_not_be_shared_disclaimer": "不用担心,软件不会收集或分享任何个人数据给第三方", - "know_how_to_login": "不知道该怎么做?", - "follow_step_by_step_guide": "请按照以下指南进行", - "spotify_cookie": "Spotify {name} Cookie", - "cookie_name_cookie": "{name} Cookie", - "fill_in_all_fields": "请填写所有栏目", - "submit": "提交", - "exit": "退出", - "previous": "上一步", - "next": "下一步", - "done": "完成", - "step_1": "步骤 1", - "first_go_to": "首先,前往", - "login_if_not_logged_in": "如果尚未登录,请登录或者注册一个账户", - "step_2": "步骤 2", - "step_2_steps": "1. 一旦你已经完成登录, 按 F12 键或者鼠标右击网页空白区域 > 选择“检查”以打开浏览器开发者工具(DevTools)\n2. 然后选择 \"应用(Application)\" 标签页(Chrome, Edge, Brave 等基于 Chromium 的浏览器) 或 \"存储(Storage)\" 标签页 (Firefox, Palemoon 等基于 Firefox 的浏览器))\n3. 选择 \"Cookies\" 栏目然后选择 \"https://accounts.spotify.com\" 子栏目", - "step_3": "步骤 3", - "success_emoji": "成功🥳", - "success_message": "你已经成功使用 Spotify 登录。干得漂亮!", - "step_4": "步骤 4", - "something_went_wrong": "某些地方出现了问题", - "piped_instance": "Piped 服务器实例", - "piped_description": "Piped 服务器实例用于匹配歌曲", - "piped_warning": "它们中的一部分可能并不能正常工作。使用时请自行承担风险", - "generate_playlist": "生成歌单", - "track_exists": "歌曲 {track} 已存在", - "replace_downloaded_tracks": "替换已下载的歌曲", - "skip_download_tracks": "下载时跳过已下载的歌曲", - "do_you_want_to_replace": "你确定要替换已下载的歌曲吗??", - "replace": "替换", - "skip": "跳过", - "select_up_to_count_type": "选择多达 {count} 种的类型 {type}", - "select_genres": "选择曲风", - "add_genres": "添加曲风", - "country": "国家和地区", - "number_of_tracks_generate": "生成歌曲的数目", - "acousticness": "原声程度", - "danceability": "律动感", - "energy": "冲击感", - "instrumentalness": "歌唱部分占比", - "liveness": "现场感", - "loudness": "响度", - "speechiness": "朗诵比例", - "valence": "心理感受", - "popularity": "流行度", - "key": "曲调", - "duration": "歌曲时长 (s)", - "tempo": "分钟节拍数 (BPM)", - "mode": "旋律重复度", - "time_signature": "音符时值", - "short": "短", - "medium": "中", - "long": "长", - "min": "最低", - "max": "最高", - "target": "目标", - "moderate": "中", - "deselect_all": "取消全选", - "select_all": "全选", - "are_you_sure": "你确定吗?", - "generating_playlist": "正在生成你的自定义歌单...", - "selected_count_tracks": "已选择 {count} 首歌曲", - "download_warning": "如果你大量下载这些歌曲,你显然在侵犯音乐的版权并对音乐创作社区造成了伤害。我希望你能意识到这一点。永远要尊重并支持艺术家们的辛勤工作", - "download_ip_ban_warning": "小心,如果出现超出正常的下载请求那你的 IP 可能会被 YouTube 封禁,这意味着你的设备将在长达 2-3 个月的时间内无法使用该 IP 访问 YouTube(即使你没登录)。Spotube 对此不承担任何责任", - "by_clicking_accept_terms": "点击 '同意' 代表着你同意以下的条款", - "download_agreement_1": "我明白侵犯音乐版权是一件不好的事情", - "download_agreement_2": "我将尽可能支持艺术家的工作。我现在之所以做不到是因为缺乏资金来购买正版", - "download_agreement_3": "我完全了解我的 IP 存在被 YouTube的风险。我同意 Spotube 的所有者与贡献者们无须对我目前的行为所导致的任何后果负责", - "decline": "拒绝", - "accept": "同意", - "details": "详情", - "youtube": "YouTube", - "channel": "频道", - "likes": "赞", - "dislikes": "踩", - "views": "浏览次数", - "streamUrl": "播放流 URL", - "stop": "停止", - "sort_newest": "按添加日期正序", - "sort_oldest": "按添加日期倒序", - "sleep_timer": "睡眠定时器", - "mins": "{minutes} 分", - "hours": "{hours} 时", - "hour": "{hours} 时", - "custom_hours": "自定义时间", - "logs": "日志", - "developers": "开发者", - "not_logged_in": "你尚未登录", - "search_mode": "搜索模式", - "audio_source": "音频源", - "ok": "确定", - "failed_to_encrypt": "加密失败", - "encryption_failed_warning": "Spotube使用加密来安全地存储您的数据。但是失败了。因此,它将回退到不安全的存储\n如果您使用Linux,请确保已安装gnome-keyring、kde-wallet和keepassxc等秘密服务", - "querying_info": "正在查询信息...", - "piped_api_down": "Piped API不可用", - "piped_down_error_instructions": "当前Piped实例{pipedInstance}不可用\n\n请更改实例或将'API类型'更改为官方YouTube API\n\n更改后请确保重新启动应用程序", - "you_are_offline": "您当前处于离线状态", - "connection_restored": "您的互联网连接已恢复", - "use_system_title_bar": "使用系统标题栏", - "update_playlist": "更新播放列表", - "update": "更新", - "crunching_results": "处理结果中...", - "search_to_get_results": "搜索以获取结果", - "use_amoled_mode": "使用 AMOLED 模式", - "pitch_dark_theme": "深色主题", - "normalize_audio": "标准化音频", - "change_cover": "更改封面", - "add_cover": "添加封面", - "restore_defaults": "恢复默认值", - "download_music_codec": "下载音乐编解码器", - "streaming_music_codec": "流媒体音乐编解码器", - "login_with_lastfm": "使用 Last.fm 登录", - "connect": "连接", - "disconnect_lastfm": "断开 Last.fm 连接", - "disconnect": "断开连接", - "username": "用户名", - "password": "密码", - "login": "登录", - "login_with_your_lastfm": "使用您的 Last.fm 帐户登录", - "scrobble_to_lastfm": "在 Last.fm 上记录播放", - "go_to_album": "前往专辑", - "discord_rich_presence": "Discord 丰富展现", - "browse_all": "浏览全部", - "genres": "音乐类型", - "explore_genres": "探索音乐类型", - "step_3_steps": "复制\"sp_dc\" Cookie的值", - "step_4_steps": "粘贴复制的\"sp_dc\"值", - "friends": "朋友", - "no_lyrics_available": "抱歉,无法找到此曲的歌词", - "sort_duration": "按时长排序", - "start_a_radio": "开始收听电台", - "how_to_start_radio": "您想如何开始收听电台?", - "replace_queue_question": "您想要替换当前队列还是追加到队列?", - "endless_playback": "无尽播放", - "delete_playlist": "删除播放列表", - "delete_playlist_confirmation": "您确定要删除此播放列表吗?", - "local_tracks": "本地音轨", - "song_link": "歌曲链接", - "skip_this_nonsense": "跳过此无聊内容", - "freedom_of_music": "“音乐的自由”", - "freedom_of_music_palm": "“音乐的自由掌握在您手中”", - "get_started": "让我们开始吧", - "youtube_source_description": "推荐并且效果最佳。", - "piped_source_description": "感觉自由?与YouTube一样但更自由。", - "jiosaavn_source_description": "最适合南亚地区。", - "highest_quality": "最高音质:{quality}", - "select_audio_source": "选择音频源", - "endless_playback_description": "自动将新歌曲添加到队列的末尾", - "choose_your_region": "选择您的地区", - "choose_your_region_description": "这将帮助Spotube为您的位置显示正确的内容。", - "choose_your_language": "选择您的语言", - "help_project_grow": "帮助这个项目成长", - "help_project_grow_description": "Spotube是一个开源项目。您可以通过为项目做出贡献、报告错误或建议新功能来帮助该项目成长。", - "contribute_on_github": "在GitHub上做出贡献", - "donate_on_open_collective": "在Open Collective上捐款", - "browse_anonymously": "匿名浏览", - "enable_connect": "启用连接", - "enable_connect_description": "从其他设备控制Spotube", - "devices": "设备", - "select": "选择", - "connect_client_alert": "您正在被 {client} 控制", - "this_device": "此设备", - "remote": "远程", - "local_library": "本地图书馆", - "add_library_location": "添加到图书馆", - "remove_library_location": "从图书馆中删除", - "local_tab": "本地", - "stats": "统计", - "and_n_more": "和 {count} 更多", - "recently_played": "最近播放", - "browse_more": "浏览更多", - "no_title": "没有标题", - "not_playing": "未播放", - "epic_failure": "史诗级失败!", - "added_num_tracks_to_queue": "已将 {tracks_length} 首曲目添加到队列", - "spotube_has_an_update": "Spotube 有更新", - "download_now": "立即下载", - "nightly_version": "Spotube Nightly {nightlyBuildNum} 已发布", - "release_version": "Spotube v{version} 已发布", - "read_the_latest": "阅读最新", - "release_notes": "版本说明", - "pick_color_scheme": "选择配色方案", - "save": "保存", - "choose_the_device": "选择设备:", - "multiple_device_connected": "已连接多个设备。\n选择您希望执行此操作的设备", - "nothing_found": "未找到任何内容", - "the_box_is_empty": "箱子为空", - "top_artists": "热门艺术家", - "top_albums": "热门专辑", - "this_week": "本周", - "this_month": "本月", - "last_6_months": "过去6个月", - "this_year": "今年", - "last_2_years": "过去2年", - "all_time": "所有时间", - "powered_by_provider": "由 {providerName} 提供支持", - "email": "电子邮件", - "profile_followers": "关注者", - "birthday": "生日", - "subscription": "订阅", - "not_born": "尚未出生", - "hacker": "黑客", - "profile": "个人资料", - "no_name": "无名", - "edit": "编辑", - "user_profile": "用户资料", - "count_plays": "{count} 次播放", - "streaming_fees_hypothetical": "*基于 Spotify 每次播放的支付金额\n从 $0.003 到 $0.005 计算。这是一个假设性的\n计算,旨在让用户了解如果他们在 Spotify 上收听\n这些歌曲,可能会付给艺术家的金额。", - "count_mins": "{minutes} 分钟", - "summary_minutes": "分钟", - "summary_listened_to_music": "听音乐", - "summary_songs": "歌曲", - "summary_streamed_overall": "总体流媒体", - "summary_owed_to_artists": "本月欠艺术家的", - "summary_artists": "艺术家的", - "summary_music_reached_you": "音乐触及了你", - "summary_full_albums": "完整专辑", - "summary_got_your_love": "获得了你的爱", - "summary_playlists": "播放列表", - "summary_were_on_repeat": "已重复播放", - "total_money": "总计 {money}", - "minutes_listened": "听的分钟数", - "streamed_songs": "已流媒体歌曲", - "count_streams": "{count} 次流媒体", - "owned_by_you": "由您拥有", - "copied_shareurl_to_clipboard": "{shareUrl} 已复制到剪贴板", - "spotify_hipotetical_calculation": "*根据 Spotify 每次流媒体的支付金额\n$0.003 到 $0.005 进行计算。这是一个假设性的\n计算,用于给用户了解他们如果在 Spotify 上\n收听歌曲会支付给艺术家的金额。", - "webview_not_found": "未找到 Webview", - "webview_not_found_description": "您的设备中未安装 Webview 运行时。\n如果已安装,请确保它在 environment PATH 中\n\n安装后,重新启动应用程序", - "unsupported_platform": "不支持的平台", - "invidious_instance": "Invidious服务器实例", - "invidious_description": "用于音轨匹配的Invidious服务器实例", - "invidious_warning": "有些可能无法正常工作。请自行承担风险", - "invidious_source_description": "类似于Piped,但可用性更高。", - "cache_music": "缓存音乐", - "open": "打开", - "cache_folder": "缓存文件夹", - "export": "导出", - "clear_cache": "清除缓存", - "clear_cache_confirmation": "您要清除缓存吗?", - "export_cache_files": "导出缓存文件", - "found_n_files": "找到 {count} 个文件", - "export_cache_confirmation": "您要导出这些文件到", - "exported_n_out_of_m_files": "导出了 {filesExported} / {files} 个文件", - "playlist": "播放列表", - "no_loop": "无循环", - "generate": "生成", - "undo": "撤销", - "download_all": "下载全部", - "add_all_to_playlist": "将全部添加到播放列表", - "add_all_to_queue": "将全部添加到队列", - "play_all_next": "播放全部下一首", - "pause": "暂停", - "view_all": "查看所有", - "no_tracks_added_yet": "看起来你还没有添加任何曲目", - "no_tracks": "看起来这里没有任何曲目", - "no_tracks_listened_yet": "看起来你还没有听任何东西", - "not_following_artists": "你没有关注任何艺术家", - "no_favorite_albums_yet": "看起来你还没有将任何专辑添加到收藏夹", - "no_logs_found": "未找到日志", - "youtube_engine": "YouTube 引擎", - "youtube_engine_not_installed_title": "{engine} 未安装", - "youtube_engine_not_installed_message": "{engine} 未在您的系统中安装。", - "youtube_engine_set_path": "确保它可用在 PATH 变量中,或\n设置 {engine} 可执行文件的绝对路径", - "youtube_engine_unix_issue_message": "在 macOS/Linux/Unix 类操作系统中,在 .zshrc/.bashrc/.bash_profile 等文件中设置路径无效。\n您需要在 shell 配置文件中设置路径", - "download": "下载", - "file_not_found": "文件未找到", - "custom": "自定义", - "add_custom_url": "添加自定义 URL", - "edit_port": "编辑端口", - "port_helper_msg": "默认值为-1,表示随机数。如果您已配置防火墙,建议设置此项。", - "connect_request": "允许 {client} 连接吗?", - "connection_request_denied": "连接被拒绝。用户拒绝访问。", - "hipotetical_calculation": "*这是根据在线音乐流媒体平台每流平均支付0.003美元至0.005美元计算得出的。这是一个假设性的计算,旨在让用户了解如果他们在不同的音乐流媒体平台上收听歌曲,他们将需要向艺人支付多少费用。", - "an_error_occurred": "发生错误", - "copy_to_clipboard": "复制到剪贴板", - "view_logs": "查看日志", - "retry": "重试", - "no_default_metadata_provider_selected": "您未设置默认元数据提供者", - "manage_metadata_providers": "管理元数据提供者", - "open_link_in_browser": "在浏览器中打开链接?", - "do_you_want_to_open_the_following_link": "您想打开以下链接吗", - "unsafe_url_warning": "从不受信任的来源打开链接可能不安全。请谨慎!\n您也可以将链接复制到剪贴板。", - "copy_link": "复制链接", - "building_your_timeline": "正在根据您的收听记录构建您的时间线...", - "official": "官方", - "author_name": "作者:{author}", - "third_party": "第三方", - "plugin_requires_authentication": "插件需要身份验证", - "update_available": "有可用更新", - "supports_scrobbling": "支持 Scrobbling", - "plugin_scrobbling_info": "此插件会 scrobble 您的音乐以生成您的收听历史记录。", - "default_plugin": "默认", - "set_default": "设为默认", - "support": "支持", - "support_plugin_development": "支持插件开发", - "can_access_name_api": "- 可以访问 **{name}** API", - "do_you_want_to_install_this_plugin": "您想安装此插件吗?", - "third_party_plugin_warning": "此插件来自第三方存储库。请在安装前确保您信任此来源。", - "author": "作者", - "this_plugin_can_do_following": "此插件可以执行以下操作", - "install": "安装", - "install_a_metadata_provider": "安装元数据提供者", - "no_tracks_playing": "当前没有播放任何曲目", - "synced_lyrics_not_available": "此歌曲的同步歌词不可用。请使用", - "plain_lyrics": "纯歌词", - "tab_instead": "选项卡。", - "disclaimer": "免责声明", - "third_party_plugin_dmca_notice": "Spotube 团队对任何“第三方”插件不承担任何责任(包括法律责任)。\n请自行承担风险使用。对于任何错误/问题,请向插件存储库报告。\n\n如果任何“第三方”插件违反了任何服务/法律实体的服务条款/DMCA,请要求该“第三方”插件作者或托管平台(例如 GitHub/Codeberg)采取行动。上面列出的(标记为“第三方”)都是公共/社区维护的插件。我们不对此类插件进行管理,因此无法对其采取任何行动。\n\n", - "input_does_not_match_format": "输入与所需格式不匹配", - "metadata_provider_plugins": "元数据提供者插件", - "paste_plugin_download_url": "粘贴下载 URL、GitHub/Codeberg 存储库 URL 或 .smplug 文件的直接链接", - "download_and_install_plugin_from_url": "从 URL 下载并安装插件", - "failed_to_add_plugin_error": "添加插件失败:{error}", - "upload_plugin_from_file": "从文件上传插件", - "installed": "已安装", - "available_plugins": "可用插件", - "configure_your_own_metadata_plugin": "配置您自己的播放列表/专辑/艺人/订阅元数据提供者", - "audio_scrobblers": "音频 Scrobblers", - "scrobbling": "Scrobbling", - "download_music_format": "下载音乐格式", - "streaming_music_format": "流媒体音乐格式", - "download_music_quality": "下载音乐质量", - "streaming_music_quality": "流媒体音乐质量", - "default_metadata_source": "默认元数据源", - "set_default_metadata_source": "设置默认元数据源", - "default_audio_source": "默认音频源", - "set_default_audio_source": "设置默认音频源", - "plugins": "插件", - "configure_plugins": "配置您自己的元数据提供者和音频源插件", - "source": "来源:", - "uncompressed": "无损", - "dab_music_source_description": "适合发烧友。提供高质量/无损音频流。基于 ISRC 的精确曲目匹配。" -} \ No newline at end of file diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb deleted file mode 100644 index 934006d5..00000000 --- a/lib/l10n/app_zh_TW.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "guest": "訪客", - "browse": "瀏覽", - "search": "搜尋", - "library": "音樂庫", - "lyrics": "歌詞", - "settings": "設定", - "genre_categories_filter": "過濾分類...", - "genre": "探索歌單", - "personalized": "為你打造", - "featured": "推薦", - "new_releases": "新歌熱播", - "songs": "歌曲", - "playing_track": "播放 {track}", - "queue_clear_alert": "這將清空目前的播放清單。{track_length} 首歌曲將被移除\n你確定要繼續嗎?", - "load_more": "載入更多", - "playlists": "歌單", - "artists": "藝人", - "albums": "專輯", - "tracks": "歌曲", - "downloads": "下載", - "filter_playlists": "過濾歌單...", - "liked_tracks": "已按讚的歌曲", - "liked_tracks_description": "你按過讚的所有歌曲", - "create_playlist": "建立歌單", - "create_a_playlist": "建立一個歌單", - "create": "建立", - "cancel": "取消", - "playlist_name": "歌單名稱", - "name_of_playlist": "歌單的名稱", - "description": "說明", - "public": "公開", - "collaborative": "共享協作", - "search_local_tracks": "搜尋本地歌曲...", - "play": "播放", - "delete": "刪除", - "none": "無", - "sort_a_z": "依字母順序", - "sort_z_a": "依字母倒序", - "sort_artist": "按藝人", - "sort_album": "按專輯", - "sort_tracks": "排序方式", - "currently_downloading": "正在下載 ({tracks_length})", - "cancel_all": "取消全部", - "filter_artist": "過濾藝人...", - "followers": "{followers} 名追蹤者", - "add_artist_to_blacklist": "封鎖該藝人", - "top_tracks": "熱門歌曲", - "fans_also_like": "粉絲也喜歡", - "loading": "載入中...", - "artist": "藝人", - "blacklisted": "已封鎖", - "following": "關注中", - "follow": "關注", - "artist_url_copied": "此名藝人的分享連結已複製至剪貼簿", - "added_to_queue": "已新增 {tracks} 首歌曲到播放清單", - "filter_albums": "過濾專輯...", - "synced": "同步", - "plain": "未同步", - "shuffle": "隨機播放", - "search_tracks": "搜尋歌曲...", - "released": "發表時間", - "error": "發生錯誤: {error}", - "title": "標題", - "time": "時長", - "more_actions": "更多動作", - "download_count": "下載 ({count}) 首歌曲", - "add_count_to_playlist": "將 ({count}) 首歌曲新增到歌單中", - "add_count_to_queue": "新增 ({count}) 首歌曲到播放清單", - "play_count_next": "接下來將播放 ({count}) 首歌曲", - "album": "專輯", - "copied_to_clipboard": "已將 {data} 複製至剪貼簿", - "add_to_following_playlists": "新增 {track} 到以下播放清單", - "add": "新增", - "added_track_to_queue": "新增 {track} 到播放清單", - "add_to_queue": "新增至播放清單", - "track_will_play_next": "{track} 將在下一首播放", - "play_next": "下一首播放", - "removed_track_from_queue": "將 {track} 從播放清單移除", - "remove_from_queue": "從播放清單移除", - "remove_from_favorites": "取消按讚", - "save_as_favorite": "按讚", - "add_to_playlist": "新增到歌單", - "remove_from_playlist": "從歌單移除", - "add_to_blacklist": "新增到已封鎖清單", - "remove_from_blacklist": "從已封鎖清單移除", - "share": "分享", - "mini_player": "小窗模式", - "slide_to_seek": "滑動以前進或後退", - "shuffle_playlist": "隨機播放歌單", - "unshuffle_playlist": "取消隨機播放歌單", - "previous_track": "上一首歌曲", - "next_track": "下一首歌", - "pause_playback": "暫停播放", - "resume_playback": "恢復播放", - "loop_track": "單曲循環", - "repeat_playlist": "歌單循環", - "queue": "播放清單", - "alternative_track_sources": "其它音源", - "download_track": "下載歌曲", - "tracks_in_queue": "{tracks} 首歌曲在播放清單中", - "clear_all": "清除全部", - "show_hide_ui_on_hover": "游標暫留時顯示 / 隱藏控制列", - "always_on_top": "置頂", - "exit_mini_player": "退出小窗模式", - "download_location": "下載路徑", - "account": "帳戶", - "login_with_spotify": "使用 Spotify 登入", - "connect_with_spotify": "與 Spotify 帳號連結", - "logout": "退出", - "logout_of_this_account": "退出該帳戶", - "language_region": "語言與地區", - "language": "語言", - "system_default": "系統預設", - "market_place_region": "市集地區", - "recommendation_country": "請選擇國家與地區以取得對應的音樂推薦", - "appearance": "外觀", - "layout_mode": "佈局類型", - "override_layout_settings": "將覆寫響應式佈局設定", - "adaptive": "響應式", - "compact": "緊湊", - "extended": "寬闊", - "theme": "主題", - "dark": "深色", - "light": "淺色", - "system": "依循系統", - "accent_color": "主色調", - "sync_album_color": "符合封面顏色", - "sync_album_color_description": "選取專輯封面主題色為主色調", - "playback": "播放", - "audio_quality": "音質", - "high": "高", - "low": "低", - "pre_download_play": "下載後播放", - "pre_download_play_description": "先下載歌曲後再播放而非串流播放(建議頻寬較高使用者使用)", - "skip_non_music": "跳過非音樂片段(跳過贊助商廣告)", - "blacklist_description": "已封鎖的歌曲與藝人", - "wait_for_download_to_finish": "請等待目前下載工作完成", - "desktop": "桌面版設定", - "close_behavior": "點選關閉按鈕行為", - "close": "關閉", - "minimize_to_tray": "最小化到工作列", - "show_tray_icon": "顯示工作列圖示", - "about": "關於", - "u_love_spotube": "我們明白你喜歡 Spotube", - "check_for_updates": "檢查更新", - "about_spotube": "關於 Spotube", - "blacklist": "黑名單", - "please_sponsor": "請考慮贊助或捐款", - "spotube_description": "Spotube,一款輕量、跨平台且完全免費的 Spotify 用戶端。", - "version": "版本", - "build_number": "建置編號", - "founder": "發起人", - "repository": "專案儲存庫", - "bug_issues": "缺陷與問題報告", - "made_with": "於孟加拉🇧🇩用 ❤️ 發電", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "授權", - "add_spotify_credentials": "新增你的 Spotify 登入資訊以開始使用", - "credentials_will_not_be_shared_disclaimer": "您大可放心,軟體不會收集或分享任何個人資料給第三方", - "know_how_to_login": "不知道該怎麼辦?", - "follow_step_by_step_guide": "請依照以下說明進行", - "spotify_cookie": "Spotify {name} Cookie", - "cookie_name_cookie": "{name} Cookie", - "fill_in_all_fields": "請填入所有欄位", - "submit": "提交", - "exit": "退出", - "previous": "上一步", - "next": "下一步", - "done": "完成", - "step_1": "步驟 1", - "first_go_to": "首先,前往", - "login_if_not_logged_in": "如果尚未登入,請登入或註冊帳戶", - "step_2": "步驟 2", - "step_2_steps": "1. 一旦你已經完成登入, 按 F12 鍵或滑鼠右鍵點選網頁空白區域 > 選擇「檢查」以開啟瀏覽器開發者工具(DevTools)\n2. 選擇 \"應用程式(Application)\" 分頁(Chrome, Edge, Brave 等基於 Chromium 記憶體或基於 Choxage, nox Firefox 的瀏覽器))\n3. 選擇 \"Cookies\" 欄位然後選擇 \"https://accounts.spotify.com\" 子選單", - "step_3": "步驟 3", - "success_emoji": "成功🥳", - "success_message": "你已經成功使用 Spotify 登入。幹得漂亮!", - "step_4": "步驟 4", - "something_went_wrong": "某些地方出現了問題", - "piped_instance": "Piped 伺服器實例", - "piped_description": "Piped 伺服器實例用於匹配歌曲", - "piped_warning": "它們之中的一部分可能無法正常運作。使用時請自行承擔風險", - "generate_playlist": "產生歌單", - "track_exists": "曲目 {track} 已存在", - "replace_downloaded_tracks": "替換已下載的歌曲", - "skip_download_tracks": "下載時跳過已下載的歌曲", - "do_you_want_to_replace": "你確定要取代已下載的歌曲嗎??", - "replace": "取代", - "skip": "跳過", - "select_up_to_count_type": "選擇最多 {count} 種的類型 {type}", - "select_genres": "選擇曲風", - "add_genres": "新增曲風", - "country": "國家和地區", - "number_of_tracks_generate": "產生歌曲的數目", - "acousticness": "原聲程度", - "danceability": "律動感", - "energy": "衝擊感", - "instrumentalness": "歌唱部分佔比", - "liveness": "現場感", - "loudness": "響度", - "speechiness": "朗誦比例", - "valence": "心理感受", - "popularity": "流行度", - "key": "曲調", - "duration": "歌曲長度 (s)", - "tempo": "每分鐘拍數 (BPM)", - "mode": "旋律重複度", - "time_signature": "音符時值", - "short": "短", - "medium": "中", - "long": "長", - "min": "最低", - "max": "最高", - "target": "目標", - "moderate": "中", - "deselect_all": "取消全選", - "select_all": "全選", - "are_you_sure": "你確定嗎?", - "generating_playlist": "正在產生你的自訂歌單...", - "selected_count_tracks": "已選取 {count} 首歌曲", - "download_warning": "如果你大量下載這些歌曲,你顯然在侵犯音樂的版權並對音樂創作社區造成了傷害。我希望你能意識到這一點。永遠要尊重並支持藝術家們的辛勤工作", - "download_ip_ban_warning": "小心,如果出現超出正常的下載請求,那你的 IP 可能會被 YouTube 封鎖,這意味著你的裝置將在長達 2-3 個月的時間內無法使用該 IP 訪問 YouTube(即使你沒登入)。Spotube 不會因而承擔任何責任", - "by_clicking_accept_terms": "點擊 '同意' 代表你同意以下的條款", - "download_agreement_1": "我明白侵害音樂版權是一件不好的事", - "download_agreement_2": "我將盡可能支持藝術家的工作。我現在之所以做不到是因為缺乏資金來購買正版", - "download_agreement_3": "我完全了解我的 IP 存在被 YouTube 封鎖的風險。並且我明白 Spotube 的擁有者與貢獻者們無須對我目前的行為所導致的任何後果負責", - "decline": "拒絕", - "accept": "同意", - "details": "詳細資訊", - "youtube": "YouTube", - "channel": "頻道", - "likes": "讚", - "dislikes": "倒讚", - "views": "瀏覽次數", - "streamUrl": "播放串流 URL", - "stop": "停止", - "sort_newest": "依新增日期順序", - "sort_oldest": "依新增日期倒序", - "sleep_timer": "睡眠計時器", - "mins": "{minutes} 分", - "hours": "{hours} 時", - "hour": "{hours} 時", - "custom_hours": "自訂時長", - "logs": "記錄檔(Log)", - "developers": "開發者", - "not_logged_in": "你尚未登入", - "search_mode": "搜尋模式", - "audio_source": "音訊來源", - "ok": "確定", - "failed_to_encrypt": "加密失敗", - "encryption_failed_warning": "Spotube使用加密來安全地儲存您的資料。但是失敗了。因此,它將回退到不安全的儲存空間\n如果您使用Linux,請確保已安裝gnome-keyring、kde-wallet和keepassxc等加密服務", - "querying_info": "正在查詢資訊...", - "piped_api_down": "Piped API 無法使用", - "piped_down_error_instructions": "當前Piped實例 {pipedInstance} 不可用\n\n請更改實例或將'API類型'更改為官方YouTube API\n\n更改後請確保重新啟動應用程式", - "you_are_offline": "您目前處於離線狀態", - "connection_restored": "您的網路連線已恢復", - "use_system_title_bar": "使用作業系統的預設視窗標題列", - "update_playlist": "更新播放清單", - "update": "更新", - "crunching_results": "處理結果中...", - "search_to_get_results": "搜尋以取得結果", - "use_amoled_mode": "使用 AMOLED 模式", - "pitch_dark_theme": "漆黑主題", - "normalize_audio": "標準化音訊", - "change_cover": "更改封面", - "add_cover": "新增封面", - "restore_defaults": "恢復預設值", - "download_music_codec": "下載音樂編解碼器", - "streaming_music_codec": "串流音樂編解碼器", - "login_with_lastfm": "使用 Last.fm 登入", - "connect": "連線", - "disconnect_lastfm": "切斷 Last.fm 連線", - "disconnect": "斷開連線", - "username": "帳號", - "password": "密碼", - "login": "登入", - "login_with_your_lastfm": "使用您的 Last.fm 帳號登入", - "scrobble_to_lastfm": "在 Last.fm 上記錄你的播放", - "go_to_album": "前往專輯", - "discord_rich_presence": "Discord Rick Presence(Discord 狀態)", - "browse_all": "瀏覽全部", - "genres": "音樂類型", - "explore_genres": "探索音樂類型", - "step_3_steps": "複製\"sp_dc\" Cookie的值", - "step_4_steps": "貼上複製的\"sp_dc\"值", - "friends": "好友", - "no_lyrics_available": "抱歉,無法找到這首歌的歌詞", - "sort_duration": "依長度排序", - "start_a_radio": "開始收聽電台", - "how_to_start_radio": "您想如何開始收聽電台?", - "replace_queue_question": "您想要取代目前清單還是追加到清單?", - "endless_playback": "無限播放", - "delete_playlist": "刪除播放清單", - "delete_playlist_confirmation": "您確定要刪除此播放清單嗎?", - "local_tracks": "本地音訊", - "song_link": "歌曲連結", - "skip_this_nonsense": "跳過這個無聊內容", - "freedom_of_music": "“音樂的自由”", - "freedom_of_music_palm": "「音樂的自由掌握在您手中」", - "get_started": "我們開始吧", - "youtube_source_description": "建議且效果最佳。", - "piped_source_description": "感覺自由?與 YouTube 一樣,但更自由。", - "jiosaavn_source_description": "最適合南亞地區。", - "highest_quality": "最高音質:{quality}", - "select_audio_source": "選擇音訊來源", - "endless_playback_description": "自動將新歌曲加入清單的結尾", - "choose_your_region": "選擇您的所在地區", - "choose_your_region_description": "這能幫助 Spotube 為您的所在位置顯示正確的內容。", - "choose_your_language": "選擇您的語言", - "help_project_grow": "幫助這個專案成長", - "help_project_grow_description": "Spotube是一個開源專案。您可以透過為專案做出貢獻、回報錯誤或建議新功能來幫助專案成長。", - "contribute_on_github": "在GitHub上做出貢獻", - "donate_on_open_collective": "在Open Collective上捐款", - "browse_anonymously": "匿名瀏覽", - "enable_connect": "啟用連線", - "enable_connect_description": "從其他裝置控制Spotube", - "devices": "裝置", - "select": "選擇", - "connect_client_alert": "您正在被 {client} 控制", - "this_device": "此裝置", - "remote": "遠端", - "local_library": "本地媒體庫", - "add_library_location": "新增至媒體庫", - "remove_library_location": "從媒體庫移除", - "local_tab": "本地", - "stats": "統計", - "and_n_more": "還有 {count} 個", - "recently_played": "最近播放", - "browse_more": "瀏覽更多", - "no_title": "無標題", - "not_playing": "未播放", - "epic_failure": "史詩級的失敗!", - "added_num_tracks_to_queue": "已將 {tracks_length} 首曲目新增至清單", - "spotube_has_an_update": "Spotube 有更新版本", - "download_now": "立即下載", - "nightly_version": "Spotube Nightly {nightlyBuildNum} 已發佈", - "release_version": "Spotube v{version} 已發布", - "read_the_latest": "閱讀最新", - "release_notes": "版本說明", - "pick_color_scheme": "選擇配色方案", - "save": "儲存", - "choose_the_device": "選擇裝置:", - "multiple_device_connected": "已連接多個裝置。\n選擇您希望執行此操作的裝置", - "nothing_found": "未找到任何內容", - "the_box_is_empty": "箱子為空", - "top_artists": "熱門藝人", - "top_albums": "熱門專輯", - "this_week": "本週", - "this_month": "本月", - "last_6_months": "過去6個月", - "this_year": "今年", - "last_2_years": "過去2年", - "all_time": "所有時間", - "powered_by_provider": "由 {providerName} 提供支援", - "email": "電子郵件", - "profile_followers": "追蹤者", - "birthday": "生日", - "subscription": "訂閱", - "not_born": "尚未建立", - "hacker": "駭客", - "profile": "個人資訊", - "no_name": "沒有名字", - "edit": "編輯", - "user_profile": "使用者資料", - "count_plays": "{count} 次播放", - "streaming_fees_hypothetical": "*基於 Spotify 每次播放的支付金額\n從 $0.003 到 $0.005 計算。這是一個假設性的\n計算,旨在讓用戶了解如果他們在 Spotify 上收聽\n這些歌曲,可能會付給作者的金額。", - "count_mins": "{minutes} 分鐘", - "summary_minutes": "分鐘", - "summary_listened_to_music": "聽音樂", - "summary_songs": "歌曲", - "summary_streamed_overall": "整體串流媒體", - "summary_owed_to_artists": "本月欠藝術家的", - "summary_artists": "藝術家的", - "summary_music_reached_you": "音樂接觸到你", - "summary_full_albums": "完整專輯", - "summary_got_your_love": "獲得了你的愛心", - "summary_playlists": "播放清單", - "summary_were_on_repeat": "已經重複播放", - "total_money": "總計 {money}", - "minutes_listened": "聽的分鐘數", - "streamed_songs": "已串流歌曲", - "count_streams": "{count} 次串流", - "owned_by_you": "由您所有", - "copied_shareurl_to_clipboard": "{shareUrl} 已複製到剪貼簿", - "spotify_hipotetical_calculation": "*根據 Spotify 每次串流媒體的支付金額\n$0.003 到 $0.005 進行計算。這是一個假設性的\n計算,用於給用戶了解他們如果在 Spotify 上\n收聽歌曲會支付給藝術家的金額。", - "webview_not_found": "未找到 Webview 框架", - "webview_not_found_description": "您的裝置中未安裝 Webview Runtime。\n如果已安裝,請確保它的位置在系統環境變數(PATH)中\n\n安裝後,重新啟動應用程式", - "unsupported_platform": "不支援的平台", - "invidious_instance": "Invidious 伺服器實例", - "invidious_description": "用於音軌匹配的 Invidious 伺服器實例", - "invidious_warning": "有些可能無法正常運作。請自行承擔風險", - "invidious_source_description": "類似 Piped,但可用性更高。", - "cache_music": "快取音樂", - "open": "開啟", - "cache_folder": "快取資料夾", - "export": "導出", - "clear_cache": "清除快取", - "clear_cache_confirmation": "您要清除快取嗎?", - "export_cache_files": "匯出快取檔案", - "found_n_files": "找到 {count} 個檔案", - "export_cache_confirmation": "您要匯出這些檔案到", - "exported_n_out_of_m_files": "匯出了 {filesExported} / {files} 個檔案", - "playlist": "播放清單", - "no_loop": "無循環", - "generate": "生成", - "undo": "取消", - "download_all": "下載全部", - "add_all_to_playlist": "全部加入到播放清單", - "add_all_to_queue": "全部加入清單", - "play_all_next": "播放全部下一首", - "pause": "暫停", - "view_all": "檢視全部", - "no_tracks_added_yet": "看起來你還沒有加入任何歌曲", - "no_tracks": "看起來這裡沒有任何歌曲", - "no_tracks_listened_yet": "看起來你還沒聽任何歌曲", - "not_following_artists": "你沒有關注任何藝術家", - "no_favorite_albums_yet": "看起來你還沒有將任何專輯加入到收藏夾", - "no_logs_found": "未找到日誌", - "youtube_engine": "YouTube 引擎", - "youtube_engine_not_installed_title": "{engine} 未安裝", - "youtube_engine_not_installed_message": "{engine} 未在您的系統中安裝。", - "youtube_engine_set_path": "確保它可用在 PATH 變數中,或\n設定 {engine} 執行檔的絕對路徑", - "youtube_engine_unix_issue_message": "在類 Unix 作業系統(如 macOS/Linux/Unix)中,請在 .zshrc/.bashrc/.bash_profile 等檔案中設定路徑無效。\n您需要在 shell 設定檔中設定路徑", - "download": "下載", - "file_not_found": "找不到檔案", - "custom": "自訂", - "add_custom_url": "新增自訂 URL", - "edit_port": "編輯端口", - "port_helper_msg": "預設值為 -1,表示隨機數。如果您已配置防火牆,建議設定此項目。", - "connect_request": "允許 {client} 連線嗎?", - "connection_request_denied": "連線被拒絕。請求被使用者拒絕。", - "hipotetical_calculation": "*此為根據線上音樂串流平台平均每次播放 $0.003 至 $0.005 的收益所計算的假設值。此為一個假設性計算,旨在讓使用者了解若他們在不同的音樂串流平台上收聽同一首歌曲,他們將會支付給藝人多少費用。", - "an_error_occurred": "發生錯誤", - "copy_to_clipboard": "複製到剪貼簿", - "view_logs": "檢視日誌", - "retry": "重試", - "no_default_metadata_provider_selected": "您沒有設定預設的中繼資料供應商", - "manage_metadata_providers": "管理中繼資料供應商", - "open_link_in_browser": "要在瀏覽器中開啟連結嗎?", - "do_you_want_to_open_the_following_link": "您想開啟以下連結嗎", - "unsafe_url_warning": "從不受信任的來源開啟連結可能不安全。請務必小心!\n您也可以將連結複製到剪貼簿。", - "copy_link": "複製連結", - "building_your_timeline": "正在根據您的收聽記錄建立您的時間軸...", - "official": "官方", - "author_name": "作者:{author}", - "third_party": "第三方", - "plugin_requires_authentication": "此外掛程式需要驗證", - "update_available": "有可用的更新", - "supports_scrobbling": "支援 Scrobbling", - "plugin_scrobbling_info": "此外掛程式會 Scrobble 您的音樂以產生您的收聽記錄。", - "default_plugin": "預設", - "set_default": "設為預設", - "support": "支援", - "support_plugin_development": "支援外掛程式開發", - "can_access_name_api": "- 可以存取 **{name}** API", - "do_you_want_to_install_this_plugin": "您想安裝此外掛程式嗎?", - "third_party_plugin_warning": "此外掛程式來自第三方儲存庫。請在安裝前確認您信任該來源。", - "author": "作者", - "this_plugin_can_do_following": "此外掛程式可以執行以下操作", - "install": "安裝", - "install_a_metadata_provider": "安裝中繼資料供應商", - "no_tracks_playing": "目前沒有正在播放的曲目", - "synced_lyrics_not_available": "此歌曲沒有同步歌詞。請改用", - "plain_lyrics": "純歌詞", - "tab_instead": "分頁。", - "disclaimer": "免責聲明", - "third_party_plugin_dmca_notice": "Spotube 團隊對任何「第三方」外掛程式不負任何責任(包括法律責任)。\n請自行承擔使用風險。如有任何錯誤/問題,請向該外掛程式的儲存庫回報。\n\n若有任何「第三方」外掛程式違反任何服務/法律實體的服務條款/DMCA,請向「第三方」外掛程式作者或託管平台(如 GitHub/Codeberg)要求採取行動。以上列出的(標記為「第三方」)外掛程式均為公開/社群維護的外掛程式。我們沒有對其進行審核,因此無法對其採取任何行動。\n\n", - "input_does_not_match_format": "輸入不符合所需格式", - "metadata_provider_plugins": "中繼資料供應商外掛程式", - "paste_plugin_download_url": "貼上下載網址、GitHub/Codeberg 儲存庫網址或 .smplug 檔案的直接連結", - "download_and_install_plugin_from_url": "從網址下載並安裝外掛程式", - "failed_to_add_plugin_error": "新增外掛程式失敗:{error}", - "upload_plugin_from_file": "從檔案上傳外掛程式", - "installed": "已安裝", - "available_plugins": "可用的外掛程式", - "configure_your_own_metadata_plugin": "設定您自己的播放清單/專輯/藝人/動態中繼資料供應商", - "audio_scrobblers": "音訊 Scrobblers", - "scrobbling": "Scrobbling", - "download_music_format": "下載音樂格式", - "streaming_music_format": "串流音樂格式", - "download_music_quality": "下載音樂品質", - "streaming_music_quality": "串流音樂品質", - "default_metadata_source": "預設中繼資料來源", - "set_default_metadata_source": "設定預設中繼資料來源", - "default_audio_source": "預設音訊來源", - "set_default_audio_source": "設定預設音訊來源", - "plugins": "外掛程式", - "configure_plugins": "配置您自己的中繼資料提供者和音訊來源外掛程式", - "source": "來源:", - "uncompressed": "未壓縮", - "dab_music_source_description": "適合音響發燒友。提供高品質/無損音訊串流。精確的 ISRC 曲目比對。" -} \ No newline at end of file diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart deleted file mode 100644 index e9d7913d..00000000 --- a/lib/l10n/generated/app_localizations.dart +++ /dev/null @@ -1,3109 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:intl/intl.dart' as intl; - -import 'app_localizations_ar.dart'; -import 'app_localizations_bn.dart'; -import 'app_localizations_ca.dart'; -import 'app_localizations_cs.dart'; -import 'app_localizations_de.dart'; -import 'app_localizations_en.dart'; -import 'app_localizations_es.dart'; -import 'app_localizations_eu.dart'; -import 'app_localizations_fa.dart'; -import 'app_localizations_fi.dart'; -import 'app_localizations_fr.dart'; -import 'app_localizations_hi.dart'; -import 'app_localizations_id.dart'; -import 'app_localizations_it.dart'; -import 'app_localizations_ja.dart'; -import 'app_localizations_ka.dart'; -import 'app_localizations_ko.dart'; -import 'app_localizations_ne.dart'; -import 'app_localizations_nl.dart'; -import 'app_localizations_pl.dart'; -import 'app_localizations_pt.dart'; -import 'app_localizations_ru.dart'; -import 'app_localizations_ta.dart'; -import 'app_localizations_th.dart'; -import 'app_localizations_tl.dart'; -import 'app_localizations_tr.dart'; -import 'app_localizations_uk.dart'; -import 'app_localizations_vi.dart'; -import 'app_localizations_zh.dart'; - -// ignore_for_file: type=lint - -/// Callers can lookup localized strings with an instance of AppLocalizations -/// returned by `AppLocalizations.of(context)`. -/// -/// Applications need to include `AppLocalizations.delegate()` in their app's -/// `localizationDelegates` list, and the locales they support in the app's -/// `supportedLocales` list. For example: -/// -/// ```dart -/// import 'generated/app_localizations.dart'; -/// -/// return MaterialApp( -/// localizationsDelegates: AppLocalizations.localizationsDelegates, -/// supportedLocales: AppLocalizations.supportedLocales, -/// home: MyApplicationHome(), -/// ); -/// ``` -/// -/// ## Update pubspec.yaml -/// -/// Please make sure to update your pubspec.yaml to include the following -/// packages: -/// -/// ```yaml -/// dependencies: -/// # Internationalization support. -/// flutter_localizations: -/// sdk: flutter -/// intl: any # Use the pinned version from flutter_localizations -/// -/// # Rest of dependencies -/// ``` -/// -/// ## iOS Applications -/// -/// iOS applications define key application metadata, including supported -/// locales, in an Info.plist file that is built into the application bundle. -/// To configure the locales supported by your app, you’ll need to edit this -/// file. -/// -/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. -/// Then, in the Project Navigator, open the Info.plist file under the Runner -/// project’s Runner folder. -/// -/// Next, select the Information Property List item, select Add Item from the -/// Editor menu, then select Localizations from the pop-up menu. -/// -/// Select and expand the newly-created Localizations item then, for each -/// locale your application supports, add a new item and select the locale -/// you wish to add from the pop-up menu in the Value field. This list should -/// be consistent with the languages listed in the AppLocalizations.supportedLocales -/// property. -abstract class AppLocalizations { - AppLocalizations(String locale) - : localeName = intl.Intl.canonicalizedLocale(locale.toString()); - - final String localeName; - - static AppLocalizations? of(BuildContext context) { - return Localizations.of(context, AppLocalizations); - } - - static const LocalizationsDelegate delegate = - _AppLocalizationsDelegate(); - - /// A list of this localizations delegate along with the default localizations - /// delegates. - /// - /// Returns a list of localizations delegates containing this delegate along with - /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, - /// and GlobalWidgetsLocalizations.delegate. - /// - /// Additional delegates can be added by appending to this list in - /// MaterialApp. This list does not have to be used at all if a custom list - /// of delegates is preferred or required. - static const List> localizationsDelegates = - >[ - delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ]; - - /// A list of this localizations delegate's supported locales. - static const List supportedLocales = [ - Locale('ar'), - Locale('bn'), - Locale('ca'), - Locale('cs'), - Locale('de'), - Locale('en'), - Locale('es'), - Locale('eu'), - Locale('fa'), - Locale('fi'), - Locale('fr'), - Locale('hi'), - Locale('id'), - Locale('it'), - Locale('ja'), - Locale('ka'), - Locale('ko'), - Locale('ne'), - Locale('nl'), - Locale('pl'), - Locale('pt'), - Locale('ru'), - Locale('ta'), - Locale('th'), - Locale('tl'), - Locale('tr'), - Locale('uk'), - Locale('vi'), - Locale('zh'), - Locale('zh', 'TW') - ]; - - /// No description provided for @guest. - /// - /// In en, this message translates to: - /// **'Guest'** - String get guest; - - /// No description provided for @browse. - /// - /// In en, this message translates to: - /// **'Browse'** - String get browse; - - /// No description provided for @search. - /// - /// In en, this message translates to: - /// **'Search'** - String get search; - - /// No description provided for @library. - /// - /// In en, this message translates to: - /// **'Library'** - String get library; - - /// No description provided for @lyrics. - /// - /// In en, this message translates to: - /// **'Lyrics'** - String get lyrics; - - /// No description provided for @settings. - /// - /// In en, this message translates to: - /// **'Settings'** - String get settings; - - /// No description provided for @genre_categories_filter. - /// - /// In en, this message translates to: - /// **'Filter categories or genres...'** - String get genre_categories_filter; - - /// No description provided for @genre. - /// - /// In en, this message translates to: - /// **'Genre'** - String get genre; - - /// No description provided for @personalized. - /// - /// In en, this message translates to: - /// **'Personalized'** - String get personalized; - - /// No description provided for @featured. - /// - /// In en, this message translates to: - /// **'Featured'** - String get featured; - - /// No description provided for @new_releases. - /// - /// In en, this message translates to: - /// **'New Releases'** - String get new_releases; - - /// No description provided for @songs. - /// - /// In en, this message translates to: - /// **'Songs'** - String get songs; - - /// No description provided for @playing_track. - /// - /// In en, this message translates to: - /// **'Playing {track}'** - String playing_track(Object track); - - /// No description provided for @queue_clear_alert. - /// - /// In en, this message translates to: - /// **'This will clear the current queue. {track_length} tracks will be removed\nDo you want to continue?'** - String queue_clear_alert(Object track_length); - - /// No description provided for @load_more. - /// - /// In en, this message translates to: - /// **'Load more'** - String get load_more; - - /// No description provided for @playlists. - /// - /// In en, this message translates to: - /// **'Playlists'** - String get playlists; - - /// No description provided for @artists. - /// - /// In en, this message translates to: - /// **'Artists'** - String get artists; - - /// No description provided for @albums. - /// - /// In en, this message translates to: - /// **'Albums'** - String get albums; - - /// No description provided for @tracks. - /// - /// In en, this message translates to: - /// **'Tracks'** - String get tracks; - - /// No description provided for @downloads. - /// - /// In en, this message translates to: - /// **'Downloads'** - String get downloads; - - /// No description provided for @filter_playlists. - /// - /// In en, this message translates to: - /// **'Filter your playlists...'** - String get filter_playlists; - - /// No description provided for @liked_tracks. - /// - /// In en, this message translates to: - /// **'Liked Tracks'** - String get liked_tracks; - - /// No description provided for @liked_tracks_description. - /// - /// In en, this message translates to: - /// **'All your liked tracks'** - String get liked_tracks_description; - - /// No description provided for @playlist. - /// - /// In en, this message translates to: - /// **'Playlist'** - String get playlist; - - /// No description provided for @create_a_playlist. - /// - /// In en, this message translates to: - /// **'Create a playlist'** - String get create_a_playlist; - - /// No description provided for @update_playlist. - /// - /// In en, this message translates to: - /// **'Update playlist'** - String get update_playlist; - - /// No description provided for @create. - /// - /// In en, this message translates to: - /// **'Create'** - String get create; - - /// No description provided for @cancel. - /// - /// In en, this message translates to: - /// **'Cancel'** - String get cancel; - - /// No description provided for @update. - /// - /// In en, this message translates to: - /// **'Update'** - String get update; - - /// No description provided for @playlist_name. - /// - /// In en, this message translates to: - /// **'Playlist Name'** - String get playlist_name; - - /// No description provided for @name_of_playlist. - /// - /// In en, this message translates to: - /// **'Name of the playlist'** - String get name_of_playlist; - - /// No description provided for @description. - /// - /// In en, this message translates to: - /// **'Description'** - String get description; - - /// No description provided for @public. - /// - /// In en, this message translates to: - /// **'Public'** - String get public; - - /// No description provided for @collaborative. - /// - /// In en, this message translates to: - /// **'Collaborative'** - String get collaborative; - - /// No description provided for @search_local_tracks. - /// - /// In en, this message translates to: - /// **'Search local tracks...'** - String get search_local_tracks; - - /// No description provided for @play. - /// - /// In en, this message translates to: - /// **'Play'** - String get play; - - /// No description provided for @delete. - /// - /// In en, this message translates to: - /// **'Delete'** - String get delete; - - /// No description provided for @none. - /// - /// In en, this message translates to: - /// **'None'** - String get none; - - /// No description provided for @sort_a_z. - /// - /// In en, this message translates to: - /// **'Sort by A-Z'** - String get sort_a_z; - - /// No description provided for @sort_z_a. - /// - /// In en, this message translates to: - /// **'Sort by Z-A'** - String get sort_z_a; - - /// No description provided for @sort_artist. - /// - /// In en, this message translates to: - /// **'Sort by Artist'** - String get sort_artist; - - /// No description provided for @sort_album. - /// - /// In en, this message translates to: - /// **'Sort by Album'** - String get sort_album; - - /// No description provided for @sort_duration. - /// - /// In en, this message translates to: - /// **'Sort by Duration'** - String get sort_duration; - - /// No description provided for @sort_tracks. - /// - /// In en, this message translates to: - /// **'Sort Tracks'** - String get sort_tracks; - - /// No description provided for @currently_downloading. - /// - /// In en, this message translates to: - /// **'Currently Downloading ({tracks_length})'** - String currently_downloading(Object tracks_length); - - /// No description provided for @cancel_all. - /// - /// In en, this message translates to: - /// **'Cancel All'** - String get cancel_all; - - /// No description provided for @filter_artist. - /// - /// In en, this message translates to: - /// **'Filter artists...'** - String get filter_artist; - - /// No description provided for @followers. - /// - /// In en, this message translates to: - /// **'{followers} Followers'** - String followers(Object followers); - - /// No description provided for @add_artist_to_blacklist. - /// - /// In en, this message translates to: - /// **'Add artist to blacklist'** - String get add_artist_to_blacklist; - - /// No description provided for @top_tracks. - /// - /// In en, this message translates to: - /// **'Top Tracks'** - String get top_tracks; - - /// No description provided for @fans_also_like. - /// - /// In en, this message translates to: - /// **'Fans also like'** - String get fans_also_like; - - /// No description provided for @loading. - /// - /// In en, this message translates to: - /// **'Loading...'** - String get loading; - - /// No description provided for @artist. - /// - /// In en, this message translates to: - /// **'Artist'** - String get artist; - - /// No description provided for @blacklisted. - /// - /// In en, this message translates to: - /// **'Blacklisted'** - String get blacklisted; - - /// No description provided for @following. - /// - /// In en, this message translates to: - /// **'Following'** - String get following; - - /// No description provided for @follow. - /// - /// In en, this message translates to: - /// **'Follow'** - String get follow; - - /// No description provided for @artist_url_copied. - /// - /// In en, this message translates to: - /// **'Artist URL copied to clipboard'** - String get artist_url_copied; - - /// No description provided for @added_to_queue. - /// - /// In en, this message translates to: - /// **'Added {tracks} tracks to queue'** - String added_to_queue(Object tracks); - - /// No description provided for @filter_albums. - /// - /// In en, this message translates to: - /// **'Filter albums...'** - String get filter_albums; - - /// No description provided for @synced. - /// - /// In en, this message translates to: - /// **'Synced'** - String get synced; - - /// No description provided for @plain. - /// - /// In en, this message translates to: - /// **'Plain'** - String get plain; - - /// No description provided for @shuffle. - /// - /// In en, this message translates to: - /// **'Shuffle'** - String get shuffle; - - /// No description provided for @search_tracks. - /// - /// In en, this message translates to: - /// **'Search tracks...'** - String get search_tracks; - - /// No description provided for @released. - /// - /// In en, this message translates to: - /// **'Released'** - String get released; - - /// No description provided for @error. - /// - /// In en, this message translates to: - /// **'Error {error}'** - String error(Object error); - - /// No description provided for @title. - /// - /// In en, this message translates to: - /// **'Title'** - String get title; - - /// No description provided for @time. - /// - /// In en, this message translates to: - /// **'Time'** - String get time; - - /// No description provided for @more_actions. - /// - /// In en, this message translates to: - /// **'More actions'** - String get more_actions; - - /// No description provided for @download_count. - /// - /// In en, this message translates to: - /// **'Download ({count})'** - String download_count(Object count); - - /// No description provided for @add_count_to_playlist. - /// - /// In en, this message translates to: - /// **'Add ({count}) to Playlist'** - String add_count_to_playlist(Object count); - - /// No description provided for @add_count_to_queue. - /// - /// In en, this message translates to: - /// **'Add ({count}) to Queue'** - String add_count_to_queue(Object count); - - /// No description provided for @play_count_next. - /// - /// In en, this message translates to: - /// **'Play ({count}) next'** - String play_count_next(Object count); - - /// No description provided for @album. - /// - /// In en, this message translates to: - /// **'Album'** - String get album; - - /// No description provided for @copied_to_clipboard. - /// - /// In en, this message translates to: - /// **'Copied {data} to clipboard'** - String copied_to_clipboard(Object data); - - /// No description provided for @add_to_following_playlists. - /// - /// In en, this message translates to: - /// **'Add {track} to following Playlists'** - String add_to_following_playlists(Object track); - - /// No description provided for @add. - /// - /// In en, this message translates to: - /// **'Add'** - String get add; - - /// No description provided for @added_track_to_queue. - /// - /// In en, this message translates to: - /// **'Added {track} to queue'** - String added_track_to_queue(Object track); - - /// No description provided for @add_to_queue. - /// - /// In en, this message translates to: - /// **'Add to queue'** - String get add_to_queue; - - /// No description provided for @track_will_play_next. - /// - /// In en, this message translates to: - /// **'{track} will play next'** - String track_will_play_next(Object track); - - /// No description provided for @play_next. - /// - /// In en, this message translates to: - /// **'Play next'** - String get play_next; - - /// No description provided for @removed_track_from_queue. - /// - /// In en, this message translates to: - /// **'Removed {track} from queue'** - String removed_track_from_queue(Object track); - - /// No description provided for @remove_from_queue. - /// - /// In en, this message translates to: - /// **'Remove from queue'** - String get remove_from_queue; - - /// No description provided for @remove_from_favorites. - /// - /// In en, this message translates to: - /// **'Remove from favorites'** - String get remove_from_favorites; - - /// No description provided for @save_as_favorite. - /// - /// In en, this message translates to: - /// **'Save as favorite'** - String get save_as_favorite; - - /// No description provided for @add_to_playlist. - /// - /// In en, this message translates to: - /// **'Add to playlist'** - String get add_to_playlist; - - /// No description provided for @remove_from_playlist. - /// - /// In en, this message translates to: - /// **'Remove from playlist'** - String get remove_from_playlist; - - /// No description provided for @add_to_blacklist. - /// - /// In en, this message translates to: - /// **'Add to blacklist'** - String get add_to_blacklist; - - /// No description provided for @remove_from_blacklist. - /// - /// In en, this message translates to: - /// **'Remove from blacklist'** - String get remove_from_blacklist; - - /// No description provided for @share. - /// - /// In en, this message translates to: - /// **'Share'** - String get share; - - /// No description provided for @mini_player. - /// - /// In en, this message translates to: - /// **'Mini Player'** - String get mini_player; - - /// No description provided for @slide_to_seek. - /// - /// In en, this message translates to: - /// **'Slide to seek forward or backward'** - String get slide_to_seek; - - /// No description provided for @shuffle_playlist. - /// - /// In en, this message translates to: - /// **'Shuffle playlist'** - String get shuffle_playlist; - - /// No description provided for @unshuffle_playlist. - /// - /// In en, this message translates to: - /// **'Unshuffle playlist'** - String get unshuffle_playlist; - - /// No description provided for @previous_track. - /// - /// In en, this message translates to: - /// **'Previous track'** - String get previous_track; - - /// No description provided for @next_track. - /// - /// In en, this message translates to: - /// **'Next track'** - String get next_track; - - /// No description provided for @pause_playback. - /// - /// In en, this message translates to: - /// **'Pause Playback'** - String get pause_playback; - - /// No description provided for @resume_playback. - /// - /// In en, this message translates to: - /// **'Resume Playback'** - String get resume_playback; - - /// No description provided for @loop_track. - /// - /// In en, this message translates to: - /// **'Loop track'** - String get loop_track; - - /// No description provided for @no_loop. - /// - /// In en, this message translates to: - /// **'No loop'** - String get no_loop; - - /// No description provided for @repeat_playlist. - /// - /// In en, this message translates to: - /// **'Repeat playlist'** - String get repeat_playlist; - - /// No description provided for @queue. - /// - /// In en, this message translates to: - /// **'Queue'** - String get queue; - - /// No description provided for @alternative_track_sources. - /// - /// In en, this message translates to: - /// **'Alternative track sources'** - String get alternative_track_sources; - - /// No description provided for @download_track. - /// - /// In en, this message translates to: - /// **'Download track'** - String get download_track; - - /// No description provided for @tracks_in_queue. - /// - /// In en, this message translates to: - /// **'{tracks} tracks in queue'** - String tracks_in_queue(Object tracks); - - /// No description provided for @clear_all. - /// - /// In en, this message translates to: - /// **'Clear all'** - String get clear_all; - - /// No description provided for @show_hide_ui_on_hover. - /// - /// In en, this message translates to: - /// **'Show/Hide UI on hover'** - String get show_hide_ui_on_hover; - - /// No description provided for @always_on_top. - /// - /// In en, this message translates to: - /// **'Always on top'** - String get always_on_top; - - /// No description provided for @exit_mini_player. - /// - /// In en, this message translates to: - /// **'Exit Mini player'** - String get exit_mini_player; - - /// No description provided for @download_location. - /// - /// In en, this message translates to: - /// **'Download location'** - String get download_location; - - /// No description provided for @local_library. - /// - /// In en, this message translates to: - /// **'Local library'** - String get local_library; - - /// No description provided for @add_library_location. - /// - /// In en, this message translates to: - /// **'Add to library'** - String get add_library_location; - - /// No description provided for @remove_library_location. - /// - /// In en, this message translates to: - /// **'Remove from library'** - String get remove_library_location; - - /// No description provided for @account. - /// - /// In en, this message translates to: - /// **'Account'** - String get account; - - /// No description provided for @logout. - /// - /// In en, this message translates to: - /// **'Logout'** - String get logout; - - /// No description provided for @logout_of_this_account. - /// - /// In en, this message translates to: - /// **'Logout of this account'** - String get logout_of_this_account; - - /// No description provided for @language_region. - /// - /// In en, this message translates to: - /// **'Language & Region'** - String get language_region; - - /// No description provided for @language. - /// - /// In en, this message translates to: - /// **'Language'** - String get language; - - /// No description provided for @system_default. - /// - /// In en, this message translates to: - /// **'System Default'** - String get system_default; - - /// No description provided for @market_place_region. - /// - /// In en, this message translates to: - /// **'Marketplace Region'** - String get market_place_region; - - /// No description provided for @recommendation_country. - /// - /// In en, this message translates to: - /// **'Recommendation Country'** - String get recommendation_country; - - /// No description provided for @appearance. - /// - /// In en, this message translates to: - /// **'Appearance'** - String get appearance; - - /// No description provided for @layout_mode. - /// - /// In en, this message translates to: - /// **'Layout Mode'** - String get layout_mode; - - /// No description provided for @override_layout_settings. - /// - /// In en, this message translates to: - /// **'Override responsive layout mode settings'** - String get override_layout_settings; - - /// No description provided for @adaptive. - /// - /// In en, this message translates to: - /// **'Adaptive'** - String get adaptive; - - /// No description provided for @compact. - /// - /// In en, this message translates to: - /// **'Compact'** - String get compact; - - /// No description provided for @extended. - /// - /// In en, this message translates to: - /// **'Extended'** - String get extended; - - /// No description provided for @theme. - /// - /// In en, this message translates to: - /// **'Theme'** - String get theme; - - /// No description provided for @dark. - /// - /// In en, this message translates to: - /// **'Dark'** - String get dark; - - /// No description provided for @light. - /// - /// In en, this message translates to: - /// **'Light'** - String get light; - - /// No description provided for @system. - /// - /// In en, this message translates to: - /// **'System'** - String get system; - - /// No description provided for @accent_color. - /// - /// In en, this message translates to: - /// **'Accent Color'** - String get accent_color; - - /// No description provided for @sync_album_color. - /// - /// In en, this message translates to: - /// **'Sync album color'** - String get sync_album_color; - - /// No description provided for @sync_album_color_description. - /// - /// In en, this message translates to: - /// **'Uses the dominant color of the album art as the accent color'** - String get sync_album_color_description; - - /// No description provided for @playback. - /// - /// In en, this message translates to: - /// **'Playback'** - String get playback; - - /// No description provided for @audio_quality. - /// - /// In en, this message translates to: - /// **'Audio Quality'** - String get audio_quality; - - /// No description provided for @high. - /// - /// In en, this message translates to: - /// **'High'** - String get high; - - /// No description provided for @low. - /// - /// In en, this message translates to: - /// **'Low'** - String get low; - - /// No description provided for @pre_download_play. - /// - /// In en, this message translates to: - /// **'Pre-download and play'** - String get pre_download_play; - - /// No description provided for @pre_download_play_description. - /// - /// In en, this message translates to: - /// **'Instead of streaming audio, download bytes and play instead (Recommended for higher bandwidth users)'** - String get pre_download_play_description; - - /// No description provided for @skip_non_music. - /// - /// In en, this message translates to: - /// **'Skip non-music segments (SponsorBlock)'** - String get skip_non_music; - - /// No description provided for @blacklist_description. - /// - /// In en, this message translates to: - /// **'Blacklisted tracks and artists'** - String get blacklist_description; - - /// No description provided for @wait_for_download_to_finish. - /// - /// In en, this message translates to: - /// **'Please wait for the current download to finish'** - String get wait_for_download_to_finish; - - /// No description provided for @desktop. - /// - /// In en, this message translates to: - /// **'Desktop'** - String get desktop; - - /// No description provided for @close_behavior. - /// - /// In en, this message translates to: - /// **'Close Behavior'** - String get close_behavior; - - /// No description provided for @close. - /// - /// In en, this message translates to: - /// **'Close'** - String get close; - - /// No description provided for @minimize_to_tray. - /// - /// In en, this message translates to: - /// **'Minimize to tray'** - String get minimize_to_tray; - - /// No description provided for @show_tray_icon. - /// - /// In en, this message translates to: - /// **'Show System tray icon'** - String get show_tray_icon; - - /// No description provided for @about. - /// - /// In en, this message translates to: - /// **'About'** - String get about; - - /// No description provided for @u_love_spotube. - /// - /// In en, this message translates to: - /// **'We know you love Spotube'** - String get u_love_spotube; - - /// No description provided for @check_for_updates. - /// - /// In en, this message translates to: - /// **'Check for updates'** - String get check_for_updates; - - /// No description provided for @about_spotube. - /// - /// In en, this message translates to: - /// **'About Spotube'** - String get about_spotube; - - /// No description provided for @blacklist. - /// - /// In en, this message translates to: - /// **'Blacklist'** - String get blacklist; - - /// No description provided for @please_sponsor. - /// - /// In en, this message translates to: - /// **'Please Sponsor/Donate'** - String get please_sponsor; - - /// No description provided for @spotube_description. - /// - /// In en, this message translates to: - /// **'Open source extensible music streaming platform and app, based on BYOMM (Bring your own music metadata) concept'** - String get spotube_description; - - /// No description provided for @version. - /// - /// In en, this message translates to: - /// **'Version'** - String get version; - - /// No description provided for @build_number. - /// - /// In en, this message translates to: - /// **'Build Number'** - String get build_number; - - /// No description provided for @founder. - /// - /// In en, this message translates to: - /// **'Founder'** - String get founder; - - /// No description provided for @repository. - /// - /// In en, this message translates to: - /// **'Repository'** - String get repository; - - /// No description provided for @bug_issues. - /// - /// In en, this message translates to: - /// **'Bug+Issues'** - String get bug_issues; - - /// No description provided for @made_with. - /// - /// In en, this message translates to: - /// **'Made with ❤️ in Bangladesh🇧🇩'** - String get made_with; - - /// No description provided for @kingkor_roy_tirtho. - /// - /// In en, this message translates to: - /// **'Kingkor Roy Tirtho'** - String get kingkor_roy_tirtho; - - /// No description provided for @copyright. - /// - /// In en, this message translates to: - /// **'© 2021-{current_year} Kingkor Roy Tirtho'** - String copyright(Object current_year); - - /// No description provided for @license. - /// - /// In en, this message translates to: - /// **'License'** - String get license; - - /// No description provided for @credentials_will_not_be_shared_disclaimer. - /// - /// In en, this message translates to: - /// **'Don\'t worry, any of your credentials won\'t be collected or shared with anyone'** - String get credentials_will_not_be_shared_disclaimer; - - /// No description provided for @know_how_to_login. - /// - /// In en, this message translates to: - /// **'Don\'t know how to do this?'** - String get know_how_to_login; - - /// No description provided for @follow_step_by_step_guide. - /// - /// In en, this message translates to: - /// **'Follow along the Step by Step guide'** - String get follow_step_by_step_guide; - - /// No description provided for @cookie_name_cookie. - /// - /// In en, this message translates to: - /// **'{name} Cookie'** - String cookie_name_cookie(Object name); - - /// No description provided for @fill_in_all_fields. - /// - /// In en, this message translates to: - /// **'Please fill in all the fields'** - String get fill_in_all_fields; - - /// No description provided for @submit. - /// - /// In en, this message translates to: - /// **'Submit'** - String get submit; - - /// No description provided for @exit. - /// - /// In en, this message translates to: - /// **'Exit'** - String get exit; - - /// No description provided for @previous. - /// - /// In en, this message translates to: - /// **'Previous'** - String get previous; - - /// No description provided for @next. - /// - /// In en, this message translates to: - /// **'Next'** - String get next; - - /// No description provided for @done. - /// - /// In en, this message translates to: - /// **'Done'** - String get done; - - /// No description provided for @step_1. - /// - /// In en, this message translates to: - /// **'Step 1'** - String get step_1; - - /// No description provided for @first_go_to. - /// - /// In en, this message translates to: - /// **'First, Go to'** - String get first_go_to; - - /// No description provided for @something_went_wrong. - /// - /// In en, this message translates to: - /// **'Something went wrong'** - String get something_went_wrong; - - /// No description provided for @piped_instance. - /// - /// In en, this message translates to: - /// **'Piped Server Instance'** - String get piped_instance; - - /// No description provided for @piped_description. - /// - /// In en, this message translates to: - /// **'The Piped server instance to use for track matching'** - String get piped_description; - - /// No description provided for @piped_warning. - /// - /// In en, this message translates to: - /// **'Some of them might not work well. So use at your own risk'** - String get piped_warning; - - /// No description provided for @invidious_instance. - /// - /// In en, this message translates to: - /// **'Invidious Server Instance'** - String get invidious_instance; - - /// No description provided for @invidious_description. - /// - /// In en, this message translates to: - /// **'The Invidious server instance to use for track matching'** - String get invidious_description; - - /// No description provided for @invidious_warning. - /// - /// In en, this message translates to: - /// **'Some of them might not work well. So use at your own risk'** - String get invidious_warning; - - /// No description provided for @generate. - /// - /// In en, this message translates to: - /// **'Generate'** - String get generate; - - /// No description provided for @track_exists. - /// - /// In en, this message translates to: - /// **'Track {track} already exists'** - String track_exists(Object track); - - /// No description provided for @replace_downloaded_tracks. - /// - /// In en, this message translates to: - /// **'Replace all downloaded tracks'** - String get replace_downloaded_tracks; - - /// No description provided for @skip_download_tracks. - /// - /// In en, this message translates to: - /// **'Skip downloading all downloaded tracks'** - String get skip_download_tracks; - - /// No description provided for @do_you_want_to_replace. - /// - /// In en, this message translates to: - /// **'Do you want to replace the existing track??'** - String get do_you_want_to_replace; - - /// No description provided for @replace. - /// - /// In en, this message translates to: - /// **'Replace'** - String get replace; - - /// No description provided for @skip. - /// - /// In en, this message translates to: - /// **'Skip'** - String get skip; - - /// No description provided for @select_up_to_count_type. - /// - /// In en, this message translates to: - /// **'Select up to {count} {type}'** - String select_up_to_count_type(Object count, Object type); - - /// No description provided for @select_genres. - /// - /// In en, this message translates to: - /// **'Select Genres'** - String get select_genres; - - /// No description provided for @add_genres. - /// - /// In en, this message translates to: - /// **'Add Genres'** - String get add_genres; - - /// No description provided for @country. - /// - /// In en, this message translates to: - /// **'Country'** - String get country; - - /// No description provided for @number_of_tracks_generate. - /// - /// In en, this message translates to: - /// **'Number of tracks to generate'** - String get number_of_tracks_generate; - - /// No description provided for @acousticness. - /// - /// In en, this message translates to: - /// **'Acousticness'** - String get acousticness; - - /// No description provided for @danceability. - /// - /// In en, this message translates to: - /// **'Danceability'** - String get danceability; - - /// No description provided for @energy. - /// - /// In en, this message translates to: - /// **'Energy'** - String get energy; - - /// No description provided for @instrumentalness. - /// - /// In en, this message translates to: - /// **'Instrumentalness'** - String get instrumentalness; - - /// No description provided for @liveness. - /// - /// In en, this message translates to: - /// **'Liveness'** - String get liveness; - - /// No description provided for @loudness. - /// - /// In en, this message translates to: - /// **'Loudness'** - String get loudness; - - /// No description provided for @speechiness. - /// - /// In en, this message translates to: - /// **'Speechiness'** - String get speechiness; - - /// No description provided for @valence. - /// - /// In en, this message translates to: - /// **'Valence'** - String get valence; - - /// No description provided for @popularity. - /// - /// In en, this message translates to: - /// **'Popularity'** - String get popularity; - - /// No description provided for @key. - /// - /// In en, this message translates to: - /// **'Key'** - String get key; - - /// No description provided for @duration. - /// - /// In en, this message translates to: - /// **'Duration (s)'** - String get duration; - - /// No description provided for @tempo. - /// - /// In en, this message translates to: - /// **'Tempo (BPM)'** - String get tempo; - - /// No description provided for @mode. - /// - /// In en, this message translates to: - /// **'Mode'** - String get mode; - - /// No description provided for @time_signature. - /// - /// In en, this message translates to: - /// **'Time Signature'** - String get time_signature; - - /// No description provided for @short. - /// - /// In en, this message translates to: - /// **'Short'** - String get short; - - /// No description provided for @medium. - /// - /// In en, this message translates to: - /// **'Medium'** - String get medium; - - /// No description provided for @long. - /// - /// In en, this message translates to: - /// **'Long'** - String get long; - - /// No description provided for @min. - /// - /// In en, this message translates to: - /// **'Min'** - String get min; - - /// No description provided for @max. - /// - /// In en, this message translates to: - /// **'Max'** - String get max; - - /// No description provided for @target. - /// - /// In en, this message translates to: - /// **'Target'** - String get target; - - /// No description provided for @moderate. - /// - /// In en, this message translates to: - /// **'Moderate'** - String get moderate; - - /// No description provided for @deselect_all. - /// - /// In en, this message translates to: - /// **'Deselect All'** - String get deselect_all; - - /// No description provided for @select_all. - /// - /// In en, this message translates to: - /// **'Select All'** - String get select_all; - - /// No description provided for @are_you_sure. - /// - /// In en, this message translates to: - /// **'Are you sure?'** - String get are_you_sure; - - /// No description provided for @generating_playlist. - /// - /// In en, this message translates to: - /// **'Generating your custom playlist...'** - String get generating_playlist; - - /// No description provided for @selected_count_tracks. - /// - /// In en, this message translates to: - /// **'Selected {count} tracks'** - String selected_count_tracks(Object count); - - /// No description provided for @download_warning. - /// - /// In en, this message translates to: - /// **'If you download all Tracks at bulk you\'re clearly pirating Music & causing damage to the creative society of Music. I hope you are aware of this. Always, try respecting & supporting Artist\'s hard work'** - String get download_warning; - - /// No description provided for @download_ip_ban_warning. - /// - /// In en, this message translates to: - /// **'BTW, your IP can get blocked on YouTube due excessive download requests than usual. IP block means you can\'t use YouTube (even if you\'re logged in) for at least 2-3 months from that IP device. And Spotube doesn\'t hold any responsibility if this ever happens'** - String get download_ip_ban_warning; - - /// No description provided for @by_clicking_accept_terms. - /// - /// In en, this message translates to: - /// **'By clicking \'accept\' you agree to following terms:'** - String get by_clicking_accept_terms; - - /// No description provided for @download_agreement_1. - /// - /// In en, this message translates to: - /// **'I know I\'m pirating Music. I\'m bad'** - String get download_agreement_1; - - /// No description provided for @download_agreement_2. - /// - /// In en, this message translates to: - /// **'I\'ll support the Artist wherever I can and I\'m only doing this because I don\'t have money to buy their art'** - String get download_agreement_2; - - /// No description provided for @download_agreement_3. - /// - /// In en, this message translates to: - /// **'I\'m completely aware that my IP can get blocked on YouTube & I don\'t hold Spotube or his owners/contributors responsible for any accidents caused by my current action'** - String get download_agreement_3; - - /// No description provided for @decline. - /// - /// In en, this message translates to: - /// **'Decline'** - String get decline; - - /// No description provided for @accept. - /// - /// In en, this message translates to: - /// **'Accept'** - String get accept; - - /// No description provided for @details. - /// - /// In en, this message translates to: - /// **'Details'** - String get details; - - /// No description provided for @youtube. - /// - /// In en, this message translates to: - /// **'YouTube'** - String get youtube; - - /// No description provided for @channel. - /// - /// In en, this message translates to: - /// **'Channel'** - String get channel; - - /// No description provided for @likes. - /// - /// In en, this message translates to: - /// **'Likes'** - String get likes; - - /// No description provided for @dislikes. - /// - /// In en, this message translates to: - /// **'Dislikes'** - String get dislikes; - - /// No description provided for @views. - /// - /// In en, this message translates to: - /// **'Views'** - String get views; - - /// No description provided for @streamUrl. - /// - /// In en, this message translates to: - /// **'Stream URL'** - String get streamUrl; - - /// No description provided for @stop. - /// - /// In en, this message translates to: - /// **'Stop'** - String get stop; - - /// No description provided for @sort_newest. - /// - /// In en, this message translates to: - /// **'Sort by newest added'** - String get sort_newest; - - /// No description provided for @sort_oldest. - /// - /// In en, this message translates to: - /// **'Sort by oldest added'** - String get sort_oldest; - - /// No description provided for @sleep_timer. - /// - /// In en, this message translates to: - /// **'Sleep Timer'** - String get sleep_timer; - - /// No description provided for @mins. - /// - /// In en, this message translates to: - /// **'{minutes} Minutes'** - String mins(Object minutes); - - /// No description provided for @hours. - /// - /// In en, this message translates to: - /// **'{hours} Hours'** - String hours(Object hours); - - /// No description provided for @hour. - /// - /// In en, this message translates to: - /// **'{hours} Hour'** - String hour(Object hours); - - /// No description provided for @custom_hours. - /// - /// In en, this message translates to: - /// **'Custom Hours'** - String get custom_hours; - - /// No description provided for @logs. - /// - /// In en, this message translates to: - /// **'Logs'** - String get logs; - - /// No description provided for @developers. - /// - /// In en, this message translates to: - /// **'Developers'** - String get developers; - - /// No description provided for @not_logged_in. - /// - /// In en, this message translates to: - /// **'You\'re not logged in'** - String get not_logged_in; - - /// No description provided for @search_mode. - /// - /// In en, this message translates to: - /// **'Search Mode'** - String get search_mode; - - /// No description provided for @audio_source. - /// - /// In en, this message translates to: - /// **'Audio Source'** - String get audio_source; - - /// No description provided for @ok. - /// - /// In en, this message translates to: - /// **'Ok'** - String get ok; - - /// No description provided for @failed_to_encrypt. - /// - /// In en, this message translates to: - /// **'Failed to encrypt'** - String get failed_to_encrypt; - - /// No description provided for @encryption_failed_warning. - /// - /// In en, this message translates to: - /// **'Spotube uses encryption to securely store your data. But failed to do so. So it\'ll fallback to insecure storage\nIf you\'re using linux, please make sure you\'ve any secret-service (gnome-keyring, kde-wallet, keepassxc etc) installed'** - String get encryption_failed_warning; - - /// No description provided for @querying_info. - /// - /// In en, this message translates to: - /// **'Querying info...'** - String get querying_info; - - /// No description provided for @piped_api_down. - /// - /// In en, this message translates to: - /// **'Piped API is down'** - String get piped_api_down; - - /// No description provided for @piped_down_error_instructions. - /// - /// In en, this message translates to: - /// **'The Piped instance {pipedInstance} is currently down\n\nEither change the instance or change the \'API type\' to official YouTube API\n\nMake sure to restart the app after change'** - String piped_down_error_instructions(Object pipedInstance); - - /// No description provided for @you_are_offline. - /// - /// In en, this message translates to: - /// **'You are currently offline'** - String get you_are_offline; - - /// No description provided for @connection_restored. - /// - /// In en, this message translates to: - /// **'Your internet connection was restored'** - String get connection_restored; - - /// No description provided for @use_system_title_bar. - /// - /// In en, this message translates to: - /// **'Use system title bar'** - String get use_system_title_bar; - - /// No description provided for @crunching_results. - /// - /// In en, this message translates to: - /// **'Crunching results...'** - String get crunching_results; - - /// No description provided for @search_to_get_results. - /// - /// In en, this message translates to: - /// **'Search to get results'** - String get search_to_get_results; - - /// No description provided for @use_amoled_mode. - /// - /// In en, this message translates to: - /// **'Pitch black dark theme'** - String get use_amoled_mode; - - /// No description provided for @pitch_dark_theme. - /// - /// In en, this message translates to: - /// **'AMOLED Mode'** - String get pitch_dark_theme; - - /// No description provided for @normalize_audio. - /// - /// In en, this message translates to: - /// **'Normalize audio'** - String get normalize_audio; - - /// No description provided for @change_cover. - /// - /// In en, this message translates to: - /// **'Change cover'** - String get change_cover; - - /// No description provided for @add_cover. - /// - /// In en, this message translates to: - /// **'Add cover'** - String get add_cover; - - /// No description provided for @restore_defaults. - /// - /// In en, this message translates to: - /// **'Restore defaults'** - String get restore_defaults; - - /// No description provided for @download_music_format. - /// - /// In en, this message translates to: - /// **'Download music format'** - String get download_music_format; - - /// No description provided for @streaming_music_format. - /// - /// In en, this message translates to: - /// **'Streaming music format'** - String get streaming_music_format; - - /// No description provided for @download_music_quality. - /// - /// In en, this message translates to: - /// **'Download music quality'** - String get download_music_quality; - - /// No description provided for @streaming_music_quality. - /// - /// In en, this message translates to: - /// **'Streaming music quality'** - String get streaming_music_quality; - - /// No description provided for @login_with_lastfm. - /// - /// In en, this message translates to: - /// **'Login with Last.fm'** - String get login_with_lastfm; - - /// No description provided for @connect. - /// - /// In en, this message translates to: - /// **'Connect'** - String get connect; - - /// No description provided for @disconnect_lastfm. - /// - /// In en, this message translates to: - /// **'Disconnect Last.fm'** - String get disconnect_lastfm; - - /// No description provided for @disconnect. - /// - /// In en, this message translates to: - /// **'Disconnect'** - String get disconnect; - - /// No description provided for @username. - /// - /// In en, this message translates to: - /// **'Username'** - String get username; - - /// No description provided for @password. - /// - /// In en, this message translates to: - /// **'Password'** - String get password; - - /// No description provided for @login. - /// - /// In en, this message translates to: - /// **'Login'** - String get login; - - /// No description provided for @login_with_your_lastfm. - /// - /// In en, this message translates to: - /// **'Login with your Last.fm account'** - String get login_with_your_lastfm; - - /// No description provided for @scrobble_to_lastfm. - /// - /// In en, this message translates to: - /// **'Scrobble to Last.fm'** - String get scrobble_to_lastfm; - - /// No description provided for @go_to_album. - /// - /// In en, this message translates to: - /// **'Go to Album'** - String get go_to_album; - - /// No description provided for @discord_rich_presence. - /// - /// In en, this message translates to: - /// **'Discord Rich Presence'** - String get discord_rich_presence; - - /// No description provided for @browse_all. - /// - /// In en, this message translates to: - /// **'Browse All'** - String get browse_all; - - /// No description provided for @genres. - /// - /// In en, this message translates to: - /// **'Genres'** - String get genres; - - /// No description provided for @explore_genres. - /// - /// In en, this message translates to: - /// **'Explore Genres'** - String get explore_genres; - - /// No description provided for @friends. - /// - /// In en, this message translates to: - /// **'Friends'** - String get friends; - - /// No description provided for @no_lyrics_available. - /// - /// In en, this message translates to: - /// **'Sorry, unable find lyrics for this track'** - String get no_lyrics_available; - - /// No description provided for @start_a_radio. - /// - /// In en, this message translates to: - /// **'Start a Radio'** - String get start_a_radio; - - /// No description provided for @how_to_start_radio. - /// - /// In en, this message translates to: - /// **'How do you want to start the radio?'** - String get how_to_start_radio; - - /// No description provided for @replace_queue_question. - /// - /// In en, this message translates to: - /// **'Do you want to replace the current queue or append to it?'** - String get replace_queue_question; - - /// No description provided for @endless_playback. - /// - /// In en, this message translates to: - /// **'Endless Playback'** - String get endless_playback; - - /// No description provided for @delete_playlist. - /// - /// In en, this message translates to: - /// **'Delete Playlist'** - String get delete_playlist; - - /// No description provided for @delete_playlist_confirmation. - /// - /// In en, this message translates to: - /// **'Are you sure you want to delete this playlist?'** - String get delete_playlist_confirmation; - - /// No description provided for @local_tracks. - /// - /// In en, this message translates to: - /// **'Local Tracks'** - String get local_tracks; - - /// No description provided for @local_tab. - /// - /// In en, this message translates to: - /// **'Local'** - String get local_tab; - - /// No description provided for @song_link. - /// - /// In en, this message translates to: - /// **'Song Link'** - String get song_link; - - /// No description provided for @skip_this_nonsense. - /// - /// In en, this message translates to: - /// **'Skip this nonsense'** - String get skip_this_nonsense; - - /// No description provided for @freedom_of_music. - /// - /// In en, this message translates to: - /// **'“Freedom of Music”'** - String get freedom_of_music; - - /// No description provided for @freedom_of_music_palm. - /// - /// In en, this message translates to: - /// **'“Freedom of Music in the palm of your hand”'** - String get freedom_of_music_palm; - - /// No description provided for @get_started. - /// - /// In en, this message translates to: - /// **'Let\'s get started'** - String get get_started; - - /// No description provided for @youtube_source_description. - /// - /// In en, this message translates to: - /// **'Recommended and works best.'** - String get youtube_source_description; - - /// No description provided for @piped_source_description. - /// - /// In en, this message translates to: - /// **'Feeling free? Same as YouTube but a lot free.'** - String get piped_source_description; - - /// No description provided for @jiosaavn_source_description. - /// - /// In en, this message translates to: - /// **'Best for South Asian region.'** - String get jiosaavn_source_description; - - /// No description provided for @invidious_source_description. - /// - /// In en, this message translates to: - /// **'Similar to Piped but with higher availability.'** - String get invidious_source_description; - - /// No description provided for @highest_quality. - /// - /// In en, this message translates to: - /// **'Highest Quality: {quality}'** - String highest_quality(Object quality); - - /// No description provided for @select_audio_source. - /// - /// In en, this message translates to: - /// **'Select Audio Source'** - String get select_audio_source; - - /// No description provided for @endless_playback_description. - /// - /// In en, this message translates to: - /// **'Automatically append new songs\nto the end of the queue'** - String get endless_playback_description; - - /// No description provided for @choose_your_region. - /// - /// In en, this message translates to: - /// **'Choose your region'** - String get choose_your_region; - - /// No description provided for @choose_your_region_description. - /// - /// In en, this message translates to: - /// **'This will help Spotube show you the right content\nfor your location.'** - String get choose_your_region_description; - - /// No description provided for @choose_your_language. - /// - /// In en, this message translates to: - /// **'Choose your language'** - String get choose_your_language; - - /// No description provided for @help_project_grow. - /// - /// In en, this message translates to: - /// **'Help this project grow'** - String get help_project_grow; - - /// No description provided for @help_project_grow_description. - /// - /// In en, this message translates to: - /// **'Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.'** - String get help_project_grow_description; - - /// No description provided for @contribute_on_github. - /// - /// In en, this message translates to: - /// **'Contribute on GitHub'** - String get contribute_on_github; - - /// No description provided for @donate_on_open_collective. - /// - /// In en, this message translates to: - /// **'Donate on Open Collective'** - String get donate_on_open_collective; - - /// No description provided for @browse_anonymously. - /// - /// In en, this message translates to: - /// **'Browse Anonymously'** - String get browse_anonymously; - - /// No description provided for @enable_connect. - /// - /// In en, this message translates to: - /// **'Enable Connect'** - String get enable_connect; - - /// No description provided for @enable_connect_description. - /// - /// In en, this message translates to: - /// **'Control Spotube from other devices'** - String get enable_connect_description; - - /// No description provided for @devices. - /// - /// In en, this message translates to: - /// **'Devices'** - String get devices; - - /// No description provided for @select. - /// - /// In en, this message translates to: - /// **'Select'** - String get select; - - /// No description provided for @connect_client_alert. - /// - /// In en, this message translates to: - /// **'You\'re being controlled by {client}'** - String connect_client_alert(Object client); - - /// No description provided for @this_device. - /// - /// In en, this message translates to: - /// **'This Device'** - String get this_device; - - /// No description provided for @remote. - /// - /// In en, this message translates to: - /// **'Remote'** - String get remote; - - /// No description provided for @stats. - /// - /// In en, this message translates to: - /// **'Stats'** - String get stats; - - /// No description provided for @and_n_more. - /// - /// In en, this message translates to: - /// **'and {count} more'** - String and_n_more(Object count); - - /// No description provided for @recently_played. - /// - /// In en, this message translates to: - /// **'Recently Played'** - String get recently_played; - - /// No description provided for @browse_more. - /// - /// In en, this message translates to: - /// **'Browse More'** - String get browse_more; - - /// No description provided for @no_title. - /// - /// In en, this message translates to: - /// **'No Title'** - String get no_title; - - /// No description provided for @not_playing. - /// - /// In en, this message translates to: - /// **'Not playing'** - String get not_playing; - - /// No description provided for @epic_failure. - /// - /// In en, this message translates to: - /// **'Epic failure!'** - String get epic_failure; - - /// No description provided for @added_num_tracks_to_queue. - /// - /// In en, this message translates to: - /// **'Added {tracks_length} tracks to queue'** - String added_num_tracks_to_queue(Object tracks_length); - - /// No description provided for @spotube_has_an_update. - /// - /// In en, this message translates to: - /// **'Spotube has an update'** - String get spotube_has_an_update; - - /// No description provided for @download_now. - /// - /// In en, this message translates to: - /// **'Download Now'** - String get download_now; - - /// No description provided for @nightly_version. - /// - /// In en, this message translates to: - /// **'Spotube Nightly {nightlyBuildNum} has been released'** - String nightly_version(Object nightlyBuildNum); - - /// No description provided for @release_version. - /// - /// In en, this message translates to: - /// **'Spotube v{version} has been released'** - String release_version(Object version); - - /// No description provided for @read_the_latest. - /// - /// In en, this message translates to: - /// **'Read the latest '** - String get read_the_latest; - - /// No description provided for @release_notes. - /// - /// In en, this message translates to: - /// **'release notes'** - String get release_notes; - - /// No description provided for @pick_color_scheme. - /// - /// In en, this message translates to: - /// **'Pick color scheme'** - String get pick_color_scheme; - - /// No description provided for @save. - /// - /// In en, this message translates to: - /// **'Save'** - String get save; - - /// No description provided for @choose_the_device. - /// - /// In en, this message translates to: - /// **'Choose the device:'** - String get choose_the_device; - - /// No description provided for @multiple_device_connected. - /// - /// In en, this message translates to: - /// **'There are multiple device connected.\nChoose the device you want this action to take place'** - String get multiple_device_connected; - - /// No description provided for @nothing_found. - /// - /// In en, this message translates to: - /// **'Nothing found'** - String get nothing_found; - - /// No description provided for @the_box_is_empty. - /// - /// In en, this message translates to: - /// **'The box is empty'** - String get the_box_is_empty; - - /// No description provided for @top_artists. - /// - /// In en, this message translates to: - /// **'Top Artists'** - String get top_artists; - - /// No description provided for @top_albums. - /// - /// In en, this message translates to: - /// **'Top Albums'** - String get top_albums; - - /// No description provided for @this_week. - /// - /// In en, this message translates to: - /// **'This week'** - String get this_week; - - /// No description provided for @this_month. - /// - /// In en, this message translates to: - /// **'This month'** - String get this_month; - - /// No description provided for @last_6_months. - /// - /// In en, this message translates to: - /// **'Last 6 months'** - String get last_6_months; - - /// No description provided for @this_year. - /// - /// In en, this message translates to: - /// **'This year'** - String get this_year; - - /// No description provided for @last_2_years. - /// - /// In en, this message translates to: - /// **'Last 2 years'** - String get last_2_years; - - /// No description provided for @all_time. - /// - /// In en, this message translates to: - /// **'All time'** - String get all_time; - - /// No description provided for @powered_by_provider. - /// - /// In en, this message translates to: - /// **'Powered by {providerName}'** - String powered_by_provider(Object providerName); - - /// No description provided for @email. - /// - /// In en, this message translates to: - /// **'Email'** - String get email; - - /// No description provided for @profile_followers. - /// - /// In en, this message translates to: - /// **'Followers'** - String get profile_followers; - - /// No description provided for @birthday. - /// - /// In en, this message translates to: - /// **'Birthday'** - String get birthday; - - /// No description provided for @subscription. - /// - /// In en, this message translates to: - /// **'Subscription'** - String get subscription; - - /// No description provided for @not_born. - /// - /// In en, this message translates to: - /// **'Not born'** - String get not_born; - - /// No description provided for @hacker. - /// - /// In en, this message translates to: - /// **'Hacker'** - String get hacker; - - /// No description provided for @profile. - /// - /// In en, this message translates to: - /// **'Profile'** - String get profile; - - /// No description provided for @no_name. - /// - /// In en, this message translates to: - /// **'No Name'** - String get no_name; - - /// No description provided for @edit. - /// - /// In en, this message translates to: - /// **'Edit'** - String get edit; - - /// No description provided for @user_profile. - /// - /// In en, this message translates to: - /// **'User Profile'** - String get user_profile; - - /// No description provided for @count_plays. - /// - /// In en, this message translates to: - /// **'{count} plays'** - String count_plays(Object count); - - /// No description provided for @streaming_fees_hypothetical. - /// - /// In en, this message translates to: - /// **'Streaming fees (hypothetical)'** - String get streaming_fees_hypothetical; - - /// No description provided for @minutes_listened. - /// - /// In en, this message translates to: - /// **'Minutes listened'** - String get minutes_listened; - - /// No description provided for @streamed_songs. - /// - /// In en, this message translates to: - /// **'Streamed songs'** - String get streamed_songs; - - /// No description provided for @count_streams. - /// - /// In en, this message translates to: - /// **'{count} streams'** - String count_streams(Object count); - - /// No description provided for @owned_by_you. - /// - /// In en, this message translates to: - /// **'Owned by you'** - String get owned_by_you; - - /// No description provided for @copied_shareurl_to_clipboard. - /// - /// In en, this message translates to: - /// **'Copied {shareUrl} to clipboard'** - String copied_shareurl_to_clipboard(Object shareUrl); - - /// No description provided for @hipotetical_calculation. - /// - /// In en, this message translates to: - /// **'*This is calculated based on average online music streaming platform\'s per stream\npayout of \$0.003 to \$0.005. This is a hypothetical\ncalculation to give user insight about how much they\nwould have paid to the artists if they were to listen\ntheir song in different music streaming platform.'** - String get hipotetical_calculation; - - /// No description provided for @count_mins. - /// - /// In en, this message translates to: - /// **'{minutes} mins'** - String count_mins(Object minutes); - - /// No description provided for @summary_minutes. - /// - /// In en, this message translates to: - /// **'minutes'** - String get summary_minutes; - - /// No description provided for @summary_listened_to_music. - /// - /// In en, this message translates to: - /// **'Listened to music'** - String get summary_listened_to_music; - - /// No description provided for @summary_songs. - /// - /// In en, this message translates to: - /// **'songs'** - String get summary_songs; - - /// No description provided for @summary_streamed_overall. - /// - /// In en, this message translates to: - /// **'Streamed overall'** - String get summary_streamed_overall; - - /// No description provided for @summary_owed_to_artists. - /// - /// In en, this message translates to: - /// **'Owed to artists\nthis month'** - String get summary_owed_to_artists; - - /// No description provided for @summary_artists. - /// - /// In en, this message translates to: - /// **'artist\'s'** - String get summary_artists; - - /// No description provided for @summary_music_reached_you. - /// - /// In en, this message translates to: - /// **'Music reached you'** - String get summary_music_reached_you; - - /// No description provided for @summary_full_albums. - /// - /// In en, this message translates to: - /// **'full albums'** - String get summary_full_albums; - - /// No description provided for @summary_got_your_love. - /// - /// In en, this message translates to: - /// **'Got your love'** - String get summary_got_your_love; - - /// No description provided for @summary_playlists. - /// - /// In en, this message translates to: - /// **'playlists'** - String get summary_playlists; - - /// No description provided for @summary_were_on_repeat. - /// - /// In en, this message translates to: - /// **'Were on repeat'** - String get summary_were_on_repeat; - - /// No description provided for @total_money. - /// - /// In en, this message translates to: - /// **'Total {money}'** - String total_money(Object money); - - /// No description provided for @webview_not_found. - /// - /// In en, this message translates to: - /// **'Webview not found'** - String get webview_not_found; - - /// No description provided for @webview_not_found_description. - /// - /// In en, this message translates to: - /// **'No webview runtime is installed in your device.\nIf it\'s installed make sure it\'s in the Environment PATH\n\nAfter installing, restart the app'** - String get webview_not_found_description; - - /// No description provided for @unsupported_platform. - /// - /// In en, this message translates to: - /// **'Unsupported platform'** - String get unsupported_platform; - - /// No description provided for @cache_music. - /// - /// In en, this message translates to: - /// **'Cache music'** - String get cache_music; - - /// No description provided for @open. - /// - /// In en, this message translates to: - /// **'Open'** - String get open; - - /// No description provided for @cache_folder. - /// - /// In en, this message translates to: - /// **'Cache folder'** - String get cache_folder; - - /// No description provided for @export. - /// - /// In en, this message translates to: - /// **'Export'** - String get export; - - /// No description provided for @clear_cache. - /// - /// In en, this message translates to: - /// **'Clear cache'** - String get clear_cache; - - /// No description provided for @clear_cache_confirmation. - /// - /// In en, this message translates to: - /// **'Do you want to clear the cache?'** - String get clear_cache_confirmation; - - /// No description provided for @export_cache_files. - /// - /// In en, this message translates to: - /// **'Export Cached Files'** - String get export_cache_files; - - /// No description provided for @found_n_files. - /// - /// In en, this message translates to: - /// **'Found {count} files'** - String found_n_files(Object count); - - /// No description provided for @export_cache_confirmation. - /// - /// In en, this message translates to: - /// **'Do you want to export these files to'** - String get export_cache_confirmation; - - /// No description provided for @exported_n_out_of_m_files. - /// - /// In en, this message translates to: - /// **'Exported {filesExported} out of {files} files'** - String exported_n_out_of_m_files(Object files, Object filesExported); - - /// No description provided for @undo. - /// - /// In en, this message translates to: - /// **'Undo'** - String get undo; - - /// No description provided for @download_all. - /// - /// In en, this message translates to: - /// **'Download all'** - String get download_all; - - /// No description provided for @add_all_to_playlist. - /// - /// In en, this message translates to: - /// **'Add all to playlist'** - String get add_all_to_playlist; - - /// No description provided for @add_all_to_queue. - /// - /// In en, this message translates to: - /// **'Add all to queue'** - String get add_all_to_queue; - - /// No description provided for @play_all_next. - /// - /// In en, this message translates to: - /// **'Play all next'** - String get play_all_next; - - /// No description provided for @pause. - /// - /// In en, this message translates to: - /// **'Pause'** - String get pause; - - /// No description provided for @view_all. - /// - /// In en, this message translates to: - /// **'View all'** - String get view_all; - - /// No description provided for @no_tracks_added_yet. - /// - /// In en, this message translates to: - /// **'Looks like you haven\'t added any tracks yet'** - String get no_tracks_added_yet; - - /// No description provided for @no_tracks. - /// - /// In en, this message translates to: - /// **'Looks like there are no tracks here'** - String get no_tracks; - - /// No description provided for @no_tracks_listened_yet. - /// - /// In en, this message translates to: - /// **'Looks like you haven\'t listened to anything yet'** - String get no_tracks_listened_yet; - - /// No description provided for @not_following_artists. - /// - /// In en, this message translates to: - /// **'You\'re not following any artists'** - String get not_following_artists; - - /// No description provided for @no_favorite_albums_yet. - /// - /// In en, this message translates to: - /// **'Looks like you haven\'t added any albums to your favorites yet'** - String get no_favorite_albums_yet; - - /// No description provided for @no_logs_found. - /// - /// In en, this message translates to: - /// **'No logs found'** - String get no_logs_found; - - /// No description provided for @youtube_engine. - /// - /// In en, this message translates to: - /// **'YouTube Engine'** - String get youtube_engine; - - /// No description provided for @youtube_engine_not_installed_title. - /// - /// In en, this message translates to: - /// **'{engine} is not installed'** - String youtube_engine_not_installed_title(Object engine); - - /// No description provided for @youtube_engine_not_installed_message. - /// - /// In en, this message translates to: - /// **'{engine} is not installed in your system.'** - String youtube_engine_not_installed_message(Object engine); - - /// No description provided for @youtube_engine_set_path. - /// - /// In en, this message translates to: - /// **'Make sure it\'s available in the PATH variable or\nset the absolute path to the {engine} executable below'** - String youtube_engine_set_path(Object engine); - - /// No description provided for @youtube_engine_unix_issue_message. - /// - /// In en, this message translates to: - /// **'In macOS/Linux/unix like OS\'s, setting path on .zshrc/.bashrc/.bash_profile etc. won\'t work.\nYou need to set the path in the shell configuration file'** - String get youtube_engine_unix_issue_message; - - /// No description provided for @download. - /// - /// In en, this message translates to: - /// **'Download'** - String get download; - - /// No description provided for @file_not_found. - /// - /// In en, this message translates to: - /// **'File not found'** - String get file_not_found; - - /// No description provided for @custom. - /// - /// In en, this message translates to: - /// **'Custom'** - String get custom; - - /// No description provided for @add_custom_url. - /// - /// In en, this message translates to: - /// **'Add custom URL'** - String get add_custom_url; - - /// No description provided for @edit_port. - /// - /// In en, this message translates to: - /// **'Edit port'** - String get edit_port; - - /// No description provided for @port_helper_msg. - /// - /// In en, this message translates to: - /// **'Default is -1 which indicates random number. If you\'ve firewall configured, setting this is recommended.'** - String get port_helper_msg; - - /// No description provided for @connect_request. - /// - /// In en, this message translates to: - /// **'Allow {client} to connect?'** - String connect_request(Object client); - - /// No description provided for @connection_request_denied. - /// - /// In en, this message translates to: - /// **'Connection denied. User denied access.'** - String get connection_request_denied; - - /// No description provided for @an_error_occurred. - /// - /// In en, this message translates to: - /// **'An error occurred'** - String get an_error_occurred; - - /// No description provided for @copy_to_clipboard. - /// - /// In en, this message translates to: - /// **'Copy to clipboard'** - String get copy_to_clipboard; - - /// No description provided for @view_logs. - /// - /// In en, this message translates to: - /// **'View logs'** - String get view_logs; - - /// No description provided for @retry. - /// - /// In en, this message translates to: - /// **'Retry'** - String get retry; - - /// No description provided for @no_default_metadata_provider_selected. - /// - /// In en, this message translates to: - /// **'You\'ve no default metadata provider set'** - String get no_default_metadata_provider_selected; - - /// No description provided for @manage_metadata_providers. - /// - /// In en, this message translates to: - /// **'Manage metadata providers'** - String get manage_metadata_providers; - - /// No description provided for @open_link_in_browser. - /// - /// In en, this message translates to: - /// **'Open Link in Browser?'** - String get open_link_in_browser; - - /// No description provided for @do_you_want_to_open_the_following_link. - /// - /// In en, this message translates to: - /// **'Do you want to open the following link'** - String get do_you_want_to_open_the_following_link; - - /// No description provided for @unsafe_url_warning. - /// - /// In en, this message translates to: - /// **'It can be unsafe to open links from untrusted sources. Be cautious!\nYou can also copy the link to your clipboard.'** - String get unsafe_url_warning; - - /// No description provided for @copy_link. - /// - /// In en, this message translates to: - /// **'Copy Link'** - String get copy_link; - - /// No description provided for @building_your_timeline. - /// - /// In en, this message translates to: - /// **'Building your timeline based on your listenings...'** - String get building_your_timeline; - - /// No description provided for @official. - /// - /// In en, this message translates to: - /// **'Official'** - String get official; - - /// No description provided for @author_name. - /// - /// In en, this message translates to: - /// **'Author: {author}'** - String author_name(Object author); - - /// No description provided for @third_party. - /// - /// In en, this message translates to: - /// **'Third-party'** - String get third_party; - - /// No description provided for @plugin_requires_authentication. - /// - /// In en, this message translates to: - /// **'Plugin requires authentication'** - String get plugin_requires_authentication; - - /// No description provided for @update_available. - /// - /// In en, this message translates to: - /// **'Update available'** - String get update_available; - - /// No description provided for @supports_scrobbling. - /// - /// In en, this message translates to: - /// **'Supports scrobbling'** - String get supports_scrobbling; - - /// No description provided for @plugin_scrobbling_info. - /// - /// In en, this message translates to: - /// **'This plugin scrobbles your music to generate your listening history.'** - String get plugin_scrobbling_info; - - /// No description provided for @default_metadata_source. - /// - /// In en, this message translates to: - /// **'Default metadata source'** - String get default_metadata_source; - - /// No description provided for @set_default_metadata_source. - /// - /// In en, this message translates to: - /// **'Set default metadata source'** - String get set_default_metadata_source; - - /// No description provided for @default_audio_source. - /// - /// In en, this message translates to: - /// **'Default audio source'** - String get default_audio_source; - - /// No description provided for @set_default_audio_source. - /// - /// In en, this message translates to: - /// **'Set default audio source'** - String get set_default_audio_source; - - /// No description provided for @set_default. - /// - /// In en, this message translates to: - /// **'Set default'** - String get set_default; - - /// No description provided for @support. - /// - /// In en, this message translates to: - /// **'Support'** - String get support; - - /// No description provided for @support_plugin_development. - /// - /// In en, this message translates to: - /// **'Support plugin development'** - String get support_plugin_development; - - /// No description provided for @can_access_name_api. - /// - /// In en, this message translates to: - /// **'- Can access **{name}** API'** - String can_access_name_api(Object name); - - /// No description provided for @do_you_want_to_install_this_plugin. - /// - /// In en, this message translates to: - /// **'Do you want to install this plugin?'** - String get do_you_want_to_install_this_plugin; - - /// No description provided for @third_party_plugin_warning. - /// - /// In en, this message translates to: - /// **'This plugin is from a third-party repository. Please ensure you trust the source before installing.'** - String get third_party_plugin_warning; - - /// No description provided for @author. - /// - /// In en, this message translates to: - /// **'Author'** - String get author; - - /// No description provided for @this_plugin_can_do_following. - /// - /// In en, this message translates to: - /// **'This plugin can do following'** - String get this_plugin_can_do_following; - - /// No description provided for @install. - /// - /// In en, this message translates to: - /// **'Install'** - String get install; - - /// No description provided for @install_a_metadata_provider. - /// - /// In en, this message translates to: - /// **'Install a Metadata Provider'** - String get install_a_metadata_provider; - - /// No description provided for @no_tracks_playing. - /// - /// In en, this message translates to: - /// **'No Track being played currently'** - String get no_tracks_playing; - - /// No description provided for @synced_lyrics_not_available. - /// - /// In en, this message translates to: - /// **'Synced lyrics are not available for this song. Please use the'** - String get synced_lyrics_not_available; - - /// No description provided for @plain_lyrics. - /// - /// In en, this message translates to: - /// **'Plain Lyrics'** - String get plain_lyrics; - - /// No description provided for @tab_instead. - /// - /// In en, this message translates to: - /// **'tab instead.'** - String get tab_instead; - - /// No description provided for @disclaimer. - /// - /// In en, this message translates to: - /// **'Disclaimer'** - String get disclaimer; - - /// No description provided for @third_party_plugin_dmca_notice. - /// - /// In en, this message translates to: - /// **'The Spotube team does not hold any responsibility (including legal) for any \"Third-party\" plugins.\nPlease use them at your own risk. For any bugs/issues, please report them to the plugin repository.\n\nIf any \"Third-party\" plugin is breaking ToS/DMCA of any service/legal entity, please ask the \"Third-party\" plugin author or the hosting platform .e.g GitHub/Codeberg to take action. Above listed (\"Third-party\" labelled) are all public/community maintained plugins. We\'re not curating them, so we cannot take any action on them.\n\n'** - String get third_party_plugin_dmca_notice; - - /// No description provided for @input_does_not_match_format. - /// - /// In en, this message translates to: - /// **'Input doesn\'t match the required format'** - String get input_does_not_match_format; - - /// No description provided for @plugins. - /// - /// In en, this message translates to: - /// **'Plugins'** - String get plugins; - - /// No description provided for @paste_plugin_download_url. - /// - /// In en, this message translates to: - /// **'Paste download url or GitHub/Codeberg repo url or direct link to .smplug file'** - String get paste_plugin_download_url; - - /// No description provided for @download_and_install_plugin_from_url. - /// - /// In en, this message translates to: - /// **'Download and install plugin from url'** - String get download_and_install_plugin_from_url; - - /// No description provided for @failed_to_add_plugin_error. - /// - /// In en, this message translates to: - /// **'Failed to add plugin: {error}'** - String failed_to_add_plugin_error(Object error); - - /// No description provided for @upload_plugin_from_file. - /// - /// In en, this message translates to: - /// **'Upload plugin from file'** - String get upload_plugin_from_file; - - /// No description provided for @installed. - /// - /// In en, this message translates to: - /// **'Installed'** - String get installed; - - /// No description provided for @available_plugins. - /// - /// In en, this message translates to: - /// **'Available plugins'** - String get available_plugins; - - /// No description provided for @configure_plugins. - /// - /// In en, this message translates to: - /// **'Configure your own metadata provider and audio source plugins'** - String get configure_plugins; - - /// No description provided for @audio_scrobblers. - /// - /// In en, this message translates to: - /// **'Audio Scrobblers'** - String get audio_scrobblers; - - /// No description provided for @scrobbling. - /// - /// In en, this message translates to: - /// **'Scrobbling'** - String get scrobbling; - - /// No description provided for @source. - /// - /// In en, this message translates to: - /// **'Source: '** - String get source; - - /// No description provided for @uncompressed. - /// - /// In en, this message translates to: - /// **'Uncompressed'** - String get uncompressed; - - /// No description provided for @dab_music_source_description. - /// - /// In en, this message translates to: - /// **'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'** - String get dab_music_source_description; -} - -class _AppLocalizationsDelegate - extends LocalizationsDelegate { - const _AppLocalizationsDelegate(); - - @override - Future load(Locale locale) { - return SynchronousFuture(lookupAppLocalizations(locale)); - } - - @override - bool isSupported(Locale locale) => [ - 'ar', - 'bn', - 'ca', - 'cs', - 'de', - 'en', - 'es', - 'eu', - 'fa', - 'fi', - 'fr', - 'hi', - 'id', - 'it', - 'ja', - 'ka', - 'ko', - 'ne', - 'nl', - 'pl', - 'pt', - 'ru', - 'ta', - 'th', - 'tl', - 'tr', - 'uk', - 'vi', - 'zh' - ].contains(locale.languageCode); - - @override - bool shouldReload(_AppLocalizationsDelegate old) => false; -} - -AppLocalizations lookupAppLocalizations(Locale locale) { - // Lookup logic when language+country codes are specified. - switch (locale.languageCode) { - case 'zh': - { - switch (locale.countryCode) { - case 'TW': - return AppLocalizationsZhTw(); - } - break; - } - } - - // Lookup logic when only language code is specified. - switch (locale.languageCode) { - case 'ar': - return AppLocalizationsAr(); - case 'bn': - return AppLocalizationsBn(); - case 'ca': - return AppLocalizationsCa(); - case 'cs': - return AppLocalizationsCs(); - case 'de': - return AppLocalizationsDe(); - case 'en': - return AppLocalizationsEn(); - case 'es': - return AppLocalizationsEs(); - case 'eu': - return AppLocalizationsEu(); - case 'fa': - return AppLocalizationsFa(); - case 'fi': - return AppLocalizationsFi(); - case 'fr': - return AppLocalizationsFr(); - case 'hi': - return AppLocalizationsHi(); - case 'id': - return AppLocalizationsId(); - case 'it': - return AppLocalizationsIt(); - case 'ja': - return AppLocalizationsJa(); - case 'ka': - return AppLocalizationsKa(); - case 'ko': - return AppLocalizationsKo(); - case 'ne': - return AppLocalizationsNe(); - case 'nl': - return AppLocalizationsNl(); - case 'pl': - return AppLocalizationsPl(); - case 'pt': - return AppLocalizationsPt(); - case 'ru': - return AppLocalizationsRu(); - case 'ta': - return AppLocalizationsTa(); - case 'th': - return AppLocalizationsTh(); - case 'tl': - return AppLocalizationsTl(); - case 'tr': - return AppLocalizationsTr(); - case 'uk': - return AppLocalizationsUk(); - case 'vi': - return AppLocalizationsVi(); - case 'zh': - return AppLocalizationsZh(); - } - - throw FlutterError( - 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' - 'an issue with the localizations generation tool. Please file an issue ' - 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.'); -} diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart deleted file mode 100644 index 8fd50ffa..00000000 --- a/lib/l10n/generated/app_localizations_ar.dart +++ /dev/null @@ -1,1566 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Arabic (`ar`). -class AppLocalizationsAr extends AppLocalizations { - AppLocalizationsAr([String locale = 'ar']) : super(locale); - - @override - String get guest => 'ضيف'; - - @override - String get browse => 'تصفح'; - - @override - String get search => 'بحث'; - - @override - String get library => 'مكتبة'; - - @override - String get lyrics => 'كلمات'; - - @override - String get settings => 'إعدادات'; - - @override - String get genre_categories_filter => 'تصفية الفئات أو الأنواع...'; - - @override - String get genre => 'النوع'; - - @override - String get personalized => 'شخصية'; - - @override - String get featured => 'متميز'; - - @override - String get new_releases => 'الإصدارات الجديدة'; - - @override - String get songs => 'أغاني'; - - @override - String playing_track(Object track) { - return 'تشغيل $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'سيؤدي هذا إلى مسح قائمة الانتظار الحالية. $track_length ستتم إزالة المقطوعات\nهل تريد الإستمرار؟'; - } - - @override - String get load_more => 'تحميل المزيد'; - - @override - String get playlists => 'قوائم التشغيل'; - - @override - String get artists => 'فنانون'; - - @override - String get albums => 'ألبومات'; - - @override - String get tracks => 'مقطوعات'; - - @override - String get downloads => 'تنزيلات'; - - @override - String get filter_playlists => 'تصفية قوائم التشغيل الخاصة بك...'; - - @override - String get liked_tracks => 'المقطوعات التي أعجبتك'; - - @override - String get liked_tracks_description => 'جميع المقطوعات التي أعجبتك'; - - @override - String get playlist => 'قائمة التشغيل'; - - @override - String get create_a_playlist => 'إنشاء قائمة تشغيل'; - - @override - String get update_playlist => 'تحديث قائمة التشغيل'; - - @override - String get create => 'إنشاء'; - - @override - String get cancel => 'إلغاء'; - - @override - String get update => 'تحديث'; - - @override - String get playlist_name => 'اسم قائمة التشغيل'; - - @override - String get name_of_playlist => 'اسم قائمة التشغيل'; - - @override - String get description => 'وصف'; - - @override - String get public => 'عام'; - - @override - String get collaborative => 'تعاوني'; - - @override - String get search_local_tracks => 'بحث عن مقطوعات محلية'; - - @override - String get play => 'تشغيل'; - - @override - String get delete => 'حذف'; - - @override - String get none => 'لا شيء'; - - @override - String get sort_a_z => 'الترتيب من A-Z'; - - @override - String get sort_z_a => 'الترتيب من Z-A'; - - @override - String get sort_artist => 'الترتيب حسب الفنان'; - - @override - String get sort_album => 'فرز حسب الألبوم'; - - @override - String get sort_duration => 'ترتيب حسب المدة'; - - @override - String get sort_tracks => 'ترتيب المقطوعات'; - - @override - String currently_downloading(Object tracks_length) { - return 'يتم التنزيل ($tracks_length)'; - } - - @override - String get cancel_all => 'إلغاء الكل'; - - @override - String get filter_artist => 'تصفية الفنانين...'; - - @override - String followers(Object followers) { - return '$followers متابعون'; - } - - @override - String get add_artist_to_blacklist => 'إضافة فنان إلى القائمة السوداء'; - - @override - String get top_tracks => 'أهم المقطوعات الصوتية'; - - @override - String get fans_also_like => 'المعجبون يحبون أيضاً'; - - @override - String get loading => 'جارٍ التحميل'; - - @override - String get artist => 'فنان'; - - @override - String get blacklisted => 'في القائمة السوداء'; - - @override - String get following => 'يتابع'; - - @override - String get follow => 'تابع'; - - @override - String get artist_url_copied => 'تم نسخ عنوان URL للفنان إلى الحافظة'; - - @override - String added_to_queue(Object tracks) { - return 'تم إضافة المقطوعات إلى قائمة الإنتظار $tracks'; - } - - @override - String get filter_albums => 'تصفية الألبومات...'; - - @override - String get synced => 'تم المزامنة'; - - @override - String get plain => 'سهل'; - - @override - String get shuffle => 'خلط'; - - @override - String get search_tracks => 'يحث عن مقطوعات'; - - @override - String get released => 'تم الإصدار'; - - @override - String error(Object error) { - return 'خطأ $error'; - } - - @override - String get title => 'عنوان'; - - @override - String get time => 'وقت'; - - @override - String get more_actions => 'المزيد من الإجراءات'; - - @override - String download_count(Object count) { - return 'تنزيل ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'إضافة ($count) إلى قائمة التشغيل'; - } - - @override - String add_count_to_queue(Object count) { - return 'إضافة ($count) إلى قائمة الإنتظار'; - } - - @override - String play_count_next(Object count) { - return 'تشغيل ($count) التالي'; - } - - @override - String get album => 'ألبوم'; - - @override - String copied_to_clipboard(Object data) { - return 'تم النسخ $data إلى الحافظة'; - } - - @override - String add_to_following_playlists(Object track) { - return 'إضافة $track إلى قوائم التشغيل التالية'; - } - - @override - String get add => 'إضافة'; - - @override - String added_track_to_queue(Object track) { - return 'تم الإضافة $track إلى قائمة الإنتظار'; - } - - @override - String get add_to_queue => 'إضافة إلى قائمة التشغيل'; - - @override - String track_will_play_next(Object track) { - return '$track سيتم تشغيل التالي'; - } - - @override - String get play_next => 'تشغيل التالي'; - - @override - String removed_track_from_queue(Object track) { - return 'تم الإزالة $track من قائمة الإنتظار'; - } - - @override - String get remove_from_queue => 'إزالة من قائمة الإنتظار'; - - @override - String get remove_from_favorites => 'إزالة من المفضلة'; - - @override - String get save_as_favorite => 'حفظ كمفضل'; - - @override - String get add_to_playlist => 'إضافة إلى قائمة التشغيل'; - - @override - String get remove_from_playlist => 'إزالة من قائمة التشغيل'; - - @override - String get add_to_blacklist => 'إضافة إلى القائمة السوداء'; - - @override - String get remove_from_blacklist => 'إزالة من القائمة السوداء'; - - @override - String get share => 'مشاكرة'; - - @override - String get mini_player => 'مشغل مصغر'; - - @override - String get slide_to_seek => 'قم بالتمرير للبحث للأمام أو للخلف'; - - @override - String get shuffle_playlist => 'قائمة تشغيل عشوائية'; - - @override - String get unshuffle_playlist => 'إلغاء ترتيب قائمة التشغيل'; - - @override - String get previous_track => 'المقطوعة السابقة'; - - @override - String get next_track => 'مقطوعة جديدة'; - - @override - String get pause_playback => 'إيقاف التشغيل مؤقتًا'; - - @override - String get resume_playback => 'استئناف التشغيل'; - - @override - String get loop_track => 'تشغيل المقطوعة بشكل لا نهائي'; - - @override - String get no_loop => 'بدون تكرار'; - - @override - String get repeat_playlist => 'تكرار قائمة التشغيل'; - - @override - String get queue => 'قائمة الإنتظار'; - - @override - String get alternative_track_sources => 'مصادر مقطوعات بديلة'; - - @override - String get download_track => 'تنزيل المقطوعة'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks المقطوعات في قائمة الإنتظار'; - } - - @override - String get clear_all => 'مسح الكل'; - - @override - String get show_hide_ui_on_hover => 'إظهار/إخفاء واجهة المستخدم عند التمرير'; - - @override - String get always_on_top => 'دائما في القمة'; - - @override - String get exit_mini_player => 'خروج من المشغل المصغر'; - - @override - String get download_location => 'تنزيل الموقع'; - - @override - String get local_library => 'المكتبة المحلية'; - - @override - String get add_library_location => 'أضف إلى المكتبة'; - - @override - String get remove_library_location => 'إزالة من المكتبة'; - - @override - String get account => 'حساب'; - - @override - String get logout => 'تسجيل الخروج'; - - @override - String get logout_of_this_account => 'تسجيل الخروج من هذا الحساب'; - - @override - String get language_region => 'اللغة والمنطقة'; - - @override - String get language => 'لغة'; - - @override - String get system_default => 'لغة النظام الإفتراضية'; - - @override - String get market_place_region => 'منطقة السوق'; - - @override - String get recommendation_country => 'بلد التوصية'; - - @override - String get appearance => 'مظهر'; - - @override - String get layout_mode => 'وضع التخطيط'; - - @override - String get override_layout_settings => - 'تجاوز إعدادات وضع التخطيط سريع الاستجابة'; - - @override - String get adaptive => 'متكيف'; - - @override - String get compact => 'مدمج'; - - @override - String get extended => 'ممتد'; - - @override - String get theme => 'مظهر'; - - @override - String get dark => 'داكن'; - - @override - String get light => 'ساطعt'; - - @override - String get system => 'حسب النظام'; - - @override - String get accent_color => 'لون تمييز'; - - @override - String get sync_album_color => 'مزامنة لون الألبوم'; - - @override - String get sync_album_color_description => - 'يستخدم اللون السائد لصورة الألبوم باعتباره لون التمييز'; - - @override - String get playback => 'التشغيل'; - - @override - String get audio_quality => 'جودة الصوت'; - - @override - String get high => 'مرتفعة'; - - @override - String get low => 'منخفضة'; - - @override - String get pre_download_play => 'التحميل المسبق والتشغيل'; - - @override - String get pre_download_play_description => - 'بدلاً من دفق الصوت، قم بتنزيل وحدات البايت وتشغيلها بدلاً من ذلك (موصى به لمستخدمي Bandwidth)'; - - @override - String get skip_non_music => 'تخطي المقاطع غير الموسيقية (SponsorBlock)'; - - @override - String get blacklist_description => - 'المقطوعات والفنانون المدرجون في القائمة السوداء'; - - @override - String get wait_for_download_to_finish => - 'يرجى الانتظار حتى انتهاء التنزيل الحالي'; - - @override - String get desktop => 'سطح المكتب'; - - @override - String get close_behavior => 'إغلاق التصرف'; - - @override - String get close => 'إغلاق'; - - @override - String get minimize_to_tray => 'تصغير إلى الدرج'; - - @override - String get show_tray_icon => 'إظهار أيقونات درج النظام'; - - @override - String get about => 'حول'; - - @override - String get u_love_spotube => 'نحن نعلم أنك تحب Spotube'; - - @override - String get check_for_updates => 'تحقق من وجود تحديثات'; - - @override - String get about_spotube => 'حول Spotube'; - - @override - String get blacklist => 'قائمة سوداء'; - - @override - String get please_sponsor => 'يرجى دعم/التبرع'; - - @override - String get spotube_description => - 'Spotube، عميل Spotify خفيف الوزن ومتعدد المنصات ومجاني للجميع'; - - @override - String get version => 'إصدار'; - - @override - String get build_number => 'رقم البنية'; - - @override - String get founder => 'الموئسس'; - - @override - String get repository => 'المستودع'; - - @override - String get bug_issues => 'أخطاء+مشاكل'; - - @override - String get made_with => 'صُنع باستخدام ❤️ في بنغلاديش🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'الترخيص'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'لا تقلق، لن يتم جمع أي من بيانات الخاصة بك أو مشاركتها مع أي شخص'; - - @override - String get know_how_to_login => 'لا تعرف كيف تفعل هذا؟'; - - @override - String get follow_step_by_step_guide => 'اتبع الدليل خطوة بخطوة'; - - @override - String cookie_name_cookie(Object name) { - return '$name كوكيز'; - } - - @override - String get fill_in_all_fields => 'يرجى تعبئة جميع الحقول'; - - @override - String get submit => 'إرسال'; - - @override - String get exit => 'خروج'; - - @override - String get previous => 'السابق'; - - @override - String get next => 'التالي'; - - @override - String get done => 'تم'; - - @override - String get step_1 => 'الخطوة 1'; - - @override - String get first_go_to => 'أولا، اذهب إلى'; - - @override - String get something_went_wrong => 'هناك خطأ ما'; - - @override - String get piped_instance => 'مثيل خادم Piped'; - - @override - String get piped_description => - 'مثيل خادم Piped الذي سيتم استخدامه لمطابقة المقطوعة'; - - @override - String get piped_warning => - 'البعض منهم قد لا يعمل بشكل جيد. لذلك استخدمه على مسؤوليتك'; - - @override - String get invidious_instance => 'مثيل خادم Invidious'; - - @override - String get invidious_description => - 'مثيل خادم Invidious المستخدم لمطابقة المسارات'; - - @override - String get invidious_warning => - 'قد لا تعمل بعض الخوادم بشكل جيد. استخدمها على مسؤوليتك الخاصة'; - - @override - String get generate => 'إنشاء'; - - @override - String track_exists(Object track) { - return 'المقطوعة $track بالفعل موجودة'; - } - - @override - String get replace_downloaded_tracks => - 'استبدل جميع المقطوعات التي تم تنزيلها'; - - @override - String get skip_download_tracks => - 'تخطي تنزيل كافة المقطوعات التي تم تنزيلها'; - - @override - String get do_you_want_to_replace => 'هل تريد استبدال المقطوعة الحالية؟'; - - @override - String get replace => 'إستبدال'; - - @override - String get skip => 'تخطي'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'إختر ما يصل إلى $count $type'; - } - - @override - String get select_genres => 'حدد الأنواع'; - - @override - String get add_genres => 'أضف الأنواع'; - - @override - String get country => 'دولة'; - - @override - String get number_of_tracks_generate => - 'عدد المسارات المقطوعات المراد توليدها'; - - @override - String get acousticness => 'صوتية'; - - @override - String get danceability => 'قدرة على الرقص'; - - @override - String get energy => 'طاقة'; - - @override - String get instrumentalness => 'نفعية'; - - @override - String get liveness => 'حيوية'; - - @override - String get loudness => 'بريق'; - - @override - String get speechiness => 'كلام'; - - @override - String get valence => 'تكافؤ'; - - @override - String get popularity => 'شعبية'; - - @override - String get key => 'مفتاح'; - - @override - String get duration => 'مدة (s)'; - - @override - String get tempo => 'Tempo (BPM)'; - - @override - String get mode => 'Mode'; - - @override - String get time_signature => 'توقيع الوقت'; - - @override - String get short => 'قصير'; - - @override - String get medium => 'متوسط'; - - @override - String get long => 'طويل'; - - @override - String get min => 'أدنى'; - - @override - String get max => 'أقصى'; - - @override - String get target => 'هدف'; - - @override - String get moderate => 'معتدل'; - - @override - String get deselect_all => 'الغاء تحديد الكل'; - - @override - String get select_all => 'اختر الكل'; - - @override - String get are_you_sure => 'هل أنت متأكد؟'; - - @override - String get generating_playlist => 'جارٍ إنشاء قائمة التشغيل المخصصة...'; - - @override - String selected_count_tracks(Object count) { - return 'مقطوعات $count مختارة'; - } - - @override - String get download_warning => - 'إذا قمت بتنزيل جميع المقاطع الصوتية بكميات كبيرة، فمن الواضح أنك تقوم بقرصنة الموسيقى وتسبب الضرر للمجتمع الإبداعي للموسيقى. أتمنى أن تكون على علم بهذا. حاول دائمًا احترام ودعم العمل الجاد للفنان'; - - @override - String get download_ip_ban_warning => - 'بالمناسبة، يمكن أن يتم حظر عنوان IP الخاص بك على YouTube بسبب طلبات التنزيل الزائدة عن المعتاد. يعني حظر IP أنه لا يمكنك استخدام YouTube (حتى إذا قمت بتسجيل الدخول) لمدة تتراوح بين شهرين إلى ثلاثة أشهر على الأقل من جهاز IP هذا. ولا يتحمل Spotube أي مسؤولية إذا حدث هذا على الإطلاق'; - - @override - String get by_clicking_accept_terms => - 'بالنقر على \"قبول\"، فإنك توافق على الشروط التالية:'; - - @override - String get download_agreement_1 => 'أعلم أنني أقوم بقرصنة الموسيقى. انا سيئ'; - - @override - String get download_agreement_2 => - 'سأدعم الفنان أينما أستطيع، وأنا أفعل هذا فقط لأنني لا أملك المال لشراء أعمالهم الفنية'; - - @override - String get download_agreement_3 => - 'أدرك تمامًا أنه يمكن حظر عنوان IP الخاص بي على YouTube ولا أحمل Spotube أو مالكيه/مساهميه المسؤولية عن أي حوادث ناجمة عن الإجراء الحالي الخاص بي'; - - @override - String get decline => 'رفض'; - - @override - String get accept => 'قبول'; - - @override - String get details => 'تفاصيل'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'قناة'; - - @override - String get likes => 'إعجابات'; - - @override - String get dislikes => 'عدم الإعجابات'; - - @override - String get views => 'مشاهدات'; - - @override - String get streamUrl => 'عنوان URL البث'; - - @override - String get stop => 'إيقاف'; - - @override - String get sort_newest => 'الترتيب حسب الأقدم'; - - @override - String get sort_oldest => 'الترتيب حسب الأقدم'; - - @override - String get sleep_timer => 'مؤقت النوم'; - - @override - String mins(Object minutes) { - return '$minutes دقائق'; - } - - @override - String hours(Object hours) { - return '$hours ساعات'; - } - - @override - String hour(Object hours) { - return '$hours ساعة'; - } - - @override - String get custom_hours => 'ساعات مخصصة'; - - @override - String get logs => 'سجلات'; - - @override - String get developers => 'المطورون'; - - @override - String get not_logged_in => 'لم تقم بتسجيل الدخول'; - - @override - String get search_mode => 'وضع البحث'; - - @override - String get audio_source => 'مصدر الصوت'; - - @override - String get ok => 'حسسناً'; - - @override - String get failed_to_encrypt => 'فشل في التشفير'; - - @override - String get encryption_failed_warning => - 'يستخدم Spotube التشفير لتخزين بياناتك بشكل آمن. لكنها فشلت في القيام بذلك. لذلك سيعود الأمر إلى التخزين غير الآمن\nإذا كنت تستخدم Linux، فيرجى التأكد من تثبيت أي خدمة سرية (gnome-keyring، kde-wallet، keepassxc، إلخ)'; - - @override - String get querying_info => 'جارٍ الاستعلام عن معلومات...'; - - @override - String get piped_api_down => 'Piped API معطلة'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'المثيل الموجه $pipedInstance معطل حاليًا\n\nيمكنك إما تغيير المثيل أو تغيير \'نوع API\' إلى YouTube API الرسمي\n\nتأكد من إعادة تشغيل التطبيق بعد التغيير'; - } - - @override - String get you_are_offline => 'أنت غير متصل حالياً'; - - @override - String get connection_restored => 'تمت استعادة اتصالك بالإنترنت'; - - @override - String get use_system_title_bar => 'استخدم شريط عنوان النظام'; - - @override - String get crunching_results => 'تدمير النتائج'; - - @override - String get search_to_get_results => 'إبحث للحصول على النتائج'; - - @override - String get use_amoled_mode => 'استخدم وضع AMOLED'; - - @override - String get pitch_dark_theme => 'موضوع دارت الأسود الفحمي'; - - @override - String get normalize_audio => 'تطبيع الصوت'; - - @override - String get change_cover => 'تغيير الغلاف'; - - @override - String get add_cover => 'إضافة غلاف'; - - @override - String get restore_defaults => 'استعادة الإعدادات الافتراضية'; - - @override - String get download_music_format => 'تنسيق تنزيل الموسيقى'; - - @override - String get streaming_music_format => 'تنسيق بث الموسيقى'; - - @override - String get download_music_quality => 'جودة تنزيل الموسيقى'; - - @override - String get streaming_music_quality => 'جودة بث الموسيقى'; - - @override - String get login_with_lastfm => 'تسجيل الدخول باستخدام Last.fm'; - - @override - String get connect => 'اتصال'; - - @override - String get disconnect_lastfm => 'قطع الاتصال بـ Last.fm'; - - @override - String get disconnect => 'قطع الاتصال'; - - @override - String get username => 'اسم المستخدم'; - - @override - String get password => 'كلمة المرور'; - - @override - String get login => 'تسجيل الدخول'; - - @override - String get login_with_your_lastfm => - 'تسجيل الدخول باستخدام حساب Last.fm الخاص بك'; - - @override - String get scrobble_to_lastfm => 'تسجيل الاستماع على Last.fm'; - - @override - String get go_to_album => 'الانتقال إلى الألبوم'; - - @override - String get discord_rich_presence => 'وجود ديسكورد الغني'; - - @override - String get browse_all => 'تصفح الكل'; - - @override - String get genres => 'الأنواع الموسيقية'; - - @override - String get explore_genres => 'استكشاف الأنواع'; - - @override - String get friends => 'أصدقاء'; - - @override - String get no_lyrics_available => - 'عذرًا، تعذر العثور على كلمات الأغنية لهذه العنصر'; - - @override - String get start_a_radio => 'بدء راديو'; - - @override - String get how_to_start_radio => 'كيف تريد بدء الراديو؟'; - - @override - String get replace_queue_question => - 'هل تريد استبدال قائمة التشغيل الحالية أم إضافة إليها؟'; - - @override - String get endless_playback => 'تشغيل بلا نهاية'; - - @override - String get delete_playlist => 'حذف قائمة التشغيل'; - - @override - String get delete_playlist_confirmation => - 'هل أنت متأكد أنك تريد حذف هذه قائمة التشغيل؟'; - - @override - String get local_tracks => 'المسارات المحلية'; - - @override - String get local_tab => 'محلي'; - - @override - String get song_link => 'رابط الأغنية'; - - @override - String get skip_this_nonsense => 'تخطي هذه الهراء'; - - @override - String get freedom_of_music => '“حرية الموسيقى”'; - - @override - String get freedom_of_music_palm => '“حرية الموسيقى في متناول يدك”'; - - @override - String get get_started => 'لنبدأ'; - - @override - String get youtube_source_description => 'موصى به ويعمل بشكل أفضل.'; - - @override - String get piped_source_description => - 'تشعر بالحرية؟ نفس يوتيوب ولكن أكثر حرية.'; - - @override - String get jiosaavn_source_description => 'الأفضل لمنطقة جنوب آسيا.'; - - @override - String get invidious_source_description => 'مشابه لـ Piped ولكن بتوافر أعلى'; - - @override - String highest_quality(Object quality) { - return 'أعلى جودة: $quality'; - } - - @override - String get select_audio_source => 'اختر مصدر الصوت'; - - @override - String get endless_playback_description => - 'إلحاق الأغاني الجديدة تلقائيًا\nإلى نهاية قائمة التشغيل'; - - @override - String get choose_your_region => 'اختر منطقتك'; - - @override - String get choose_your_region_description => - 'سيساعدك هذا في عرض المحتوى المناسب\nلموقعك.'; - - @override - String get choose_your_language => 'اختر لغتك'; - - @override - String get help_project_grow => 'ساعد في نمو هذا المشروع'; - - @override - String get help_project_grow_description => - 'Spotube هو مشروع مفتوح المصدر. يمكنك مساعدة هذا المشروع في النمو عن طريق المساهمة في المشروع، أو الإبلاغ عن الأخطاء، أو اقتراح ميزات جديدة.'; - - @override - String get contribute_on_github => 'المساهمة على GitHub'; - - @override - String get donate_on_open_collective => 'التبرع على Open Collective'; - - @override - String get browse_anonymously => 'تصفح بشكل مجهول'; - - @override - String get enable_connect => 'تمكين الاتصال'; - - @override - String get enable_connect_description => - 'التحكم في Spotube من الأجهزة الأخرى'; - - @override - String get devices => 'الأجهزة'; - - @override - String get select => 'اختر'; - - @override - String connect_client_alert(Object client) { - return 'أنت تتم التحكم بواسطة $client'; - } - - @override - String get this_device => 'هذا الجهاز'; - - @override - String get remote => 'بعيد'; - - @override - String get stats => 'إحصائيات'; - - @override - String and_n_more(Object count) { - return 'و $count أكثر'; - } - - @override - String get recently_played => 'تم تشغيله مؤخرًا'; - - @override - String get browse_more => 'تصفح المزيد'; - - @override - String get no_title => 'بدون عنوان'; - - @override - String get not_playing => 'غير مشغل'; - - @override - String get epic_failure => 'فشل كبير!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'تمت إضافة $tracks_length مسارات إلى قائمة الانتظار'; - } - - @override - String get spotube_has_an_update => 'يوجد تحديث لسبوتيوب'; - - @override - String get download_now => 'تحميل الآن'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'تم إصدار سبوتيوب الليلي $nightlyBuildNum'; - } - - @override - String release_version(Object version) { - return 'تم إصدار سبوتيوب v$version'; - } - - @override - String get read_the_latest => 'اقرأ الأحدث'; - - @override - String get release_notes => 'ملاحظات الإصدار'; - - @override - String get pick_color_scheme => 'اختر نظام الألوان'; - - @override - String get save => 'حفظ'; - - @override - String get choose_the_device => 'اختر الجهاز:'; - - @override - String get multiple_device_connected => - 'تم توصيل أجهزة متعددة.\nاختر الجهاز الذي تريد إجراء هذه العملية عليه'; - - @override - String get nothing_found => 'لم يتم العثور على شيء'; - - @override - String get the_box_is_empty => 'الصندوق فارغ'; - - @override - String get top_artists => 'أفضل الفنانين'; - - @override - String get top_albums => 'أفضل الألبومات'; - - @override - String get this_week => 'هذا الأسبوع'; - - @override - String get this_month => 'هذا الشهر'; - - @override - String get last_6_months => 'آخر 6 أشهر'; - - @override - String get this_year => 'هذا العام'; - - @override - String get last_2_years => 'آخر سنتين'; - - @override - String get all_time => 'كل الوقت'; - - @override - String powered_by_provider(Object providerName) { - return 'مدعوم من $providerName'; - } - - @override - String get email => 'البريد الإلكتروني'; - - @override - String get profile_followers => 'المتابعين'; - - @override - String get birthday => 'عيد الميلاد'; - - @override - String get subscription => 'اشتراك'; - - @override - String get not_born => 'لم يولد'; - - @override - String get hacker => 'هاكر'; - - @override - String get profile => 'الملف الشخصي'; - - @override - String get no_name => 'بدون اسم'; - - @override - String get edit => 'تعديل'; - - @override - String get user_profile => 'ملف المستخدم'; - - @override - String count_plays(Object count) { - return '$count تشغيلات'; - } - - @override - String get streaming_fees_hypothetical => 'رسوم البث (افتراضية)'; - - @override - String get minutes_listened => 'الدقائق المستمعة'; - - @override - String get streamed_songs => 'الأغاني المذاعة'; - - @override - String count_streams(Object count) { - return '$count بث'; - } - - @override - String get owned_by_you => 'مملوك لك'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return 'تم نسخ $shareUrl إلى الحافظة'; - } - - @override - String get hipotetical_calculation => - '*تمّ الحساب بمعدّل دفعة تتراوح بين 0.003–0.005 دولار أمريكي لكل تشغيل على منصات الموسيقى عبر الإنترنت. هذا حساب افتراضي لتوضيح للمستخدم مقدار ما كان سيدفعه للفنانين لو استمع إلى أغنيتهم على منصات مختلفة.'; - - @override - String count_mins(Object minutes) { - return '$minutes دقيقة'; - } - - @override - String get summary_minutes => 'الدقائق'; - - @override - String get summary_listened_to_music => 'استمعت إلى الموسيقى'; - - @override - String get summary_songs => 'أغاني'; - - @override - String get summary_streamed_overall => 'بث بشكل عام'; - - @override - String get summary_owed_to_artists => 'مدين للفنانين\nهذا الشهر'; - - @override - String get summary_artists => 'الفنانين'; - - @override - String get summary_music_reached_you => 'وصلت إليك الموسيقى'; - - @override - String get summary_full_albums => 'ألبومات كاملة'; - - @override - String get summary_got_your_love => 'حصلت على حبك'; - - @override - String get summary_playlists => 'قوائم التشغيل'; - - @override - String get summary_were_on_repeat => 'كانت على التكرار'; - - @override - String total_money(Object money) { - return 'المجموع $money'; - } - - @override - String get webview_not_found => 'لم يتم العثور على Webview'; - - @override - String get webview_not_found_description => - 'لم يتم تثبيت بيئة تشغيل Webview على جهازك.\nإذا كانت مثبتة، تأكد من وجودها في environment PATH\n\nبعد التثبيت، أعد تشغيل التطبيق'; - - @override - String get unsupported_platform => 'المنصة غير مدعومة'; - - @override - String get cache_music => 'تخزين الموسيقى مؤقتًا'; - - @override - String get open => 'فتح'; - - @override - String get cache_folder => 'مجلد التخزين المؤقت'; - - @override - String get export => 'تصدير'; - - @override - String get clear_cache => 'مسح التخزين المؤقت'; - - @override - String get clear_cache_confirmation => 'هل تريد مسح التخزين المؤقت؟'; - - @override - String get export_cache_files => 'تصدير الملفات المخزنة مؤقتًا'; - - @override - String found_n_files(Object count) { - return 'تم العثور على $count ملف'; - } - - @override - String get export_cache_confirmation => 'هل تريد تصدير هذه الملفات إلى'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return 'تم تصدير $filesExported من أصل $files ملفات'; - } - - @override - String get undo => 'تراجع'; - - @override - String get download_all => 'تنزيل الكل'; - - @override - String get add_all_to_playlist => 'إضافة الكل إلى قائمة التشغيل'; - - @override - String get add_all_to_queue => 'إضافة الكل إلى القائمة'; - - @override - String get play_all_next => 'تشغيل الكل بعد ذلك'; - - @override - String get pause => 'إيقاف مؤقت'; - - @override - String get view_all => 'عرض الكل'; - - @override - String get no_tracks_added_yet => 'يبدو أنك لم تضف أي مسارات بعد'; - - @override - String get no_tracks => 'يبدو أنه لا يوجد أي مسارات هنا'; - - @override - String get no_tracks_listened_yet => 'يبدو أنك لم تستمع إلى أي شيء بعد'; - - @override - String get not_following_artists => 'أنت لا تتابع أي فنانين'; - - @override - String get no_favorite_albums_yet => - 'يبدو أنك لم تضف أي ألبومات إلى المفضلة بعد'; - - @override - String get no_logs_found => 'لم يتم العثور على سجلات'; - - @override - String get youtube_engine => 'محرك يوتيوب'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine غير مثبت'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine غير مثبت في نظامك.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'تأكد من أنه متاح في متغير PATH أو\nحدد المسار الكامل للملف القابل للتنفيذ $engine أدناه'; - } - - @override - String get youtube_engine_unix_issue_message => - 'في أنظمة macOS/Linux/Unix مثل الأنظمة، لن يعمل تعيين المسار في .zshrc/.bashrc/.bash_profile وما إلى ذلك.\nيجب تعيين المسار في ملف تكوين الصدفة'; - - @override - String get download => 'تنزيل'; - - @override - String get file_not_found => 'الملف غير موجود'; - - @override - String get custom => 'مخصص'; - - @override - String get add_custom_url => 'إضافة URL مخصص'; - - @override - String get edit_port => 'تعديل المنفذ'; - - @override - String get port_helper_msg => - 'القيمة الافتراضية هي -1 والتي تشير إلى رقم عشوائي. إذا كان لديك جدار ناري مُعد، يُوصى بتعيين هذا.'; - - @override - String connect_request(Object client) { - return 'السماح لـ $client بالاتصال؟'; - } - - @override - String get connection_request_denied => - 'تم رفض الاتصال. المستخدم رفض الوصول.'; - - @override - String get an_error_occurred => 'حدث خطأ'; - - @override - String get copy_to_clipboard => 'نسخ إلى الحافظة'; - - @override - String get view_logs => 'عرض السجلات'; - - @override - String get retry => 'إعادة المحاولة'; - - @override - String get no_default_metadata_provider_selected => - 'لم تقُم بتعيين مزود بيانات افتراضي'; - - @override - String get manage_metadata_providers => 'إدارة مزوّدي البيانات'; - - @override - String get open_link_in_browser => 'فتح الرابط في المتصفح؟'; - - @override - String get do_you_want_to_open_the_following_link => - 'هل ترغب في فتح الرابط التالي؟'; - - @override - String get unsafe_url_warning => - 'قد يكون فتح الروابط من مصادر غير موثوقة غير آمن. تحرّ الحذر!\nيمكنك أيضًا نسخ الرابط إلى الحافظة.'; - - @override - String get copy_link => 'نسخ الرابط'; - - @override - String get building_your_timeline => - 'جاري بناء المخطط الزمني استنادًا إلى استماعاتك...'; - - @override - String get official => 'رسمي'; - - @override - String author_name(Object author) { - return 'المؤلّف: $author'; - } - - @override - String get third_party => 'طرف ثالث'; - - @override - String get plugin_requires_authentication => 'تتطلّب الإضافة تسجيل الدخول'; - - @override - String get update_available => 'تحديث متوفر'; - - @override - String get supports_scrobbling => 'يدعم التتبع (scrobbling)'; - - @override - String get plugin_scrobbling_info => - 'تقوم هذه الإضافة بتتبع مقاطعك الموسيقية لإنشاء سجل الاستماع الخاص بك.'; - - @override - String get default_metadata_source => 'مصدر البيانات الوصفية الافتراضي'; - - @override - String get set_default_metadata_source => - 'تعيين مصدر البيانات الوصفية الافتراضي'; - - @override - String get default_audio_source => 'مصدر الصوت الافتراضي'; - - @override - String get set_default_audio_source => 'تعيين مصدر الصوت الافتراضي'; - - @override - String get set_default => 'تعيين كافتراضي'; - - @override - String get support => 'الدعم'; - - @override - String get support_plugin_development => 'دعم تطوير الإضافات'; - - @override - String can_access_name_api(Object name) { - return '- يمكن الوصول إلى واجهة برمجة التطبيقات **$name**'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'هل ترغب في تثبيت هذه الإضافة؟'; - - @override - String get third_party_plugin_warning => - 'هذه الإضافة من مستودع طرف ثالث. تأكد من موثوقية المصدر قبل التثبيت.'; - - @override - String get author => 'المؤلف'; - - @override - String get this_plugin_can_do_following => 'يمكن لهذه الإضافة القيام بما يلي'; - - @override - String get install => 'تثبيت'; - - @override - String get install_a_metadata_provider => 'تثبيت مزوّد بيانات'; - - @override - String get no_tracks_playing => 'لا توجد مقاطع تعمل حاليًا'; - - @override - String get synced_lyrics_not_available => - 'الكلمات المتزامنة غير متوفرة لهذه الأغنية. يُرجى استخدام'; - - @override - String get plain_lyrics => 'الكلمات العادية'; - - @override - String get tab_instead => 'بدلاً من ذلك، استخدم التبويب.'; - - @override - String get disclaimer => 'إخلاء المسؤولية'; - - @override - String get third_party_plugin_dmca_notice => - 'لا تتحمّل فريق Spotube أي مسؤولية (بما في ذلك القانونية) عن أي من الإضافات “لطرف ثالث”.\nاستخدمها على مسؤوليتك الخاصّة. لأيّة أخطاء/مشكلات، يُرجى الإبلاغ عنها في مستودع الإضافة.\n\nإذا كانت أي إضافة “لطرف ثالث” تنتهك شروط الخدمة أو قانون DMCA الخاص بأي خدمة أو كيان قانوني، فيُرجى طلب اتخاذ إجراء من مؤلف الإضافة أو منصة الاستضافة مثل GitHub/Codeberg. الإضافات المدرجة كـ “لطرف ثالث” هي مفعّلة ومُدارة من المجتمع، وليس لدينا صلاحية إدارتها أو التدخل فيها.\n\n'; - - @override - String get input_does_not_match_format => - 'المدخل لا يتوافق مع التنسيق المطلوب'; - - @override - String get plugins => 'الإضافات'; - - @override - String get paste_plugin_download_url => - 'الصق رابط التنزيل أو GitHub/Codeberg أو رابط مباشر لملف .smplug'; - - @override - String get download_and_install_plugin_from_url => - 'تنزيل وتثبيت الإضافة من رابط'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'فشل في إضافة الإضافة: $error'; - } - - @override - String get upload_plugin_from_file => 'رفع الإضافة من ملف'; - - @override - String get installed => 'تم التثبيت'; - - @override - String get available_plugins => 'الإضافات المتوفّرة'; - - @override - String get configure_plugins => - 'قم بتكوين مزود البيانات الوصفية ومكونات مصدر الصوت الخاصة بك'; - - @override - String get audio_scrobblers => 'أجهزة تتبع الصوت'; - - @override - String get scrobbling => 'التتبع'; - - @override - String get source => 'المصدر: '; - - @override - String get uncompressed => 'غير مضغوط'; - - @override - String get dab_music_source_description => - 'لمحبي الصوتيات. يوفر تدفقات صوتية عالية الجودة/بدون فقدان. مطابقة دقيقة للمسارات بناءً على ISRC.'; -} diff --git a/lib/l10n/generated/app_localizations_bn.dart b/lib/l10n/generated/app_localizations_bn.dart deleted file mode 100644 index 7dc1e07f..00000000 --- a/lib/l10n/generated/app_localizations_bn.dart +++ /dev/null @@ -1,1566 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Bengali Bangla (`bn`). -class AppLocalizationsBn extends AppLocalizations { - AppLocalizationsBn([String locale = 'bn']) : super(locale); - - @override - String get guest => 'অতিথি'; - - @override - String get browse => 'ব্রাউজ করুন'; - - @override - String get search => 'অনুসন্ধান করুন'; - - @override - String get library => 'লাইব্রেরী'; - - @override - String get lyrics => 'গানের কথা'; - - @override - String get settings => 'সেটিংস'; - - @override - String get genre_categories_filter => 'গানের ধরণ বা শ্রেণি খুঁজুন'; - - @override - String get genre => 'গানের ধরণ'; - - @override - String get personalized => 'আপনার জন্য'; - - @override - String get featured => 'বৈশিষ্ট্যযুক্ত'; - - @override - String get new_releases => 'সাম্প্রতিক মুক্তি প্রাপ্ত'; - - @override - String get songs => 'গান'; - - @override - String playing_track(Object track) { - return '$track চালানো হচ্ছে'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'এটি বর্তমান প্লেলিষ্ট সাফ করে দিবে। $track_lengthটি গান বাদ দেওয়া হবে\nআপনি কি চালিয়ে যেতে চান?'; - } - - @override - String get load_more => 'আরো লোড করুন'; - - @override - String get playlists => 'প্লেলিস্ট'; - - @override - String get artists => 'শিল্পী'; - - @override - String get albums => 'অ্যালবাম'; - - @override - String get tracks => 'গানের ট্র্যাক'; - - @override - String get downloads => 'ডাউনলোড'; - - @override - String get filter_playlists => 'প্লেলিস্ট অনুসন্ধান করুন...'; - - @override - String get liked_tracks => 'পছন্দের গান'; - - @override - String get liked_tracks_description => 'আপনার পছন্দের গান সমূহ'; - - @override - String get playlist => 'প্লেলিস্ট'; - - @override - String get create_a_playlist => 'একটি প্লেলিস্ট তৈরি করুন'; - - @override - String get update_playlist => 'প্লেলিস্ট আপডেট করুন'; - - @override - String get create => 'তৈরি করুন'; - - @override - String get cancel => 'বাতিল করুন'; - - @override - String get update => 'আপডেট'; - - @override - String get playlist_name => 'প্লেলিস্টের নাম'; - - @override - String get name_of_playlist => 'প্লেলিস্টের নাম'; - - @override - String get description => 'বিবরণ'; - - @override - String get public => 'পাবলিক'; - - @override - String get collaborative => 'সহযোগিতামূলক'; - - @override - String get search_local_tracks => 'ডাউনলোডকৃত গান অনুসন্ধান করুন...'; - - @override - String get play => 'চালান'; - - @override - String get delete => 'মুছে ফেলুন'; - - @override - String get none => 'কোনটিই না'; - - @override - String get sort_a_z => 'A-Z ক্রমে সাজান'; - - @override - String get sort_z_a => 'Z-A ক্রমে সাজান'; - - @override - String get sort_artist => 'শিল্পীর ক্রমে সাজান'; - - @override - String get sort_album => 'অ্যালবামের ক্রমে সাজান'; - - @override - String get sort_duration => 'দৈর্ঘ্য অনুযায়ী বাছাই করুন'; - - @override - String get sort_tracks => 'গানের ক্রম'; - - @override - String currently_downloading(Object tracks_length) { - return 'ডাউনলোড করা হচ্ছে ($tracks_length)'; - } - - @override - String get cancel_all => 'সব বাতিল করুন'; - - @override - String get filter_artist => 'শিল্পীর অনুসন্ধান করুন...'; - - @override - String followers(Object followers) { - return '$followers অনুসরণকারী'; - } - - @override - String get add_artist_to_blacklist => 'শিল্পীকে ব্ল্যাকলিস্টে যোগ করুন'; - - @override - String get top_tracks => 'শীর্ষ গানের ট্র্যাক'; - - @override - String get fans_also_like => 'অনুসরণকারীদের পছন্দ'; - - @override - String get loading => 'লোড হচ্ছে...'; - - @override - String get artist => 'শিল্পী'; - - @override - String get blacklisted => 'ব্ল্যাকলিস্টে আছে'; - - @override - String get following => 'অনুসরণ করছেন'; - - @override - String get follow => 'অনুসরণ করুন'; - - @override - String get artist_url_copied => 'শিল্পীর URL কপি করা হয়েছে'; - - @override - String added_to_queue(Object tracks) { - return '$tracksটি গানের ট্র্যাক কিউতে যোগ করা হয়েছে'; - } - - @override - String get filter_albums => 'অ্যালবাম অনুসন্ধান করুন...'; - - @override - String get synced => 'সময় সুসংগত'; - - @override - String get plain => 'অসুসংগত'; - - @override - String get shuffle => 'অদলবদল'; - - @override - String get search_tracks => 'গান অনুসন্ধান করুন...'; - - @override - String get released => 'প্রকাশিত হয়েছে'; - - @override - String error(Object error) { - return 'ত্রুটি $error'; - } - - @override - String get title => 'শিরোনাম'; - - @override - String get time => 'সময়'; - - @override - String get more_actions => 'আরও অপশন'; - - @override - String download_count(Object count) { - return 'ডাউনলোড ($countটি)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'প্লেলিস্টে যোগ করুন ($countটি)'; - } - - @override - String add_count_to_queue(Object count) { - return 'কিউতে যোগ করুন ($countটি)'; - } - - @override - String play_count_next(Object count) { - return 'পরবর্তীতে চালান ($countটি)'; - } - - @override - String get album => 'অ্যালবাম'; - - @override - String copied_to_clipboard(Object data) { - return '$data ক্লিপবোর্ডে কপি করা হয়েছে'; - } - - @override - String add_to_following_playlists(Object track) { - return 'নিম্নলিখিত প্লেলিস্টে $track যোগ করুন'; - } - - @override - String get add => 'যোগ করুন'; - - @override - String added_track_to_queue(Object track) { - return 'কিউতে $track যোগ করা হয়েছে'; - } - - @override - String get add_to_queue => 'কিউতে যোগ করুন'; - - @override - String track_will_play_next(Object track) { - return '$track পরবর্তীতে চালানো হবে'; - } - - @override - String get play_next => 'পরবর্তীতে চালান'; - - @override - String removed_track_from_queue(Object track) { - return 'কিউ থেকে $track সরিয়ে নেওয়া হয়েছে'; - } - - @override - String get remove_from_queue => 'কিউ থেকে সরান'; - - @override - String get remove_from_favorites => 'পছন্দের তালিকা থেকে অপসারণ করুন'; - - @override - String get save_as_favorite => 'পছন্দের তালিকায় সংরক্ষণ করুন'; - - @override - String get add_to_playlist => 'প্লেলিস্টে যোগ করুন'; - - @override - String get remove_from_playlist => 'প্লেলিস্ট থেকে সরান'; - - @override - String get add_to_blacklist => 'ব্ল্যাকলিস্টে যোগ করুন'; - - @override - String get remove_from_blacklist => 'ব্ল্যাকলিস্ট থেকে সরান'; - - @override - String get share => 'শেয়ার করুন'; - - @override - String get mini_player => 'মিনি প্লেয়ার'; - - @override - String get slide_to_seek => 'গান সামনে বা পিছনে নিতে স্লাইড করুন'; - - @override - String get shuffle_playlist => 'প্লেলিস্ট এলোমেলো করুন'; - - @override - String get unshuffle_playlist => 'প্লেলিস্ট আগের মতো করুন'; - - @override - String get previous_track => 'আগের গানের ট্র্যাক'; - - @override - String get next_track => 'পরের গানের ট্র্যাক'; - - @override - String get pause_playback => 'গান বন্ধ করুন'; - - @override - String get resume_playback => 'গান চালু করুন'; - - @override - String get loop_track => 'গান শেষে পুনরায় চালান'; - - @override - String get no_loop => 'কোনো লুপ নেই'; - - @override - String get repeat_playlist => 'প্লেলিস্ট শেষে পুনরায় চালান'; - - @override - String get queue => 'গানের কিউ'; - - @override - String get alternative_track_sources => 'বিকল্প গানের উৎস'; - - @override - String get download_track => 'গান ডাউনলোড করুন'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracksটি গান কিউতে রয়েছে'; - } - - @override - String get clear_all => 'সব মুছে ফেলুন'; - - @override - String get show_hide_ui_on_hover => 'হভার করলে UI দেখান/লুকান'; - - @override - String get always_on_top => 'সর্বদা উপরে'; - - @override - String get exit_mini_player => 'মিনি প্লেয়ার থেকে বের হয়ে যান'; - - @override - String get download_location => 'ডাউনলোড স্থান'; - - @override - String get local_library => 'স্থানীয় লাইব্রেরি'; - - @override - String get add_library_location => 'লাইব্রেরিতে যোগ করুন'; - - @override - String get remove_library_location => 'লাইব্রেরি থেকে সরান'; - - @override - String get account => 'অ্যাকাউন্ট'; - - @override - String get logout => 'লগআউট করুন'; - - @override - String get logout_of_this_account => 'অ্যাকাউন্ট থেকে লগআউট করুন'; - - @override - String get language_region => 'ভাষা ও অঞ্চল'; - - @override - String get language => 'ভাষা'; - - @override - String get system_default => 'সিস্টেম ডিফল্ট'; - - @override - String get market_place_region => 'মার্কেটপ্লেস অঞ্চল'; - - @override - String get recommendation_country => 'দেশভিত্তিক সঙ্গীত পরামর্শের জন্য দেশ'; - - @override - String get appearance => 'রুপ'; - - @override - String get layout_mode => 'UI বিন্যাস রূপ'; - - @override - String get override_layout_settings => - 'প্রতিক্রিয়াশীল UI বিন্যাস রূপের সেটিংস পরিবর্তন করুন'; - - @override - String get adaptive => 'অভিযোজিত'; - - @override - String get compact => 'আঁটসাঁট UI'; - - @override - String get extended => 'বিস্তৃত UI'; - - @override - String get theme => 'থিম'; - - @override - String get dark => 'অন্ধকার'; - - @override - String get light => 'উজ্জল'; - - @override - String get system => 'সিস্টেম থিম'; - - @override - String get accent_color => 'প্রভাবশালী রং'; - - @override - String get sync_album_color => 'অ্যালবাম সুসংগত UI এর রং'; - - @override - String get sync_album_color_description => - 'অ্যালবাম কভারের প্রভাবশালী রঙ UI অ্যাকসেন্ট রঙ হিসাবে ব্যবহার করে'; - - @override - String get playback => 'সংগীতের প্লেব্যাক'; - - @override - String get audio_quality => 'শব্দের গুণমান'; - - @override - String get high => 'উচ্চ'; - - @override - String get low => 'নিম্ন'; - - @override - String get pre_download_play => 'আগে গান ডাউনলোড করে পরে চালান '; - - @override - String get pre_download_play_description => - 'গান স্ট্রিম করার পরিবর্তে, ডাউনলোড করুন এবং প্লে করুন (উচ্চ ব্যান্ডউইথ ব্যবহারকারীদের জন্য প্রস্তাবিত)'; - - @override - String get skip_non_music => - 'গানের নন-মিউজিক সেগমেন্ট এড়িয়ে যান (SponsorBlock)'; - - @override - String get blacklist_description => - 'কালো তালিকাভুক্ত গানের ট্র্যাক এবং শিল্পী'; - - @override - String get wait_for_download_to_finish => - 'ডাউনলোড শেষ হওয়ার জন্য অপেক্ষা করুন'; - - @override - String get desktop => 'ডেস্কটপ'; - - @override - String get close_behavior => 'বন্ধ করার প্রক্রিয়া'; - - @override - String get close => 'বন্ধ করুন'; - - @override - String get minimize_to_tray => 'সিস্টেম ট্রেতে রাখুন'; - - @override - String get show_tray_icon => 'সিস্টেম ট্রে আইকন দেখান'; - - @override - String get about => 'বিস্তারিত'; - - @override - String get u_love_spotube => 'আমরা জানি আপনি Spotube কে ভালবাসেন'; - - @override - String get check_for_updates => 'আপডেট চেক করুন'; - - @override - String get about_spotube => 'Spotube সম্পর্কে বিস্তারিত'; - - @override - String get blacklist => 'কালো তালিকা'; - - @override - String get please_sponsor => 'স্পনসর/সহায়তা করুন'; - - @override - String get spotube_description => - 'Spotube, একটি কর্মদক্ষ, ক্রস-প্ল্যাটফর্ম, বিনামূল্যের জন্য Spotify ক্লায়েন্ট'; - - @override - String get version => 'সংস্করণ'; - - @override - String get build_number => 'বিল্ড নম্বর'; - - @override - String get founder => 'প্রতিষ্ঠাতা'; - - @override - String get repository => 'সংগ্রহস্থল'; - - @override - String get bug_issues => 'বাগ/সমস্যা'; - - @override - String get made_with => '❤️ দিয়ে বাংলাদেশে🇧🇩 তৈরি'; - - @override - String get kingkor_roy_tirtho => 'কিংকর রায় তীর্থ'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year কিংকর রায় তীর্থ'; - } - - @override - String get license => 'লাইসেন্স'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'চিন্তা করবেন না, আপনার কোনো লগইন তথ্য সংগ্রহ করা হবে না বা কারো সাথে শেয়ার করা হবে না'; - - @override - String get know_how_to_login => 'আপনি কিভাবে লগইন করবেন তা জানেন না?'; - - @override - String get follow_step_by_step_guide => 'ধাপে ধাপে নির্দেশিকা অনুসরণ করুন'; - - @override - String cookie_name_cookie(Object name) { - return '$name কুকি'; - } - - @override - String get fill_in_all_fields => 'সমস্ত ফর্ম ক্ষেত্র পূরণ করুন'; - - @override - String get submit => 'জমা দিন'; - - @override - String get exit => 'প্রস্থান'; - - @override - String get previous => 'পূর্ববর্তী'; - - @override - String get next => 'পরবর্তী'; - - @override - String get done => 'সম্পন্ন'; - - @override - String get step_1 => 'ধাপ 1'; - - @override - String get first_go_to => 'প্রথমে যান'; - - @override - String get something_went_wrong => 'কিছু ভুল হয়েছে'; - - @override - String get piped_instance => 'Piped সার্ভার এড্রেস'; - - @override - String get piped_description => 'গান ম্যাচ করার জন্য ব্যবহৃত পাইপড সার্ভার'; - - @override - String get piped_warning => - 'এগুলোর মধ্যে কিছু ভাল কাজ নাও করতে পারে৷ তাই নিজ দায়িত্বে ব্যবহার করুন'; - - @override - String get invidious_instance => 'ইনভিডিয়াস সার্ভার ইন্সটেন্স'; - - @override - String get invidious_description => - 'ট্রাক মিলানোর জন্য ব্যবহৃত ইনভিডিয়াস সার্ভার'; - - @override - String get invidious_warning => - 'কিছু সার্ভার ভাল কাজ নাও করতে পারে। নিজের ঝুঁকিতে ব্যবহার করুন'; - - @override - String get generate => 'উৎপন্ন করুন'; - - @override - String track_exists(Object track) { - return 'ট্র্যাক $track ইতিমধ্যে বিদ্যমান'; - } - - @override - String get replace_downloaded_tracks => - 'সমস্ত ডাউনলোড করা ট্র্যাক প্রতিস্থাপন করুন'; - - @override - String get skip_download_tracks => 'সমস্ত ডাউনলোড করা ট্র্যাক এ স্কিপ করুন'; - - @override - String get do_you_want_to_replace => - 'আপনি কি বিদ্যমান ট্র্যাকটি প্রতিস্থাপন করতে চান?'; - - @override - String get replace => 'প্রতিস্থাপন করুন'; - - @override - String get skip => 'স্কিপ করুন'; - - @override - String select_up_to_count_type(Object count, Object type) { - return '$count $type পর্যন্ত নির্বাচন করুন'; - } - - @override - String get select_genres => 'গানের ধরণ নির্বাচন করুন'; - - @override - String get add_genres => 'গানের ধরণ যুক্ত করুন'; - - @override - String get country => 'দেশ'; - - @override - String get number_of_tracks_generate => 'উত্পাদিত ট্র্যাকের সংখ্যা'; - - @override - String get acousticness => 'অধ্যাত্মিকতা'; - - @override - String get danceability => 'নৃত্যমূলকতা'; - - @override - String get energy => 'শক্তি'; - - @override - String get instrumentalness => 'সাধারণতা'; - - @override - String get liveness => 'জীবনমুক্ততা'; - - @override - String get loudness => 'স্বরের উচ্চতা'; - - @override - String get speechiness => 'বক্তব্যমূলকতা'; - - @override - String get valence => 'সন্তোষমূলকতা'; - - @override - String get popularity => 'জনপ্রিয়তা'; - - @override - String get key => 'কী'; - - @override - String get duration => 'সময়কাল (সেকেন্ড)'; - - @override - String get tempo => 'গতি (বিপিএম)'; - - @override - String get mode => 'মোড'; - - @override - String get time_signature => 'সময়ের স্বাক্ষর'; - - @override - String get short => 'সংক্ষিপ্ত'; - - @override - String get medium => 'মাঝারি'; - - @override - String get long => 'দীর্ঘ'; - - @override - String get min => 'সর্বনিম্ন'; - - @override - String get max => 'সর্বাধিক'; - - @override - String get target => 'লক্ষ্য'; - - @override - String get moderate => 'মাঝারি'; - - @override - String get deselect_all => 'সমস্ত অপচুন করুন'; - - @override - String get select_all => 'সমস্ত নির্বাচন করুন'; - - @override - String get are_you_sure => 'আপনি কি নিশ্চিত?'; - - @override - String get generating_playlist => 'আপনার কাস্টম প্লেলিস্ট তৈরি হচ্ছে...'; - - @override - String selected_count_tracks(Object count) { - return '$count ট্র্যাক নির্বাচিত'; - } - - @override - String get download_warning => - 'যদি আপনি সমস্ত ট্র্যাকগুলি একসঙ্গে ডাউনলোড করেন, তবে আপনি নিশ্চিতভাবে সঙ্গীত চুরি করছেন এবং সৃষ্টিশীল সমাজে ক্ষতি দিচ্ছেন। আমি আশা করি আপনি এটা সম্পর্কে জানেন। সর্বদা, শিল্পীদের কঠিন পরিশ্রমকে সম্মান করতে চেষ্টা করুন এবং সমর্থন করুন'; - - @override - String get download_ip_ban_warning => - 'তথ্যবিশ্বস্ত করে নেওয়া যায় যে, আপনার IP ঠিকানাটি YouTube দ্বারা স্থানান্তরিত করা হতে পারে যখন সাধারন থেকে বেশি ডাউনলোড অনুরোধ হয়। IP ব্লকের মাধ্যমে আপনি কমপক্ষে ২-৩ মাস ধরে (ঐ IP ডিভাইস থেকে) YouTube ব্যবহার করতে পারবেন না। এবং Spotube কোনও দায়িত্ব সম্পর্কে দায়িত্ব বহন করে না যদি এটি ঘটে।'; - - @override - String get by_clicking_accept_terms => - '\'গ্রহণ\' ক্লিক করে আপনি নিম্নলিখিত শর্তাদি স্বীকার করছেন:'; - - @override - String get download_agreement_1 => 'আমি জানি আমি সঙ্গীত চুরি করছি। আমি খারাপ'; - - @override - String get download_agreement_2 => - 'আমি কেবলমাত্র তাদের কাজ কেনার জন্য অর্থ নেই কিন্তু যেখানে প্রয়োজন সেখানে আমি শিল্পীদের সমর্থন করব।'; - - @override - String get download_agreement_3 => - 'আমি সম্পূর্ণরূপে জানি যে আমার IP YouTube-তে ব্লক হতে পারে এবং আমি Spotube বা তার মালিকানাধীন কোনও দায়িত্ব পেতে পারিনি আমার বর্তমান ক্রিয়াটি দ্বারা সৃষ্ট দুর্ঘটনা করার জন্য'; - - @override - String get decline => 'অগ্রায়ন করুন'; - - @override - String get accept => 'গ্রহণ করুন'; - - @override - String get details => 'বিস্তারিত'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'চ্যানেল'; - - @override - String get likes => 'লাইক'; - - @override - String get dislikes => 'অপছন্দ'; - - @override - String get views => 'দর্শনার্থী'; - - @override - String get streamUrl => 'স্ট্রিম URL'; - - @override - String get stop => 'বন্ধ করুন'; - - @override - String get sort_newest => 'নতুনতম অনুসারে সাজান'; - - @override - String get sort_oldest => 'পুরানোতম অনুসারে সাজান'; - - @override - String get sleep_timer => 'স্লীপ টাইমার'; - - @override - String mins(Object minutes) { - return '$minutes মিনিট'; - } - - @override - String hours(Object hours) { - return '$hours ঘন্টা'; - } - - @override - String hour(Object hours) { - return '$hours ঘন্টা'; - } - - @override - String get custom_hours => 'কাস্টম ঘন্টা'; - - @override - String get logs => 'লগ'; - - @override - String get developers => 'ডেভেলপার'; - - @override - String get not_logged_in => 'আপনি লগইন করা নেই'; - - @override - String get search_mode => 'অনুসন্ধান মোড'; - - @override - String get audio_source => 'অডিও উৎস'; - - @override - String get ok => 'ঠিক আছে'; - - @override - String get failed_to_encrypt => 'এনক্রিপ্ট করা ব্যর্থ হয়েছে'; - - @override - String get encryption_failed_warning => - 'Spotube আপনার তথ্যগুলি নিরাপদভাবে স্টোর করতে এনক্রিপশন ব্যবহার করে। কিন্তু এটি ব্যর্থ হয়েছে। তাই এটি অনিরাপদ স্টোরে ফলফল হবে\nযদি আপনি Linux ব্যবহার করেন, তবে দয়া করে নিশ্চিত হউন যে আপনার কোনও সিক্রেট-সার্ভিস gnome-keyring, kde-wallet, keepassxc ইত্যাদি ইনস্টল করা আছে'; - - @override - String get querying_info => 'তথ্য অনুসন্ধান করা হচ্ছে'; - - @override - String get piped_api_down => 'পাইপড API ডাউন আছে'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'বর্তমানে পাইপড ইনস্ট্যান্স $pipedInstance ডাউন আছে\n\nইনস্ট্যান্স পরিবর্তন করুন অথবা \'API টাইপ\' পরিবর্তন করুন অফিসিয়াল ইউটিউব API হতে\n\nপরিবর্তনের পরে অ্যাপটি পুনরায় চালানোর নিশ্চিত করুন'; - } - - @override - String get you_are_offline => 'আপনি বর্তমানে অফলাইন'; - - @override - String get connection_restored => 'আপনার ইন্টারনেট সংযোগ পুনরুদ্ধার হয়েছে'; - - @override - String get use_system_title_bar => 'সিস্টেম শিরোনাম বার ব্যবহার করুন'; - - @override - String get crunching_results => 'ফলাফল বিশ্লেষণ করা হচ্ছে...'; - - @override - String get search_to_get_results => 'ফলাফল পেতে খোঁজ করুন'; - - @override - String get use_amoled_mode => 'AMOLED মোড ব্যবহার করুন'; - - @override - String get pitch_dark_theme => 'পিচ ব্ল্যাক ডার্ট থিম'; - - @override - String get normalize_audio => 'অডিও স্তরমান করুন'; - - @override - String get change_cover => 'কভার পরিবর্তন করুন'; - - @override - String get add_cover => 'কভার যোগ করুন'; - - @override - String get restore_defaults => 'ডিফল্ট সেটিংস পুনরুদ্ধার করুন'; - - @override - String get download_music_format => 'গান ডাউনলোডের বিন্যাস'; - - @override - String get streaming_music_format => 'গান স্ট্রিমিং এর বিন্যাস'; - - @override - String get download_music_quality => 'গান ডাউনলোডের মান'; - - @override - String get streaming_music_quality => 'গান স্ট্রিমিং এর মান'; - - @override - String get login_with_lastfm => 'Last.fm দিয়ে লগইন করুন'; - - @override - String get connect => 'সংযোগ করুন'; - - @override - String get disconnect_lastfm => 'Last.fm সংযোগ বিচ্ছিন্ন করুন'; - - @override - String get disconnect => 'সংযোগ বিচ্ছিন্ন করুন'; - - @override - String get username => 'ব্যবহারকারীর নাম'; - - @override - String get password => 'পাসওয়ার্ড'; - - @override - String get login => 'লগইন'; - - @override - String get login_with_your_lastfm => - 'আপনার Last.fm অ্যাকাউন্ট দিয়ে লগইন করুন'; - - @override - String get scrobble_to_lastfm => 'Last.fm এ স্ক্রবল করুন'; - - @override - String get go_to_album => 'الانتقال إلى الألبوم'; - - @override - String get discord_rich_presence => 'وجود ديسكورد الغني'; - - @override - String get browse_all => 'تصفح الكل'; - - @override - String get genres => 'الأنواع الموسيقية'; - - @override - String get explore_genres => 'استكشاف الأنواع'; - - @override - String get friends => 'বন্ধু'; - - @override - String get no_lyrics_available => - 'দুঃখিত, এই ট্র্যাকের জন্য কথা খুঁজে পাওয়া গেলনা'; - - @override - String get start_a_radio => 'রেডিও শুরু করুন'; - - @override - String get how_to_start_radio => 'রেডিও কিভাবে শুরু করতে চান?'; - - @override - String get replace_queue_question => - 'আপনি বর্তমান কিউটি প্রতিস্থাপন করতে চান কিনা বা এর সাথে যুক্ত করতে চান?'; - - @override - String get endless_playback => 'অবিরাম প্রচার'; - - @override - String get delete_playlist => 'প্লেলিস্ট মুছুন'; - - @override - String get delete_playlist_confirmation => - 'আপনি কি নিশ্চিত যে আপনি এই প্লেলিস্টটি মুছতে চান?'; - - @override - String get local_tracks => 'স্থানীয় ট্র্যাক'; - - @override - String get local_tab => 'স্থানীয়'; - - @override - String get song_link => 'গানের লিংক'; - - @override - String get skip_this_nonsense => 'এই বাকবাস পালান'; - - @override - String get freedom_of_music => '“সংগীতের স্বাধীনতা”'; - - @override - String get freedom_of_music_palm => '“তোমার হাতের কাছে সংগীতের স্বাধীনতা”'; - - @override - String get get_started => 'শুরু করা যাক'; - - @override - String get youtube_source_description => 'প্রস্তাবিত এবং সেরা কাজ করে।'; - - @override - String get piped_source_description => 'মন খারাপ? ইউটিউবের মতো আবার ফ্রি।'; - - @override - String get jiosaavn_source_description => 'দক্ষিণ এশিয়ান অঞ্চলের জন্য সেরা।'; - - @override - String get invidious_source_description => - 'পাইপের মতো কিন্তু আরও বেশি উপলব্ধতা সহ'; - - @override - String highest_quality(Object quality) { - return 'সর্বোচ্চ গুণগতি: $quality'; - } - - @override - String get select_audio_source => 'অডিও উৎস নির্বাচন করুন'; - - @override - String get endless_playback_description => - 'নতুন গান নিজে নিজে প্লেলিস্টের শেষে\nসংযুক্ত করুন'; - - @override - String get choose_your_region => 'আপনার অঞ্চল নির্বাচন করুন'; - - @override - String get choose_your_region_description => - 'এটি স্পটুবে আপনাকে আপনার অবস্থানের জন্য ঠিক কন্টেন্ট দেখানোর সাহায্য করবে।'; - - @override - String get choose_your_language => 'আপনার ভাষা নির্বাচন করুন'; - - @override - String get help_project_grow => 'এই প্রকল্পের বৃদ্ধি করুন'; - - @override - String get help_project_grow_description => - 'স্পটুব একটি ওপেন সোর্স প্রকল্প। আপনি প্রকল্পে অবদান রাখেন, বাগ রিপোর্ট করেন, বা নতুন বৈশিষ্ট্যগুলি সুপারিশ করেন।'; - - @override - String get contribute_on_github => 'গিটহাবে অবদান রাখুন'; - - @override - String get donate_on_open_collective => 'ওপেন কলেক্টিভে অনুদান করুন'; - - @override - String get browse_anonymously => 'অজানে ব্রাউজ করুন'; - - @override - String get enable_connect => 'সংযোগ সক্রিয় করুন'; - - @override - String get enable_connect_description => - 'অন্যান্য ডিভাইস থেকে Spotube নিয়ন্ত্রণ করুন'; - - @override - String get devices => 'ডিভাইস'; - - @override - String get select => 'নির্বাচন করুন'; - - @override - String connect_client_alert(Object client) { - return 'আপনি $client দ্বারা নিয়ন্ত্রিত হচ্ছেন'; - } - - @override - String get this_device => 'এই ডিভাইস'; - - @override - String get remote => 'রিমোট'; - - @override - String get stats => 'পরিসংখ্যান'; - - @override - String and_n_more(Object count) { - return 'এবং $count আরও'; - } - - @override - String get recently_played => 'সম্প্রতি বাজানো'; - - @override - String get browse_more => 'আরও ব্রাউজ করুন'; - - @override - String get no_title => 'কোনো শিরোনাম নেই'; - - @override - String get not_playing => 'চালানো হচ্ছে না'; - - @override - String get epic_failure => 'বিরাট ব্যর্থতা!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return '$tracks_length ট্র্যাক সারিতে যোগ করা হয়েছে'; - } - - @override - String get spotube_has_an_update => 'স্পটিউবে একটি আপডেট আছে'; - - @override - String get download_now => 'এখনই ডাউনলোড করুন'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'স্পটিউব নাইটলি $nightlyBuildNum প্রকাশিত হয়েছে'; - } - - @override - String release_version(Object version) { - return 'স্পটিউব v$version প্রকাশিত হয়েছে'; - } - - @override - String get read_the_latest => 'সর্বশেষ পড়ুন'; - - @override - String get release_notes => 'রিলিজ নোট'; - - @override - String get pick_color_scheme => 'রঙের থিম নির্বাচন করুন'; - - @override - String get save => 'সংরক্ষণ করুন'; - - @override - String get choose_the_device => 'ডিভাইস নির্বাচন করুন:'; - - @override - String get multiple_device_connected => - 'একাধিক ডিভাইস সংযুক্ত রয়েছে।\nযে ডিভাইসে আপনি এই ক্রিয়াটি চালাতে চান সেটি নির্বাচন করুন'; - - @override - String get nothing_found => 'কিছুই পাওয়া যায়নি'; - - @override - String get the_box_is_empty => 'বাক্সটি খালি'; - - @override - String get top_artists => 'শীর্ষ শিল্পী'; - - @override - String get top_albums => 'শীর্ষ অ্যালবাম'; - - @override - String get this_week => 'এই সপ্তাহ'; - - @override - String get this_month => 'এই মাস'; - - @override - String get last_6_months => 'গত ৬ মাস'; - - @override - String get this_year => 'এই বছর'; - - @override - String get last_2_years => 'গত ২ বছর'; - - @override - String get all_time => 'সব সময়'; - - @override - String powered_by_provider(Object providerName) { - return '$providerName দ্বারা চালিত'; - } - - @override - String get email => 'ইমেইল'; - - @override - String get profile_followers => 'অনুসারী'; - - @override - String get birthday => 'জন্মদিন'; - - @override - String get subscription => 'সাবস্ক্রিপশন'; - - @override - String get not_born => 'জন্মগ্রহণ করেনি'; - - @override - String get hacker => 'হ্যাকার'; - - @override - String get profile => 'প্রোফাইল'; - - @override - String get no_name => 'কোন নাম নেই'; - - @override - String get edit => 'সম্পাদনা করুন'; - - @override - String get user_profile => 'ব্যবহারকারীর প্রোফাইল'; - - @override - String count_plays(Object count) { - return '$count বার প্লে হয়েছে'; - } - - @override - String get streaming_fees_hypothetical => 'স্ট্রিমিং ফি (ধারণাগত)'; - - @override - String get minutes_listened => 'শুনেছেন মিনিট'; - - @override - String get streamed_songs => 'স্ট্রিম করা গান'; - - @override - String count_streams(Object count) { - return '$count বার স্ট্রিম'; - } - - @override - String get owned_by_you => 'আপনার মালিকানাধীন'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl ক্লিপবোর্ডে কপি করা হয়েছে'; - } - - @override - String get hipotetical_calculation => - '*এটি নিরূপণ করা হয়েছে গড় অনলাইন মিউজিক স্ট্রিমিং প্ল্যাটফর্মের প্রতি স্ট্রিম 0.003–0.005 USD পেআউটের ভিত্তিতে। এটি একটি কাল্পনিক হিসাব যা ব্যবহারকারীকে ধারণা দিতে পারে তারা অন্যান্য স্ট্রিমিং প্ল্যাটফর্মে একই গান শোনার জন্য শিল্পীদের কত টাকা দিয়েছেন হোক।'; - - @override - String count_mins(Object minutes) { - return '$minutes মিনিট'; - } - - @override - String get summary_minutes => 'মিনিট'; - - @override - String get summary_listened_to_music => 'সঙ্গীত শুনেছেন'; - - @override - String get summary_songs => 'গান'; - - @override - String get summary_streamed_overall => 'মোট স্ট্রিম'; - - @override - String get summary_owed_to_artists => 'এই মাসে\nশিল্পীদেরকে ঋণী'; - - @override - String get summary_artists => 'শিল্পীর'; - - @override - String get summary_music_reached_you => 'আপনার কাছে পৌঁছেছে সঙ্গীত'; - - @override - String get summary_full_albums => 'সম্পূর্ণ অ্যালবাম'; - - @override - String get summary_got_your_love => 'আপনার ভালোবাসা পেয়েছে'; - - @override - String get summary_playlists => 'প্লেলিস্ট'; - - @override - String get summary_were_on_repeat => 'পুনরাবৃত্তিতে ছিল'; - - @override - String total_money(Object money) { - return 'মোট $money'; - } - - @override - String get webview_not_found => 'ওয়েবভিউ পাওয়া যায়নি'; - - @override - String get webview_not_found_description => - 'আপনার ডিভাইসে কোনো ওয়েবভিউ রানটাইম ইনস্টল করা নেই।\nযদি ইনস্টল থাকে, তা নিশ্চিত করুন যে এটি environment PATH এ রয়েছে\n\nইনস্টল করার পর, অ্যাপটি পুনরায় চালু করুন'; - - @override - String get unsupported_platform => 'সমর্থিত প্ল্যাটফর্ম নয়'; - - @override - String get cache_music => 'ক্যাশে সংগীত'; - - @override - String get open => 'খুলুন'; - - @override - String get cache_folder => 'ক্যাশে ফোল্ডার'; - - @override - String get export => 'রপ্তানি'; - - @override - String get clear_cache => 'ক্যাশে পরিষ্কার'; - - @override - String get clear_cache_confirmation => 'আপনি কি ক্যাশে পরিষ্কার করতে চান?'; - - @override - String get export_cache_files => 'ক্যাশে ফাইল রপ্তানি'; - - @override - String found_n_files(Object count) { - return '$count টি ফাইল পাওয়া গেছে'; - } - - @override - String get export_cache_confirmation => - 'আপনি কি এই ফাইলগুলি রপ্তানি করতে চান'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return '$filesExported টি ফাইল রপ্তানি করা হয়েছে $files এর মধ্যে'; - } - - @override - String get undo => 'পূর্বাবস্থায় ফিরুন'; - - @override - String get download_all => 'সব ডাউনলোড করুন'; - - @override - String get add_all_to_playlist => 'সব প্লেলিস্টে যোগ করুন'; - - @override - String get add_all_to_queue => 'সব কিউতে যোগ করুন'; - - @override - String get play_all_next => 'সব পরবর্তী খেলুন'; - - @override - String get pause => 'বিরতি'; - - @override - String get view_all => 'সব দেখুন'; - - @override - String get no_tracks_added_yet => 'এখনও কোনো ট্র্যাক যোগ করা হয়নি মনে হচ্ছে'; - - @override - String get no_tracks => 'এখানে কোনো ট্র্যাক নেই মনে হচ্ছে'; - - @override - String get no_tracks_listened_yet => 'এখনও কিছু শোনা হয়নি মনে হচ্ছে'; - - @override - String get not_following_artists => 'আপনি কোনো শিল্পীকে অনুসরণ করছেন না'; - - @override - String get no_favorite_albums_yet => - 'এখনও কোনো অ্যালবাম প্রিয় তালিকায় যোগ করা হয়নি মনে হচ্ছে'; - - @override - String get no_logs_found => 'কোনো লগ পাওয়া যায়নি'; - - @override - String get youtube_engine => 'ইউটিউব ইঞ্জিন'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine ইনস্টল করা নেই'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine আপনার সিস্টেমে ইনস্টল করা নেই।'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'এটি PATH ভেরিয়েবলে উপলব্ধ কিনা নিশ্চিত করুন অথবা\nনীচে $engine এক্সিকিউটেবল এর পূর্ণপথ সেট করুন'; - } - - @override - String get youtube_engine_unix_issue_message => - 'macOS/Linux/Unix-এর মতো অপারেটিং সিস্টেমে, .zshrc/.bashrc/.bash_profile ইত্যাদিতে পাথ সেট করা কাজ করবে না।\nআপনাকে শেল কনফিগারেশন ফাইলে পাথ সেট করতে হবে'; - - @override - String get download => 'ডাউনলোড'; - - @override - String get file_not_found => 'ফাইল পাওয়া যায়নি'; - - @override - String get custom => 'কাস্টম'; - - @override - String get add_custom_url => 'কাস্টম URL যোগ করুন'; - - @override - String get edit_port => 'পোর্ট সম্পাদনা করুন'; - - @override - String get port_helper_msg => - 'ডিফল্ট হল -1 যা এলোমেলো সংখ্যা নির্দেশ করে। যদি আপনার ফায়ারওয়াল কনফিগার করা থাকে, তবে এটি সেট করা সুপারিশ করা হয়।'; - - @override - String connect_request(Object client) { - return '$client কে সংযোগ করতে অনুমতি দেবেন?'; - } - - @override - String get connection_request_denied => - 'সংযোগ অস্বীকৃত। ব্যবহারকারী প্রবেশাধিকার অস্বীকার করেছে।'; - - @override - String get an_error_occurred => 'একটি ত্রুটি ঘটেছে'; - - @override - String get copy_to_clipboard => 'ক্লিপবোর্ডে কপি করুন'; - - @override - String get view_logs => 'লগ দেখুন'; - - @override - String get retry => 'পুনরায় চেষ্টা করুন'; - - @override - String get no_default_metadata_provider_selected => - 'আপনি কোনো ডিফল্ট মেটাডেটা প্রদানকারী সেট করেননি'; - - @override - String get manage_metadata_providers => 'মেটাডেটা প্রদানকারীগণ পরিচালনা করুন'; - - @override - String get open_link_in_browser => 'লিংকটি ব্রাউজারে খুলবেন?'; - - @override - String get do_you_want_to_open_the_following_link => - 'নিচের লিংকটি খুলতে চান?'; - - @override - String get unsafe_url_warning => - 'অবিশ্বাসযোগ্য উৎস থেকে লিংক খোলা নিরাপদ নাও হতে পারে। সতর্ক থাকুন!\nআপনি এটি ক্লিপবোর্ডে কপি করতে পারেন।'; - - @override - String get copy_link => 'লিংক কপি করুন'; - - @override - String get building_your_timeline => - 'আপনার শোনার ধারা অনুযায়ী টাইমলাইন তৈরি করা হচ্ছে...'; - - @override - String get official => 'সরকারি'; - - @override - String author_name(Object author) { - return 'লেখক: $author'; - } - - @override - String get third_party => 'তৃতীয় পক্ষ'; - - @override - String get plugin_requires_authentication => 'প্লাগইনটি প্রমাণীকরণ প্রয়োজন'; - - @override - String get update_available => 'হালনাগাদ উপলব্ধ'; - - @override - String get supports_scrobbling => 'স্ক্রোব্বলিং সমর্থিত'; - - @override - String get plugin_scrobbling_info => - 'এই প্লাগইনটি আপনার সঙ্গীত স্ক্রোব্বল করে আপনার শোনা ইতিহাস তৈরি করে।'; - - @override - String get default_metadata_source => 'ডিফল্ট মেটাডেটা উৎস'; - - @override - String get set_default_metadata_source => 'ডিফল্ট মেটাডেটা উৎস সেট করুন'; - - @override - String get default_audio_source => 'ডিফল্ট অডিও উৎস'; - - @override - String get set_default_audio_source => 'ডিফল্ট অডিও উৎস সেট করুন'; - - @override - String get set_default => 'ডিফল্ট হিসাবে নির্ধারণ করুন'; - - @override - String get support => 'সমর্থন'; - - @override - String get support_plugin_development => 'প্লাগইন উন্নয়নকে সমর্থন করুন'; - - @override - String can_access_name_api(Object name) { - return '- **$name** API-তে অ্যাক্সেস করতে পারে'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'আপনি কি এই প্লাগইন ইনস্টল করতে চান?'; - - @override - String get third_party_plugin_warning => - 'এই প্লাগইন একটি তৃতীয় পক্ষের রেপোজিটরির। ইনস্টল করার আগে উৎস বিশ্বস্ত কিনা নিশ্চিত করুন।'; - - @override - String get author => 'লেখক'; - - @override - String get this_plugin_can_do_following => 'এই প্লাগইন নিচের কাজ করতে পারে'; - - @override - String get install => 'ইনস্টল করুন'; - - @override - String get install_a_metadata_provider => - 'একটি মেটাডেটা প্রদানকারী ইনস্টল করুন'; - - @override - String get no_tracks_playing => 'বর্তমানে কোনো ট্র্যাক শোনা হচ্ছে না'; - - @override - String get synced_lyrics_not_available => - 'এই গানের জন্য সিঙ্ক্রোনাইজড লিরিক্স পাওয়া যায় না। অনুগ্রহ করে ব্যবহার করুন'; - - @override - String get plain_lyrics => 'সহজ লিরিক্স'; - - @override - String get tab_instead => 'তার পরিবর্তে ট্যাব ব্যবহার করুন।'; - - @override - String get disclaimer => 'অস্বীকৃতি'; - - @override - String get third_party_plugin_dmca_notice => - 'Spotube দল কোনো “তৃতীয় পক্ষ” প্লাগইনের জন্য কোনো (আইনগত সহ) দায়িত্ব নেয় না। নিজের বিপদে ব্যবহার করুন। কোনো বাগ/সমস্যা হলে প্লাগইন রেপোজিটরিতে জানাতে অনুরোধ করা হচ্ছে।\n\nযদি কোনো “তৃতীয় পক্ষ” প্লাগইন কোনো পরিষেবা/আইনগত সংস্থার ToS/DMCA ভূঙ্গ করে, অনুগ্রহ করে “তৃতীয় পক্ষ” প্লাগইনের লেখক বা হোস্টিং প্ল্যাটফর্মে (যেমন GitHub/Codeberg) পদক্ষেপ নিতে বলুন। “তৃতীয় পক্ষ” লেবেলযুক্ত যুক্তিগুলি সকলই পাবলিক/কমিউনিটি দ্বারা রক্ষণাবেক্ষণ করা হয়; আমরা সেগুলি কিউরেট করি না, তাই আমরা কোনো পদক্ষেপ নিতে পারি না।\n\n'; - - @override - String get input_does_not_match_format => - 'ইনপুট প্রয়োজনীয় ফরম্যাটের সাথে মেলে না'; - - @override - String get plugins => 'প্লাগইন'; - - @override - String get paste_plugin_download_url => - 'ডাউনলোড URL বা GitHub/Codeberg রিপো URL বা .smplug ফাইলের সরাসরি লিঙ্ক পেস্ট করুন'; - - @override - String get download_and_install_plugin_from_url => - 'URL থেকে প্লাগইন ডাউনলোড এবং ইনস্টল করুন'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'প্লাগইন যোগ করতে ব্যর্থ: $error'; - } - - @override - String get upload_plugin_from_file => 'ফাইল থেকে প্লাগইন আপলোড করুন'; - - @override - String get installed => 'ইনস্টল করা হয়েছে'; - - @override - String get available_plugins => 'উপলব্ধ প্লাগইনগুলো'; - - @override - String get configure_plugins => - 'আপনার নিজের মেটাডেটা প্রদানকারী এবং অডিও উৎস প্লাগইন কনফিগার করুন'; - - @override - String get audio_scrobblers => 'অডিও স্ক্রোব্বলার্স'; - - @override - String get scrobbling => 'স্ক্রোব্বলিং'; - - @override - String get source => 'উৎস: '; - - @override - String get uncompressed => 'অ-সংকুচিত'; - - @override - String get dab_music_source_description => - 'অডিওফাইলদের জন্য। উচ্চ-মানের/লসলেস অডিও স্ট্রিম প্রদান করে। সঠিক ISRC ভিত্তিক ট্র্যাক ম্যাচিং।'; -} diff --git a/lib/l10n/generated/app_localizations_ca.dart b/lib/l10n/generated/app_localizations_ca.dart deleted file mode 100644 index a4f587c9..00000000 --- a/lib/l10n/generated/app_localizations_ca.dart +++ /dev/null @@ -1,1579 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Catalan Valencian (`ca`). -class AppLocalizationsCa extends AppLocalizations { - AppLocalizationsCa([String locale = 'ca']) : super(locale); - - @override - String get guest => 'Convidat'; - - @override - String get browse => 'Explorar'; - - @override - String get search => 'Cercar'; - - @override - String get library => 'Biblioteca'; - - @override - String get lyrics => 'Lletres'; - - @override - String get settings => 'Configuració'; - - @override - String get genre_categories_filter => 'Filtrar categories o gèneres...'; - - @override - String get genre => 'Gènere'; - - @override - String get personalized => 'Personalizat'; - - @override - String get featured => 'Destacat'; - - @override - String get new_releases => 'Nous Llançaments'; - - @override - String get songs => 'Cançons'; - - @override - String playing_track(Object track) { - return 'Reproduint $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Això eliminarà la llista actual. S\'eliminaran $track_length cançons.\n¿Vol continuar?'; - } - - @override - String get load_more => 'Carregar més'; - - @override - String get playlists => 'Llistes de reproducció'; - - @override - String get artists => 'Artistes'; - - @override - String get albums => 'Àlbums'; - - @override - String get tracks => 'Cançons'; - - @override - String get downloads => 'Descàrregues'; - - @override - String get filter_playlists => 'Filtrar les seves llistes de reproducció...'; - - @override - String get liked_tracks => 'Cançons Preferides'; - - @override - String get liked_tracks_description => 'Totes les seves cançons preferides'; - - @override - String get playlist => 'Llista de reproducció'; - - @override - String get create_a_playlist => 'Crear una llista de reproducció'; - - @override - String get update_playlist => 'Actualitzar la llista de reproducció'; - - @override - String get create => 'Crear'; - - @override - String get cancel => 'Cancel·lar'; - - @override - String get update => 'Actualitzar'; - - @override - String get playlist_name => 'Nom de la llista'; - - @override - String get name_of_playlist => 'Nom de la lista'; - - @override - String get description => 'Descripció'; - - @override - String get public => 'Pública'; - - @override - String get collaborative => 'Col·laborativa'; - - @override - String get search_local_tracks => 'Cercar cançons locals...'; - - @override - String get play => 'Reproduir'; - - @override - String get delete => 'Eliminar'; - - @override - String get none => 'Cap'; - - @override - String get sort_a_z => 'Ordenar de la A a la Z'; - - @override - String get sort_z_a => 'Ordenar de la Z a la A'; - - @override - String get sort_artist => 'Ordenar per Artista'; - - @override - String get sort_album => 'Ordenar per Àlbum'; - - @override - String get sort_duration => 'Ordenar per Durada'; - - @override - String get sort_tracks => 'Ordenar Cançons'; - - @override - String currently_downloading(Object tracks_length) { - return 'Descàrrega en curs ($tracks_length)'; - } - - @override - String get cancel_all => 'Cancel·lar todo'; - - @override - String get filter_artist => 'Filtrar artistes...'; - - @override - String followers(Object followers) { - return '$followers Seguidors'; - } - - @override - String get add_artist_to_blacklist => 'Afegir artista a la llista negra'; - - @override - String get top_tracks => 'Millors Cançons'; - - @override - String get fans_also_like => 'Als fans també els hi agrada'; - - @override - String get loading => 'Carregant...'; - - @override - String get artist => 'Artista'; - - @override - String get blacklisted => 'A la llista negra'; - - @override - String get following => 'Seguint'; - - @override - String get follow => 'Seguir'; - - @override - String get artist_url_copied => 'URL de l\'artista copiada al porta-retalls '; - - @override - String added_to_queue(Object tracks) { - return '$tracks cançons afegides a la llista'; - } - - @override - String get filter_albums => 'Filtrar àlbums...'; - - @override - String get synced => 'Sincronitzat'; - - @override - String get plain => 'Normal'; - - @override - String get shuffle => 'Aleatori'; - - @override - String get search_tracks => 'Buscar cançons...'; - - @override - String get released => 'Publicat'; - - @override - String error(Object error) { - return 'Error $error'; - } - - @override - String get title => 'Títul'; - - @override - String get time => 'Duració'; - - @override - String get more_actions => 'Més accios'; - - @override - String download_count(Object count) { - return 'Descarregar ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Afegir ($count) a la llista de reproducció'; - } - - @override - String add_count_to_queue(Object count) { - return 'Agregar ($count) a la llista'; - } - - @override - String play_count_next(Object count) { - return 'Reproduir ($count) a continuació'; - } - - @override - String get album => 'Àlbum'; - - @override - String copied_to_clipboard(Object data) { - return '$data copiado al porta-retalls'; - } - - @override - String add_to_following_playlists(Object track) { - return 'Afegir $track a les llistes de reproducció següents'; - } - - @override - String get add => 'Afegir'; - - @override - String added_track_to_queue(Object track) { - return '$track afegida a la llista'; - } - - @override - String get add_to_queue => 'Afegir a la llista'; - - @override - String track_will_play_next(Object track) { - return '$track es reproduirà a continuació'; - } - - @override - String get play_next => 'Reproduir a continuació'; - - @override - String removed_track_from_queue(Object track) { - return '$track eliminada de la llista'; - } - - @override - String get remove_from_queue => 'Eliminar de la llista'; - - @override - String get remove_from_favorites => 'Eliminar de preferits'; - - @override - String get save_as_favorite => 'Guardar a preferits'; - - @override - String get add_to_playlist => 'Afegir a la llista de reproducció'; - - @override - String get remove_from_playlist => 'Eliminar de la llista de reproducció'; - - @override - String get add_to_blacklist => 'Afegir a la llista negra'; - - @override - String get remove_from_blacklist => 'Eliminar de la llista negra'; - - @override - String get share => 'Compartir'; - - @override - String get mini_player => 'Reproductor Petit'; - - @override - String get slide_to_seek => 'Lliscar per cercar endavant o endarrere'; - - @override - String get shuffle_playlist => 'Mesclar la llista de reproducció'; - - @override - String get unshuffle_playlist => 'No mesclar la llista de reproducció'; - - @override - String get previous_track => 'Cançó anterior'; - - @override - String get next_track => 'Canço següent'; - - @override - String get pause_playback => 'Pausar reproducció'; - - @override - String get resume_playback => 'Continuar reproducció'; - - @override - String get loop_track => 'Repetir canço'; - - @override - String get no_loop => 'Sense repetició'; - - @override - String get repeat_playlist => 'Repetir la llista de reproducció'; - - @override - String get queue => 'Llista'; - - @override - String get alternative_track_sources => 'Fonts alternatives de cançons'; - - @override - String get download_track => 'Descarregar cançó'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks cançons a la llista'; - } - - @override - String get clear_all => 'Netejar tot'; - - @override - String get show_hide_ui_on_hover => - 'Mostrar/Ocultar interfície al passar el cursor'; - - @override - String get always_on_top => 'Sempre visible'; - - @override - String get exit_mini_player => 'Sortir del reproductor petit'; - - @override - String get download_location => 'Ubicació de descàrregues'; - - @override - String get local_library => 'Biblioteca local'; - - @override - String get add_library_location => 'Afegeix a la biblioteca'; - - @override - String get remove_library_location => 'Elimina de la biblioteca'; - - @override - String get account => 'Compte'; - - @override - String get logout => 'Tancar sessió'; - - @override - String get logout_of_this_account => 'Tancar sessió d\'aquest compte'; - - @override - String get language_region => 'Idioma i Regió'; - - @override - String get language => 'Idioma'; - - @override - String get system_default => 'Predeterminat del sistema'; - - @override - String get market_place_region => 'Regió de la botiga'; - - @override - String get recommendation_country => 'País de recomanació'; - - @override - String get appearance => 'Apariència'; - - @override - String get layout_mode => 'Mode de disseny'; - - @override - String get override_layout_settings => - 'Anul·leu la configuració del mode de disseny responsiu'; - - @override - String get adaptive => 'Adaptable'; - - @override - String get compact => 'Compacte'; - - @override - String get extended => 'Extès'; - - @override - String get theme => 'Tema'; - - @override - String get dark => 'Fosc'; - - @override - String get light => 'Clar'; - - @override - String get system => 'Sistema'; - - @override - String get accent_color => 'Color d\'accent'; - - @override - String get sync_album_color => 'Sincronitzar color de l\'àlbum'; - - @override - String get sync_album_color_description => - 'Utilitza el color dominant de l\'álbum com a color d\'accent'; - - @override - String get playback => 'Reproducció'; - - @override - String get audio_quality => 'Qualitat d\'àudio'; - - @override - String get high => 'Alta'; - - @override - String get low => 'Baixa'; - - @override - String get pre_download_play => 'Descàrrega prèvia i reproduir'; - - @override - String get pre_download_play_description => - 'En lloc de transmetre l\'àudio, descarrega bytes i ho reprodueix (recomendat per usuaris amb un bon ample de banda)'; - - @override - String get skip_non_music => - 'Ometre segments que no son música (SponsorBlock)'; - - @override - String get blacklist_description => 'Cançons i artistes de la llista negra'; - - @override - String get wait_for_download_to_finish => - 'Si us plau, esperi que acabi la descàrrega actual'; - - @override - String get desktop => 'Escriptori'; - - @override - String get close_behavior => 'Comportament al tancar'; - - @override - String get close => 'Tancar'; - - @override - String get minimize_to_tray => 'Minimizar a la safata del sistema'; - - @override - String get show_tray_icon => 'Mostrar icona a la safata del sistema'; - - @override - String get about => 'Sobre'; - - @override - String get u_love_spotube => 'Sabem que li encanta Spotube'; - - @override - String get check_for_updates => 'Buscar actualitzacions'; - - @override - String get about_spotube => 'Sobre Spotube'; - - @override - String get blacklist => 'Llista negra'; - - @override - String get please_sponsor => 'Si us plau, patrocina/dona'; - - @override - String get spotube_description => - 'Spotube, un client lleuger, multiplataforma i gratuït de Spotify'; - - @override - String get version => 'Versió'; - - @override - String get build_number => 'Número de compilació'; - - @override - String get founder => 'Fundador'; - - @override - String get repository => 'Repositori'; - - @override - String get bug_issues => 'Errors i problemes'; - - @override - String get made_with => 'Fet amb ❤️ a Bangladesh🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Llicència'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'No es preocupi, les seves credencials no seran recollides ni compartides amb ningú'; - - @override - String get know_how_to_login => 'No sap com fer-ho?'; - - @override - String get follow_step_by_step_guide => 'Segueixi la guia pas a pas'; - - @override - String cookie_name_cookie(Object name) { - return 'Cookie $name'; - } - - @override - String get fill_in_all_fields => 'Si us plau, completi tots els camps'; - - @override - String get submit => 'Enviar'; - - @override - String get exit => 'Sortir'; - - @override - String get previous => 'Anterior'; - - @override - String get next => 'Següent'; - - @override - String get done => 'Fet'; - - @override - String get step_1 => 'Pas 1'; - - @override - String get first_go_to => 'Primer, vagi a'; - - @override - String get something_went_wrong => 'Quelcom ha sortit malament'; - - @override - String get piped_instance => 'Instància del servidor Piped'; - - @override - String get piped_description => - 'La instància del servidor Piped a utilitzar per la coincidència de cançons'; - - @override - String get piped_warning => - 'Algunes poden no funcionar bé, utilitzi-les sota el seu propi risc'; - - @override - String get invidious_instance => 'Instància del servidor Invidious'; - - @override - String get invidious_description => - 'La instància del servidor Invidious per fer coincidir pistes'; - - @override - String get invidious_warning => - 'Algunes instàncies podrien no funcionar bé. Feu-les servir sota la vostra responsabilitat'; - - @override - String get generate => 'Generar'; - - @override - String track_exists(Object track) { - return 'La cançó $track ja existeix'; - } - - @override - String get replace_downloaded_tracks => - 'Substituir totes les cançons descarregades'; - - @override - String get skip_download_tracks => - 'Ometre la descàrrega de totes les cançons descarregades'; - - @override - String get do_you_want_to_replace => 'Vol substituir la cançó existent?'; - - @override - String get replace => 'Substituir'; - - @override - String get skip => 'Ometre'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Seleccionar fins$count $type'; - } - - @override - String get select_genres => 'Seleccionar Gèneres'; - - @override - String get add_genres => 'Afegir Gèneres'; - - @override - String get country => 'País'; - - @override - String get number_of_tracks_generate => 'Número de cançons a generar'; - - @override - String get acousticness => 'Acústica'; - - @override - String get danceability => 'Ballabilitat'; - - @override - String get energy => 'Energia'; - - @override - String get instrumentalness => 'Instrumental'; - - @override - String get liveness => 'En viu'; - - @override - String get loudness => 'Sonoritat'; - - @override - String get speechiness => 'Parla'; - - @override - String get valence => 'Valencia'; - - @override - String get popularity => 'Popularidad'; - - @override - String get key => 'To'; - - @override - String get duration => 'Duració (s)'; - - @override - String get tempo => 'Tempo (BPM)'; - - @override - String get mode => 'Mode'; - - @override - String get time_signature => 'Signatura de temps'; - - @override - String get short => 'Curt'; - - @override - String get medium => 'Mig'; - - @override - String get long => 'Llarg'; - - @override - String get min => 'Mín.'; - - @override - String get max => 'Màx.'; - - @override - String get target => 'Objetiu'; - - @override - String get moderate => 'Moderat'; - - @override - String get deselect_all => 'Desseleccionar tot'; - - @override - String get select_all => 'Seleccionar tot'; - - @override - String get are_you_sure => 'Està segur?'; - - @override - String get generating_playlist => - 'Generant la seva llista de reproducció personalitzada...'; - - @override - String selected_count_tracks(Object count) { - return 'Cançons $count seleccionades'; - } - - @override - String get download_warning => - 'Si descarrega totes les cançons de cop, està piratejant música clarament i causant dany a la societat creativa de la música. Espero que sigui conscient d\'això i sempre intenti respectar i recolzar la forta feina dels artístes'; - - @override - String get download_ip_ban_warning => - 'Per cert, la seva IP pot ser bloquejada a YouTube degut a solicituds de descàrrega excessives. El bloqueig d\'IP vol dir que no podrà utilitzar YouTube (fins i tot si ha iniciat sessió) durant un mínim de 2-3 meses desde esa dirección IP. I Spotube no es fa responsable si això succeeix en alguna ocasió'; - - @override - String get by_clicking_accept_terms => - 'Al fer clic a \'Acceptar\', acepta els següents termes:'; - - @override - String get download_agreement_1 => - 'Se que estic piratejant música. Sóc dolent'; - - @override - String get download_agreement_2 => - 'Recolzaré l\'artista quan pugui i només ho faig perquè no tinc diners per comprar el seu art'; - - @override - String get download_agreement_3 => - 'Sóc completament conscient que la meva IP pot ser bloqueada per YouTube i no responsabilizo a Spotube ni als seus propietaris/contribuents per qualsevol incident causat per la meva acció actual'; - - @override - String get decline => 'Rebutjar'; - - @override - String get accept => 'Acceptar'; - - @override - String get details => 'Detalls'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Canal'; - - @override - String get likes => 'M\'agrada'; - - @override - String get dislikes => 'No m\'agrada'; - - @override - String get views => 'Vistes'; - - @override - String get streamUrl => 'URL del streaming'; - - @override - String get stop => 'Parar'; - - @override - String get sort_newest => 'Ordenar per més noves'; - - @override - String get sort_oldest => 'Ordenar per més antigues'; - - @override - String get sleep_timer => 'Temporitzador d\'apagat'; - - @override - String mins(Object minutes) { - return '$minutes minuts'; - } - - @override - String hours(Object hours) { - return '$hours hores'; - } - - @override - String hour(Object hours) { - return '$hours hora'; - } - - @override - String get custom_hours => 'Hores personalitzades'; - - @override - String get logs => 'Registres'; - - @override - String get developers => 'Desenvolupadors'; - - @override - String get not_logged_in => 'No ha iniciat sesió'; - - @override - String get search_mode => 'Mode de cerca'; - - @override - String get audio_source => 'Font d\'àudio'; - - @override - String get ok => 'OK'; - - @override - String get failed_to_encrypt => 'Error al xifrar'; - - @override - String get encryption_failed_warning => - 'Spotube utilitza el xifrado per emmagatzemar les seves dades de forma segura. Però ha fallat. Per tant, tornarà a un emmagatzament no segur\nSi estè utilizant Linux, asseguri\'s de tenir instal·lats els serveis secrets com gnome-keyring, kde-wallet i keepassxc'; - - @override - String get querying_info => 'Consultant informació...'; - - @override - String get piped_api_down => 'La API de Piped no està operativa'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'La instància de Piped $pipedInstance no està operativa en aquest moment\n\nCanvieu la instància o canvieu el \'Tipus d\'API\' a l\'API oficial de YouTube\n\nAssegureu-vos de reiniciar l\'aplicació després del canvi'; - } - - @override - String get you_are_offline => 'Actualment no teniu connexió a internet'; - - @override - String get connection_restored => 'S\'ha restablert la connexió a internet'; - - @override - String get use_system_title_bar => 'Utilitza la barra de títol del sistema'; - - @override - String get crunching_results => 'Processant resultats...'; - - @override - String get search_to_get_results => 'Cerca per obtenir resultats'; - - @override - String get use_amoled_mode => 'Utilitza el mode AMOLED'; - - @override - String get pitch_dark_theme => 'Tema de dart negre intens'; - - @override - String get normalize_audio => 'Normalitza l\'àudio'; - - @override - String get change_cover => 'Canvia la coberta'; - - @override - String get add_cover => 'Afegeix una coberta'; - - @override - String get restore_defaults => 'Restaura els valors per defecte'; - - @override - String get download_music_format => 'Format de descàrrega de música'; - - @override - String get streaming_music_format => - 'Format de reproducció de música en temps real'; - - @override - String get download_music_quality => 'Qualitat de descàrrega de música'; - - @override - String get streaming_music_quality => - 'Qualitat de reproducció de música en temps real'; - - @override - String get login_with_lastfm => 'Inicia la sessió amb Last.fm'; - - @override - String get connect => 'Connecta'; - - @override - String get disconnect_lastfm => 'Desconnecta de Last.fm'; - - @override - String get disconnect => 'Desconnecta'; - - @override - String get username => 'Nom d\'usuari'; - - @override - String get password => 'Contrasenya'; - - @override - String get login => 'Inicia la sessió'; - - @override - String get login_with_your_lastfm => - 'Inicia la sessió amb el teu compte de Last.fm'; - - @override - String get scrobble_to_lastfm => 'Scrobble a Last.fm'; - - @override - String get go_to_album => 'Anar a l\'àlbum'; - - @override - String get discord_rich_presence => 'Presència rica de Discord'; - - @override - String get browse_all => 'Navega per tot'; - - @override - String get genres => 'Gèneres'; - - @override - String get explore_genres => 'Explora els gèneres'; - - @override - String get friends => 'Amics'; - - @override - String get no_lyrics_available => - 'Ho sentim, no es poden trobar les lletres d\'aquesta pista'; - - @override - String get start_a_radio => 'Inicia una ràdio'; - - @override - String get how_to_start_radio => 'Com vols començar la ràdio?'; - - @override - String get replace_queue_question => - 'Voleu substituir la cua actual o afegir-hi?'; - - @override - String get endless_playback => 'Reproducció infinita'; - - @override - String get delete_playlist => 'Suprimeix la llista de reproducció'; - - @override - String get delete_playlist_confirmation => - 'Esteu segur que voleu suprimir aquesta llista de reproducció?'; - - @override - String get local_tracks => 'Pistes locals'; - - @override - String get local_tab => 'Local'; - - @override - String get song_link => 'Enllaç de la cançó'; - - @override - String get skip_this_nonsense => 'Omet aquesta tonteria'; - - @override - String get freedom_of_music => '“Llibertat de la música”'; - - @override - String get freedom_of_music_palm => - '“Llibertat de la música a la palma de la mà”'; - - @override - String get get_started => 'Comencem'; - - @override - String get youtube_source_description => 'Recomanat i funciona millor.'; - - @override - String get piped_source_description => - 'Et sents lliure? El mateix que YouTube però més lliure.'; - - @override - String get jiosaavn_source_description => - 'El millor per a la regió del sud d\'Àsia.'; - - @override - String get invidious_source_description => - 'Similar a Piped però amb més disponibilitat'; - - @override - String highest_quality(Object quality) { - return 'Qualitat més alta: $quality'; - } - - @override - String get select_audio_source => 'Seleccioneu la font d\'àudio'; - - @override - String get endless_playback_description => - 'Afegiu automàticament noves cançons\nal final de la cua'; - - @override - String get choose_your_region => 'Trieu la vostra regió'; - - @override - String get choose_your_region_description => - 'Això ajudarà a Spotube a mostrar-vos el contingut adequat\nper a la vostra ubicació.'; - - @override - String get choose_your_language => 'Trieu el vostre idioma'; - - @override - String get help_project_grow => 'Ajuda a fer créixer aquest projecte'; - - @override - String get help_project_grow_description => - 'Spotube és un projecte de codi obert. Podeu ajudar a fer créixer aquest projecte contribuint al projecte, informant d\'errors o suggerint noves funcionalitats.'; - - @override - String get contribute_on_github => 'Contribueix a GitHub'; - - @override - String get donate_on_open_collective => 'Fes una donació a Open Collective'; - - @override - String get browse_anonymously => 'Navega de manera anònima'; - - @override - String get enable_connect => 'Habilita la connexió'; - - @override - String get enable_connect_description => - 'Controla Spotube des d\'altres dispositius'; - - @override - String get devices => 'Dispositius'; - - @override - String get select => 'Selecciona'; - - @override - String connect_client_alert(Object client) { - return 'Estàs sent controlat per $client'; - } - - @override - String get this_device => 'Aquest dispositiu'; - - @override - String get remote => 'Remot'; - - @override - String get stats => 'Estadístiques'; - - @override - String and_n_more(Object count) { - return 'i $count més'; - } - - @override - String get recently_played => 'Reproduït recentment'; - - @override - String get browse_more => 'Navega més'; - - @override - String get no_title => 'Sense títol'; - - @override - String get not_playing => 'No s\'està reproduint'; - - @override - String get epic_failure => 'Fracàs èpic!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'Afegit $tracks_length pistes a la cua'; - } - - @override - String get spotube_has_an_update => 'Spotube té una actualització'; - - @override - String get download_now => 'Descarregar ara'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum ha estat publicat'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version ha estat publicat'; - } - - @override - String get read_the_latest => 'Llegeix el més recent'; - - @override - String get release_notes => 'notes de la versió'; - - @override - String get pick_color_scheme => 'Tria l\'esquema de colors'; - - @override - String get save => 'Desar'; - - @override - String get choose_the_device => 'Tria el dispositiu:'; - - @override - String get multiple_device_connected => - 'Hi ha diversos dispositius connectats.\nTria el dispositiu on vols realitzar aquesta acció'; - - @override - String get nothing_found => 'No s\'ha trobat res'; - - @override - String get the_box_is_empty => 'La caixa està buida'; - - @override - String get top_artists => 'Millors artistes'; - - @override - String get top_albums => 'Millors àlbums'; - - @override - String get this_week => 'Aquesta setmana'; - - @override - String get this_month => 'Aquest mes'; - - @override - String get last_6_months => 'Últims 6 mesos'; - - @override - String get this_year => 'Aquest any'; - - @override - String get last_2_years => 'Últims 2 anys'; - - @override - String get all_time => 'Tots els temps'; - - @override - String powered_by_provider(Object providerName) { - return 'Funciona amb $providerName'; - } - - @override - String get email => 'Correu electrònic'; - - @override - String get profile_followers => 'Seguidors'; - - @override - String get birthday => 'Aniversari'; - - @override - String get subscription => 'Subscripció'; - - @override - String get not_born => 'No ha nascut'; - - @override - String get hacker => 'Hacker'; - - @override - String get profile => 'Perfil'; - - @override - String get no_name => 'Sense nom'; - - @override - String get edit => 'Editar'; - - @override - String get user_profile => 'Perfil d\'usuari'; - - @override - String count_plays(Object count) { - return '$count reproduccions'; - } - - @override - String get streaming_fees_hypothetical => - 'Comissions de streaming (hipotètic)'; - - @override - String get minutes_listened => 'minuts escoltats'; - - @override - String get streamed_songs => 'cançons reproduïdes'; - - @override - String count_streams(Object count) { - return '$count reproduccions'; - } - - @override - String get owned_by_you => 'De la teva propietat'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return 'S\'ha copiat $shareUrl al porta-retalls'; - } - - @override - String get hipotetical_calculation => - '*Això està calculat en funció d’un pagament mitjà per reproducció de 0,003–0,005 USD en plataformes de reproducció musical en línia. És un càlcul hipotètic per ajudar l’usuari a entendre quant hauria pagat als artistes si hagués escoltat la seva cançó en diferents plataformes.'; - - @override - String count_mins(Object minutes) { - return '$minutes minuts'; - } - - @override - String get summary_minutes => 'minuts'; - - @override - String get summary_listened_to_music => 'has escoltat música'; - - @override - String get summary_songs => 'cançons'; - - @override - String get summary_streamed_overall => 'reproduït en general'; - - @override - String get summary_owed_to_artists => 'degut als artistes\nAquest mes'; - - @override - String get summary_artists => 'artistes'; - - @override - String get summary_music_reached_you => 'La música t\'ha arribat'; - - @override - String get summary_full_albums => 'Àlbums complets'; - - @override - String get summary_got_your_love => 'ha aconseguit el teu amor'; - - @override - String get summary_playlists => 'llistes de reproducció'; - - @override - String get summary_were_on_repeat => 'estaven en repetició'; - - @override - String total_money(Object money) { - return 'total $money'; - } - - @override - String get webview_not_found => 'No s\'ha trobat el Webview'; - - @override - String get webview_not_found_description => - 'No hi ha cap temps d\'execució de Webview instal·lat al dispositiu.\nSi està instal·lat, assegureu-vos que estigui en el environment PATH\n\nDesprés d\'instal·lar-lo, reinicieu l\'aplicació'; - - @override - String get unsupported_platform => 'Plataforma no compatible'; - - @override - String get cache_music => 'Música en caché'; - - @override - String get open => 'Obrir'; - - @override - String get cache_folder => 'Carpeta de caché'; - - @override - String get export => 'Exportar'; - - @override - String get clear_cache => 'Netejar caché'; - - @override - String get clear_cache_confirmation => 'Voleu netejar la memòria cau?'; - - @override - String get export_cache_files => 'Exportar arxius en caché'; - - @override - String found_n_files(Object count) { - return 'S\'han trobat $count arxius'; - } - - @override - String get export_cache_confirmation => 'Voleu exportar aquests arxius a'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return 'S\'han exportat $filesExported de $files arxius'; - } - - @override - String get undo => 'Desfer'; - - @override - String get download_all => 'Descarregar tot'; - - @override - String get add_all_to_playlist => 'Afegir tot a la llista de reproducció'; - - @override - String get add_all_to_queue => 'Afegir tot a la cua'; - - @override - String get play_all_next => 'Reproduir tot a continuació'; - - @override - String get pause => 'Pausa'; - - @override - String get view_all => 'Veure tot'; - - @override - String get no_tracks_added_yet => 'Sembla que encara no has afegit cap pista'; - - @override - String get no_tracks => 'Sembla que no hi ha pistes aquí'; - - @override - String get no_tracks_listened_yet => 'Sembla que no has escoltat res encara'; - - @override - String get not_following_artists => 'No estàs seguint cap artista'; - - @override - String get no_favorite_albums_yet => - 'Sembla que encara no has afegit cap àlbum als teus favorits'; - - @override - String get no_logs_found => 'No s\'han trobat registres'; - - @override - String get youtube_engine => 'Motor de YouTube'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine no està instal·lat'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine no està instal·lat al teu sistema.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Assegura\'t que estigui disponible a la variable PATH o\nestableix el camí absolut a l\'executable de $engine a continuació'; - } - - @override - String get youtube_engine_unix_issue_message => - 'En macOS/Linux/Unix com a sistemes operatius, establir el camí a .zshrc/.bashrc/.bash_profile etc. no funcionarà.\nHas de configurar el camí al fitxer de configuració de la shell'; - - @override - String get download => 'Descarregar'; - - @override - String get file_not_found => 'Fitxer no trobat'; - - @override - String get custom => 'Personalitzat'; - - @override - String get add_custom_url => 'Afegir URL personalitzada'; - - @override - String get edit_port => 'Editar port'; - - @override - String get port_helper_msg => - 'El valor per defecte és -1, que indica un número aleatori. Si teniu un tallafoc configurat, es recomana establir-ho.'; - - @override - String connect_request(Object client) { - return 'Permetre que $client es connecti?'; - } - - @override - String get connection_request_denied => - 'Connexió denegada. L\'usuari ha denegat l\'accés.'; - - @override - String get an_error_occurred => 'S’ha produït un error'; - - @override - String get copy_to_clipboard => 'Copiar al porta-retalls'; - - @override - String get view_logs => 'Veure registres'; - - @override - String get retry => 'Tornar-ho a provar'; - - @override - String get no_default_metadata_provider_selected => - 'No has configurat cap proveïdor de metadades predeterminat'; - - @override - String get manage_metadata_providers => 'Gestionar proveïdors de metadades'; - - @override - String get open_link_in_browser => 'Obrir l’enllaç en el navegador?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Vols obrir l’enllaç següent?'; - - @override - String get unsafe_url_warning => - 'Pot ser perillós obrir enllaços de fonts no fiables. Sigues precavís!\nTambé pots copiar l’enllaç al porta-retalls.'; - - @override - String get copy_link => 'Copiar enllaç'; - - @override - String get building_your_timeline => - 'Construint la teva cronologia en funció de les teves escoltes...'; - - @override - String get official => 'Oficial'; - - @override - String author_name(Object author) { - return 'Autor: $author'; - } - - @override - String get third_party => 'Tercers'; - - @override - String get plugin_requires_authentication => - 'El complement requereix autenticació'; - - @override - String get update_available => 'Actualització disponible'; - - @override - String get supports_scrobbling => 'Admet scrobbling'; - - @override - String get plugin_scrobbling_info => - 'Aquest complement fa scrobbling de la teva música per generar l’historial d’escoltes.'; - - @override - String get default_metadata_source => 'Font de metadades per defecte'; - - @override - String get set_default_metadata_source => - 'Estableix la font de metadades per defecte'; - - @override - String get default_audio_source => 'Font d\'àudio per defecte'; - - @override - String get set_default_audio_source => - 'Estableix la font d\'àudio per defecte'; - - @override - String get set_default => 'Establir com a predeterminat'; - - @override - String get support => 'Suport'; - - @override - String get support_plugin_development => - 'Suportar el desenvolupament del complement'; - - @override - String can_access_name_api(Object name) { - return '- Pot accedir a l’API **$name**'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Vols instal·lar aquest complement?'; - - @override - String get third_party_plugin_warning => - 'Aquest complement prové d’un repositori de tercers. Assegura’t de confiar en la font abans d’instal·lar-lo.'; - - @override - String get author => 'Autor'; - - @override - String get this_plugin_can_do_following => - 'Aquest complement pot fer el següent'; - - @override - String get install => 'Instal·lar'; - - @override - String get install_a_metadata_provider => - 'Instal·lar un proveïdor de metadades'; - - @override - String get no_tracks_playing => 'No s’està reproduint cap pista actualment'; - - @override - String get synced_lyrics_not_available => - 'Les lletres sincronitzades no estan disponibles per a aquesta cançó. Si us plau, usa'; - - @override - String get plain_lyrics => 'Lletres sense format'; - - @override - String get tab_instead => 'en lloc d’això, utilitza la tecla Tab.'; - - @override - String get disclaimer => 'Avís legal'; - - @override - String get third_party_plugin_dmca_notice => - 'L’equip de Spotube no accepta cap responsabilitat (inclosa legal) pels complements de “tercers”.\nFes-los servir sota la teva responsabilitat. Si detectes errors/problemes, informa’ls al repositori del complement.\n\nSi algun complement de “tercers” incompleix els ToS/DMCA d’un servei o entitat legal, contacta amb l’autor del complement o amb la plataforma d’allotjament (per exemple GitHub/Codeberg) per prendre mesures. Els complements etiquetats com a “tercers” són públics i gestionats per la comunitat; no els curatem, per la qual cosa no podem intervenir-hi.\n\n'; - - @override - String get input_does_not_match_format => - 'L’entrada no coincideix amb el format requerit'; - - @override - String get plugins => 'Connectors'; - - @override - String get paste_plugin_download_url => - 'Enllaça l’URL de descàrrega o el repositori de GitHub/Codeberg o l’enllaç directe al fitxer .smplug'; - - @override - String get download_and_install_plugin_from_url => - 'Descarrega i instal·la el complement des d’un URL'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Error en afegir el complement: $error'; - } - - @override - String get upload_plugin_from_file => 'Penja el complement des d’un fitxer'; - - @override - String get installed => 'Instal·lat'; - - @override - String get available_plugins => 'Complements disponibles'; - - @override - String get configure_plugins => - 'Configura els teus propis connectors de proveïdor de metadades i de font d\'àudio'; - - @override - String get audio_scrobblers => 'Scrobblers d’àudio'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Font: '; - - @override - String get uncompressed => 'Sense comprimir'; - - @override - String get dab_music_source_description => - 'Per als audiòfils. Ofereix fluxos d\'àudio d\'alta qualitat/sense pèrdua. Coincidència precisa de pistes basada en ISRC.'; -} diff --git a/lib/l10n/generated/app_localizations_cs.dart b/lib/l10n/generated/app_localizations_cs.dart deleted file mode 100644 index 24d5b34b..00000000 --- a/lib/l10n/generated/app_localizations_cs.dart +++ /dev/null @@ -1,1566 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Czech (`cs`). -class AppLocalizationsCs extends AppLocalizations { - AppLocalizationsCs([String locale = 'cs']) : super(locale); - - @override - String get guest => 'Host'; - - @override - String get browse => 'Procházet'; - - @override - String get search => 'Hledat'; - - @override - String get library => 'Knihovna'; - - @override - String get lyrics => 'Texty'; - - @override - String get settings => 'Nastavení'; - - @override - String get genre_categories_filter => 'Filtrovat kategorie nebo žánry...'; - - @override - String get genre => 'Žánr'; - - @override - String get personalized => 'Personalizované'; - - @override - String get featured => 'Doporučené'; - - @override - String get new_releases => 'Nově vydané'; - - @override - String get songs => 'Skladby'; - - @override - String playing_track(Object track) { - return 'Hraje $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Toto vymaže aktuální frontu. $track_length skladeb bude odstraněno\nChcete pokračovat?'; - } - - @override - String get load_more => 'Načíst více'; - - @override - String get playlists => 'Playlisty'; - - @override - String get artists => 'Umělci'; - - @override - String get albums => 'Alba'; - - @override - String get tracks => 'Skladby'; - - @override - String get downloads => 'Stahování'; - - @override - String get filter_playlists => 'Filtrovat playlisty...'; - - @override - String get liked_tracks => 'Oblíbené skladby'; - - @override - String get liked_tracks_description => 'Všechny vaše oblíbené skladby'; - - @override - String get playlist => 'Seznam skladeb'; - - @override - String get create_a_playlist => 'Vytvořit playlist'; - - @override - String get update_playlist => 'Aktualizovat playlist'; - - @override - String get create => 'Vytvořit'; - - @override - String get cancel => 'Zrušit'; - - @override - String get update => 'Aktualizovat'; - - @override - String get playlist_name => 'Název playlistu'; - - @override - String get name_of_playlist => 'Název playlistu'; - - @override - String get description => 'Popis'; - - @override - String get public => 'Veřejné'; - - @override - String get collaborative => 'Společný'; - - @override - String get search_local_tracks => 'Hledat místní skladby...'; - - @override - String get play => 'Přehrát'; - - @override - String get delete => 'Smazat'; - - @override - String get none => 'Žádné'; - - @override - String get sort_a_z => 'Seřadit od A-Z'; - - @override - String get sort_z_a => 'Seřadit od Z-A'; - - @override - String get sort_artist => 'Seřadit podle umělce'; - - @override - String get sort_album => 'Seřadit podle alba'; - - @override - String get sort_duration => 'Seřadit podle délky'; - - @override - String get sort_tracks => 'Seřadit skladby'; - - @override - String currently_downloading(Object tracks_length) { - return 'Právě se stahuje ($tracks_length)'; - } - - @override - String get cancel_all => 'Zrušit vše'; - - @override - String get filter_artist => 'Filtrovat umělce...'; - - @override - String followers(Object followers) { - return '$followers Sledující'; - } - - @override - String get add_artist_to_blacklist => 'Přidat umělce na černou listinu'; - - @override - String get top_tracks => 'Top skladby'; - - @override - String get fans_also_like => 'Fanoušci mají také rádi'; - - @override - String get loading => 'Načítání...'; - - @override - String get artist => 'Umělec'; - - @override - String get blacklisted => 'Na černé listině'; - - @override - String get following => 'Sleduje'; - - @override - String get follow => 'Sledovat'; - - @override - String get artist_url_copied => 'URL umělce zkopírována do schránky'; - - @override - String added_to_queue(Object tracks) { - return 'Přidáno $tracks skladeb do fronty'; - } - - @override - String get filter_albums => 'Filtrovat alba...'; - - @override - String get synced => 'Synchronizováno'; - - @override - String get plain => 'Jednoduché'; - - @override - String get shuffle => 'Zamíchat'; - - @override - String get search_tracks => 'Hledat skladby...'; - - @override - String get released => 'Vydáno'; - - @override - String error(Object error) { - return 'Chyba $error'; - } - - @override - String get title => 'Název'; - - @override - String get time => 'Čas'; - - @override - String get more_actions => 'Více akcí'; - - @override - String download_count(Object count) { - return 'Stáhnout ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Přidat ($count) do playlistu'; - } - - @override - String add_count_to_queue(Object count) { - return 'Přidat ($count) do fronty'; - } - - @override - String play_count_next(Object count) { - return 'Přehrát ($count) dalších'; - } - - @override - String get album => 'Album'; - - @override - String copied_to_clipboard(Object data) { - return 'Zkopírováno $data do schránky'; - } - - @override - String add_to_following_playlists(Object track) { - return 'Přidat $track do následujících playlistů'; - } - - @override - String get add => 'Přidat'; - - @override - String added_track_to_queue(Object track) { - return 'Přidána skladba $track do fronty'; - } - - @override - String get add_to_queue => 'Přidat do fronty'; - - @override - String track_will_play_next(Object track) { - return '$track se přehraje jako další'; - } - - @override - String get play_next => 'Přehrát další'; - - @override - String removed_track_from_queue(Object track) { - return 'Odstraněna skladba $track z fronty'; - } - - @override - String get remove_from_queue => 'Odstranit z fronty'; - - @override - String get remove_from_favorites => 'Odstranit z oblíbených'; - - @override - String get save_as_favorite => 'Uložit jako oblíbené'; - - @override - String get add_to_playlist => 'Přidat do playlistu'; - - @override - String get remove_from_playlist => 'Odstranit z playlistu'; - - @override - String get add_to_blacklist => 'Přidat na černou listinu'; - - @override - String get remove_from_blacklist => 'Odstranit z černé listiny'; - - @override - String get share => 'Sdílet'; - - @override - String get mini_player => 'Mini přehrávač'; - - @override - String get slide_to_seek => 'Táhněte pro posunutí vpřed nebo vzad'; - - @override - String get shuffle_playlist => 'Zamíchat playlist'; - - @override - String get unshuffle_playlist => 'Zrušit zamíchání playlistu'; - - @override - String get previous_track => 'Předchozí skladba'; - - @override - String get next_track => 'Další skladba'; - - @override - String get pause_playback => 'Pozastavit přehrávání'; - - @override - String get resume_playback => 'Pokračovat v přehrávání'; - - @override - String get loop_track => 'Opakovat skladbu'; - - @override - String get no_loop => 'Žádné opakování'; - - @override - String get repeat_playlist => 'Opakovat playlist'; - - @override - String get queue => 'Fronta'; - - @override - String get alternative_track_sources => 'Alternativní zdroje skladeb'; - - @override - String get download_track => 'Stáhnout skladbu'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks skladeb ve frontě'; - } - - @override - String get clear_all => 'Vymazat vše'; - - @override - String get show_hide_ui_on_hover => 'Zobrazit/Skrýt UI při najetí'; - - @override - String get always_on_top => 'Vždy nahoře'; - - @override - String get exit_mini_player => 'Zavřít mini přehrávač'; - - @override - String get download_location => 'Umístění stahování'; - - @override - String get local_library => 'Místní knihovna'; - - @override - String get add_library_location => 'Přidat do knihovny'; - - @override - String get remove_library_location => 'Odebrat z knihovny'; - - @override - String get account => 'Účet'; - - @override - String get logout => 'Odhlásit se'; - - @override - String get logout_of_this_account => 'Odhlásit se z tohoto účtu'; - - @override - String get language_region => 'Jazyk a region'; - - @override - String get language => 'Jazyk'; - - @override - String get system_default => 'Systém'; - - @override - String get market_place_region => 'Region'; - - @override - String get recommendation_country => 'Země pro doporučení'; - - @override - String get appearance => 'Vzhled'; - - @override - String get layout_mode => 'Režim rozložení'; - - @override - String get override_layout_settings => 'Přepsat režim rozložení'; - - @override - String get adaptive => 'Adaptivní'; - - @override - String get compact => 'Kompaktní'; - - @override - String get extended => 'Rozšířený'; - - @override - String get theme => 'Téma'; - - @override - String get dark => 'Tmavé'; - - @override - String get light => 'Světlé'; - - @override - String get system => 'Systém'; - - @override - String get accent_color => 'Barva akcentu'; - - @override - String get sync_album_color => 'Synchronizovat barvu alba'; - - @override - String get sync_album_color_description => - 'Používá dominantní barvu obalu alba jako barvu akcentu'; - - @override - String get playback => 'Přehrávání'; - - @override - String get audio_quality => 'Kvalita zvuku'; - - @override - String get high => 'Vysoká'; - - @override - String get low => 'Nízká'; - - @override - String get pre_download_play => 'Předstáhnout a přehrát'; - - @override - String get pre_download_play_description => - 'Místo streamování audia stáhnout skladbu a přehrát (doporučeno pro uživatele s rychlejším internetem)'; - - @override - String get skip_non_music => 'Přeskočit nehudební segmenty (SponsorBlock)'; - - @override - String get blacklist_description => 'Zakázané skladby a umělci'; - - @override - String get wait_for_download_to_finish => 'Počkejte, až se dokončí stahování'; - - @override - String get desktop => 'Desktop'; - - @override - String get close_behavior => 'Chování při zavření'; - - @override - String get close => 'Zavřít'; - - @override - String get minimize_to_tray => 'Minimalizovat do lišty'; - - @override - String get show_tray_icon => 'Zobrazit ikonu v systémové liště'; - - @override - String get about => 'O aplikaci'; - - @override - String get u_love_spotube => 'Víme, že milujete Spotube'; - - @override - String get check_for_updates => 'Zkontrolovat aktualizace'; - - @override - String get about_spotube => 'O Spotube'; - - @override - String get blacklist => 'Černá listina'; - - @override - String get please_sponsor => 'Sponzorovat/darovat'; - - @override - String get spotube_description => - 'Spotube, rychlý, multiplatformní, bezplatný Spotify klient'; - - @override - String get version => 'Verze'; - - @override - String get build_number => 'Číslo sestavení'; - - @override - String get founder => 'Zakladatel'; - - @override - String get repository => 'Repozitář'; - - @override - String get bug_issues => 'Chyby+Problémy'; - - @override - String get made_with => 'Vytvořeno s ❤️ v Bangladéši🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Licence'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Nebojte, žádné z vašich údajů nebudou shromažďovány ani s nikým sdíleny'; - - @override - String get know_how_to_login => 'Nevíte, jak na to?'; - - @override - String get follow_step_by_step_guide => 'Postupujte podle návodu'; - - @override - String cookie_name_cookie(Object name) { - return 'Cookie $name'; - } - - @override - String get fill_in_all_fields => 'Vyplňte prosím všechna pole'; - - @override - String get submit => 'Odeslat'; - - @override - String get exit => 'Ukončit'; - - @override - String get previous => 'Předchozí'; - - @override - String get next => 'Další'; - - @override - String get done => 'Hotovo'; - - @override - String get step_1 => 'Krok 1'; - - @override - String get first_go_to => 'Nejprve jděte na'; - - @override - String get something_went_wrong => 'Něco se pokazilo'; - - @override - String get piped_instance => 'Instance serveru Piped'; - - @override - String get piped_description => - 'Instance serveru Piped, kterou použít pro hledání skladeb'; - - @override - String get piped_warning => - 'Některé z nich nemusí dobře fungovat. Používejte na vlastní riziko'; - - @override - String get invidious_instance => 'Instance serveru Invidious'; - - @override - String get invidious_description => - 'Instance serveru Invidious pro párování stop'; - - @override - String get invidious_warning => - 'Některé instance nemusí fungovat správně. Používejte na vlastní riziko'; - - @override - String get generate => 'Generovat'; - - @override - String track_exists(Object track) { - return 'Skladba $track již existuje'; - } - - @override - String get replace_downloaded_tracks => 'Nahradit všechny stažené skladby'; - - @override - String get skip_download_tracks => - 'Přeskočit stahování všech stažených skladeb'; - - @override - String get do_you_want_to_replace => 'Chcete nahradit existující skladbu??'; - - @override - String get replace => 'Nahradit'; - - @override - String get skip => 'Přeskočit'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Vyberte až $count $type'; - } - - @override - String get select_genres => 'Vyberte žánry'; - - @override - String get add_genres => 'Přidat žánry'; - - @override - String get country => 'Země'; - - @override - String get number_of_tracks_generate => 'Počet skladeb k vygenerování'; - - @override - String get acousticness => 'Akustičnost'; - - @override - String get danceability => 'Tanečnost'; - - @override - String get energy => 'Energie'; - - @override - String get instrumentalness => 'Instrumentálnost'; - - @override - String get liveness => 'Živost'; - - @override - String get loudness => 'Hlasitost'; - - @override - String get speechiness => 'Mluvnost'; - - @override - String get valence => 'Valence'; - - @override - String get popularity => 'Popularita'; - - @override - String get key => 'Klíč'; - - @override - String get duration => 'Délka (s)'; - - @override - String get tempo => 'Tempo (BPM)'; - - @override - String get mode => 'Režim'; - - @override - String get time_signature => 'Udání taktu'; - - @override - String get short => 'Krátký'; - - @override - String get medium => 'Střední'; - - @override - String get long => 'Dlouhý'; - - @override - String get min => 'Min'; - - @override - String get max => 'Max'; - - @override - String get target => 'Cíl'; - - @override - String get moderate => 'Mírný'; - - @override - String get deselect_all => 'Zrušit výběr'; - - @override - String get select_all => 'Vybrat vše'; - - @override - String get are_you_sure => 'Jste si jisti?'; - - @override - String get generating_playlist => 'Generování vašeho vlastního playlistu...'; - - @override - String selected_count_tracks(Object count) { - return 'Vybráno $count skladeb'; - } - - @override - String get download_warning => - 'Pokud stáhnete všechny skladby najednou, pirátíte tím hudbu a škodíte kreativní společnosti hudby. Doufám, že jste si toho vědomi. Vždy se snažte respektovat a podporovat tvrdou práci umělců'; - - @override - String get download_ip_ban_warning => - 'Mimochodem, vaše IP může být na YouTube zablokována kvůli nadměrným požadavkům na stahování. Blokování IP znamená, že nemůžete používat YouTube (i když jste přihlášeni) alespoň 2-3 měsíce ze zařízení s touto IP. A Spotube nenese žádnou odpovědnost, pokud se to někdy stane'; - - @override - String get by_clicking_accept_terms => - 'Kliknutím na \'přijmout\' souhlasíte s následujícími podmínkami:'; - - @override - String get download_agreement_1 => 'Vím, že pirátím hudbu. Jsem špatný'; - - @override - String get download_agreement_2 => - 'Budu podporovat umělce, kdekoliv to bude možné, a dělám to jen proto, že nemám peníze na koupi jejich umění'; - - @override - String get download_agreement_3 => - 'Jsem si naprosto vědom toho, že moje IP může být na YouTube zablokována a nenesu žádnou odpovědnost za nehody způsobené mým současným jednáním'; - - @override - String get decline => 'Odmítnout'; - - @override - String get accept => 'Přijmout'; - - @override - String get details => 'Podrobnosti'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Kanál'; - - @override - String get likes => 'Líbí se'; - - @override - String get dislikes => 'Nelíbí se'; - - @override - String get views => 'Zobrazení'; - - @override - String get streamUrl => 'URL streamu'; - - @override - String get stop => 'Zastavit'; - - @override - String get sort_newest => 'Seřadit od nejnovějších'; - - @override - String get sort_oldest => 'Seřadit od nejstarších'; - - @override - String get sleep_timer => 'Časovač spánku'; - - @override - String mins(Object minutes) { - return '$minutes Minut'; - } - - @override - String hours(Object hours) { - return '$hours Hodin'; - } - - @override - String hour(Object hours) { - return '$hours Hodina'; - } - - @override - String get custom_hours => 'Vlastní hodiny'; - - @override - String get logs => 'Protokoly'; - - @override - String get developers => 'Vývojáři'; - - @override - String get not_logged_in => 'Nejste přihlášeni'; - - @override - String get search_mode => 'Režim hledání'; - - @override - String get audio_source => 'Zdroj zvuku'; - - @override - String get ok => 'Ok'; - - @override - String get failed_to_encrypt => 'Šifrování selhalo'; - - @override - String get encryption_failed_warning => - 'Spotube používá šifrování k bezpečnému ukládání vašich dat. Ale selhalo. Takže se vrátí k nezabezpečenému úložišti\nPokud používáte linux, ujistěte se, že máte nainstalovanou jakoukoli službu k ukládání bezpečnostních pověření (gnome-keyring, kde-wallet, keepassxc atd.)'; - - @override - String get querying_info => 'Získávání informací...'; - - @override - String get piped_api_down => 'Piped API je mimo provoz'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'Instance Piped $pipedInstance je momentálně mimo provoz\n\nBuď změňte instanci nebo změňte \'Typ API\' na oficiální YouTube API\n\nPo změně se ujistěte, že aplikaci restartujete'; - } - - @override - String get you_are_offline => 'Momentálně jste offline'; - - @override - String get connection_restored => 'Vaše internetové připojení bylo obnoveno'; - - @override - String get use_system_title_bar => 'Použít systémové záhlaví okna'; - - @override - String get crunching_results => 'Zpracovávání výsledků...'; - - @override - String get search_to_get_results => 'Hledejte pro získání výsledků'; - - @override - String get use_amoled_mode => 'Úplně černé téma'; - - @override - String get pitch_dark_theme => 'AMOLED režim'; - - @override - String get normalize_audio => 'Normalizovat audio'; - - @override - String get change_cover => 'Změnit obal'; - - @override - String get add_cover => 'Přidat obal'; - - @override - String get restore_defaults => 'Obnovit výchozí'; - - @override - String get download_music_format => 'Formát stahování hudby'; - - @override - String get streaming_music_format => 'Formát streamování hudby'; - - @override - String get download_music_quality => 'Kvalita stahování hudby'; - - @override - String get streaming_music_quality => 'Kvalita streamování hudby'; - - @override - String get login_with_lastfm => 'Přihlásit se pomocí Last.fm'; - - @override - String get connect => 'Připojit'; - - @override - String get disconnect_lastfm => 'Odpojit Last.fm'; - - @override - String get disconnect => 'Odpojit'; - - @override - String get username => 'Uživatelské jméno'; - - @override - String get password => 'Heslo'; - - @override - String get login => 'Přihlásit se'; - - @override - String get login_with_your_lastfm => - 'Přihlásit se pomocí vašeho Last.fm účtu'; - - @override - String get scrobble_to_lastfm => 'Scrobble na Last.fm'; - - @override - String get go_to_album => 'Přejít na album'; - - @override - String get discord_rich_presence => 'Discord Rich Presence'; - - @override - String get browse_all => 'Procházet vše'; - - @override - String get genres => 'Žánry'; - - @override - String get explore_genres => 'Prozkoumat žánry'; - - @override - String get friends => 'Přátelé'; - - @override - String get no_lyrics_available => - 'Omlouváme se, není možné najít texty pro tuto skladbu'; - - @override - String get start_a_radio => 'Vytvořit rádio'; - - @override - String get how_to_start_radio => 'Jak chcete vytvořit rádio?'; - - @override - String get replace_queue_question => - 'Chcete nahradit aktuální frontu nebo k ní přidat?'; - - @override - String get endless_playback => 'Nekonečné přehrávání'; - - @override - String get delete_playlist => 'Smazat playlist'; - - @override - String get delete_playlist_confirmation => - 'Jste si jisti, že chcete smazat tento playlist?'; - - @override - String get local_tracks => 'Místní skladby'; - - @override - String get local_tab => 'Místní'; - - @override - String get song_link => 'Odkaz na skladbu'; - - @override - String get skip_this_nonsense => 'Přeskočit tenhle nesmysl'; - - @override - String get freedom_of_music => '“Svobodná hudba”'; - - @override - String get freedom_of_music_palm => '“Svobodná hudba ve vaší dlani”'; - - @override - String get get_started => 'Začít'; - - @override - String get youtube_source_description => 'Doporučeno a funguje nejlépe.'; - - @override - String get piped_source_description => - 'Nechcete být sledováni? Stejné jako YouTube, ale respektuje soukromí.'; - - @override - String get jiosaavn_source_description => 'Nejlepší pro jihoasijský region.'; - - @override - String get invidious_source_description => - 'Podobné Piped, ale s vyšší dostupností'; - - @override - String highest_quality(Object quality) { - return 'Nejvyšší kvalita: $quality'; - } - - @override - String get select_audio_source => 'Vyberte zdroj zvuku'; - - @override - String get endless_playback_description => - 'Automaticky přidávat nové skladby\nna konec fronty'; - - @override - String get choose_your_region => 'Vyberte svůj region'; - - @override - String get choose_your_region_description => - 'To pomůže Spotube ukázat vám správný obsah\npro vaši lokalitu.'; - - @override - String get choose_your_language => 'Vyberte svůj jazyk'; - - @override - String get help_project_grow => 'Pomozte tomuto projektu růst'; - - @override - String get help_project_grow_description => - 'Spotube je open-source projekt. Můžete pomoci tomuto projektu růst tím, že přispějete do projektu, nahlásíte chyby nebo navrhnete nové funkce.'; - - @override - String get contribute_on_github => 'Přispějte na GitHub'; - - @override - String get donate_on_open_collective => 'Darujte na Open Collective'; - - @override - String get browse_anonymously => 'Procházet anonymně'; - - @override - String get enable_connect => 'Povolit ovládání'; - - @override - String get enable_connect_description => - 'Ovládejte Spotube z jiného zařízení'; - - @override - String get devices => 'Zařízení'; - - @override - String get select => 'Vybrat'; - - @override - String connect_client_alert(Object client) { - return 'Zařízení je ovládáno z $client'; - } - - @override - String get this_device => 'Toto zařízení'; - - @override - String get remote => 'Ovladač'; - - @override - String get stats => 'Statistiky'; - - @override - String and_n_more(Object count) { - return 'a dalších $count'; - } - - @override - String get recently_played => 'Nedávno přehráno'; - - @override - String get browse_more => 'Procházet více'; - - @override - String get no_title => 'Bez názvu'; - - @override - String get not_playing => 'Nepřehrává se'; - - @override - String get epic_failure => 'Epické selhání!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'Přidáno $tracks_length skladeb do fronty'; - } - - @override - String get spotube_has_an_update => 'Spotube má aktualizaci'; - - @override - String get download_now => 'Stáhnout nyní'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Byla vydána noční verze Spotube $nightlyBuildNum'; - } - - @override - String release_version(Object version) { - return 'Byla vydána verze Spotube v$version'; - } - - @override - String get read_the_latest => 'Přečtěte si nejnovější '; - - @override - String get release_notes => 'poznámky k vydání'; - - @override - String get pick_color_scheme => 'Vyberte barevné schéma'; - - @override - String get save => 'Uložit'; - - @override - String get choose_the_device => 'Vyberte zařízení:'; - - @override - String get multiple_device_connected => - 'Je připojeno více zařízení.\nVyberte zařízení, na kterém chcete provést tuto akci'; - - @override - String get nothing_found => 'Nic nenalezeno'; - - @override - String get the_box_is_empty => 'Krabice je prázdná'; - - @override - String get top_artists => 'Nejlepší umělci'; - - @override - String get top_albums => 'Nejlepší alba'; - - @override - String get this_week => 'Tento týden'; - - @override - String get this_month => 'Tento měsíc'; - - @override - String get last_6_months => 'Posledních 6 měsíců'; - - @override - String get this_year => 'Tento rok'; - - @override - String get last_2_years => 'Poslední 2 roky'; - - @override - String get all_time => 'Všechny časy'; - - @override - String powered_by_provider(Object providerName) { - return 'Pohání $providerName'; - } - - @override - String get email => 'Email'; - - @override - String get profile_followers => 'Sledující'; - - @override - String get birthday => 'Narozeniny'; - - @override - String get subscription => 'Předplatné'; - - @override - String get not_born => 'Nenarozen'; - - @override - String get hacker => 'Hacker'; - - @override - String get profile => 'Profil'; - - @override - String get no_name => 'Bez jména'; - - @override - String get edit => 'Upravit'; - - @override - String get user_profile => 'Uživatelský profil'; - - @override - String count_plays(Object count) { - return '$count přehrání'; - } - - @override - String get streaming_fees_hypothetical => - 'Poplatky za streamování (hypotetické)'; - - @override - String get minutes_listened => 'Poslouchané minuty'; - - @override - String get streamed_songs => 'Streamované skladby'; - - @override - String count_streams(Object count) { - return '$count streamů'; - } - - @override - String get owned_by_you => 'Vlastněno vámi'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return 'Zkopírováno $shareUrl do schránky'; - } - - @override - String get hipotetical_calculation => - '*Toto je vypočítáno na základě průměrného výplatu za přehrání 0,003–0,005 USD na online hudebních streamovacích platformách. Jedná se o hypotetický výpočet, který má uživateli ukázat, kolik by umělci dostali, pokud by jeho píseň poslouchal na jiné platformě.'; - - @override - String count_mins(Object minutes) { - return '$minutes minut'; - } - - @override - String get summary_minutes => 'minuty'; - - @override - String get summary_listened_to_music => 'Poslouchal(a) hudbu'; - - @override - String get summary_songs => 'písně'; - - @override - String get summary_streamed_overall => 'Streamováno celkově'; - - @override - String get summary_owed_to_artists => 'Dluženo umělcům\nTento měsíc'; - - @override - String get summary_artists => 'umělců'; - - @override - String get summary_music_reached_you => 'Hudba vás oslovila'; - - @override - String get summary_full_albums => 'plná alba'; - - @override - String get summary_got_your_love => 'Získal vaši lásku'; - - @override - String get summary_playlists => 'playlisty'; - - @override - String get summary_were_on_repeat => 'Byly na opakování'; - - @override - String total_money(Object money) { - return 'Celkem $money'; - } - - @override - String get webview_not_found => 'Webview nebyl nalezen'; - - @override - String get webview_not_found_description => - 'Na vašem zařízení není nainstalováno žádné runtime prostředí Webview.\nPokud je nainstalováno, ujistěte se, že je v environment PATH\n\nPo instalaci restartujte aplikaci'; - - @override - String get unsupported_platform => 'Nepodporovaná platforma'; - - @override - String get cache_music => 'Hudba v mezipaměti'; - - @override - String get open => 'Otevřít'; - - @override - String get cache_folder => 'Složka mezipaměti'; - - @override - String get export => 'Exportovat'; - - @override - String get clear_cache => 'Vymazat mezipaměť'; - - @override - String get clear_cache_confirmation => 'Opravdu chcete vymazat mezipaměť?'; - - @override - String get export_cache_files => 'Exportovat soubory z mezipaměti'; - - @override - String found_n_files(Object count) { - return 'Nalezeno $count souborů'; - } - - @override - String get export_cache_confirmation => 'Chcete exportovat tyto soubory do'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return 'Exportováno $filesExported z $files souborů'; - } - - @override - String get undo => 'Zpět'; - - @override - String get download_all => 'Stáhnout vše'; - - @override - String get add_all_to_playlist => 'Přidat vše do seznamu skladeb'; - - @override - String get add_all_to_queue => 'Přidat vše do fronty'; - - @override - String get play_all_next => 'Přehrát vše následně'; - - @override - String get pause => 'Pauza'; - - @override - String get view_all => 'Zobrazit vše'; - - @override - String get no_tracks_added_yet => - 'Zdá se, že jste ještě nepřidali žádné skladby'; - - @override - String get no_tracks => 'Zdá se, že zde nejsou žádné skladby'; - - @override - String get no_tracks_listened_yet => - 'Zdá se, že jste ještě nic neposlouchali'; - - @override - String get not_following_artists => 'Nezajímáte se o žádné umělce'; - - @override - String get no_favorite_albums_yet => - 'Zdá se, že jste ještě nepřidali žádné alba mezi oblíbené'; - - @override - String get no_logs_found => 'Žádné záznamy nenalezeny'; - - @override - String get youtube_engine => 'YouTube Engine'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine není nainstalován'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine není nainstalován ve vašem systému.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Ujistěte se, že je k dispozici v proměnné PATH nebo\nnastavte absolutní cestu k $engine spustitelnému souboru níže'; - } - - @override - String get youtube_engine_unix_issue_message => - 'V macOS/Linux/Unixových systémech nebude fungovat nastavení cesty v .zshrc/.bashrc/.bash_profile atd.\nMusíte nastavit cestu v konfiguračním souboru shellu'; - - @override - String get download => 'Stáhnout'; - - @override - String get file_not_found => 'Soubor nenalezen'; - - @override - String get custom => 'Vlastní'; - - @override - String get add_custom_url => 'Přidat vlastní URL'; - - @override - String get edit_port => 'Upravit port'; - - @override - String get port_helper_msg => - 'Výchozí hodnota je -1, což znamená náhodné číslo. Pokud máte nakonfigurován firewall, doporučuje se to nastavit.'; - - @override - String connect_request(Object client) { - return 'Povolit $client připojení?'; - } - - @override - String get connection_request_denied => - 'Připojení bylo zamítnuto. Uživatel odmítl přístup.'; - - @override - String get an_error_occurred => 'Došlo k chybě'; - - @override - String get copy_to_clipboard => 'Kopírovat do schránky'; - - @override - String get view_logs => 'Zobrazit protokoly'; - - @override - String get retry => 'Zkusit znovu'; - - @override - String get no_default_metadata_provider_selected => - 'Nemáte nastaven výchozí poskytovatel metadat'; - - @override - String get manage_metadata_providers => 'Spravovat poskytovatele metadat'; - - @override - String get open_link_in_browser => 'Otevřít odkaz v prohlížeči?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Chcete otevřít následující odkaz?'; - - @override - String get unsafe_url_warning => - 'Odkazy z nedůvěryhodných zdrojů mohou být nebezpečné. Buďte opatrní!\nOdkaz si také můžete zkopírovat do schránky.'; - - @override - String get copy_link => 'Zkopírovat odkaz'; - - @override - String get building_your_timeline => - 'Vytváří se váš časový přehled podle poslechů...'; - - @override - String get official => 'Oficiální'; - - @override - String author_name(Object author) { - return 'Autor: $author'; - } - - @override - String get third_party => 'Třetí strana'; - - @override - String get plugin_requires_authentication => 'Plugin vyžaduje ověření'; - - @override - String get update_available => 'Aktualizace dostupná'; - - @override - String get supports_scrobbling => 'Podpora scrobblování'; - - @override - String get plugin_scrobbling_info => - 'Tento plugin scrobbles vaši hudbu pro vytvoření historie poslechů.'; - - @override - String get default_metadata_source => 'Výchozí zdroj metadat'; - - @override - String get set_default_metadata_source => 'Nastavit výchozí zdroj metadat'; - - @override - String get default_audio_source => 'Výchozí zdroj zvuku'; - - @override - String get set_default_audio_source => 'Nastavit výchozí zdroj zvuku'; - - @override - String get set_default => 'Nastavit jako výchozí'; - - @override - String get support => 'Podpora'; - - @override - String get support_plugin_development => 'Podpořit vývoj pluginu'; - - @override - String can_access_name_api(Object name) { - return '- Může přistupovat k API **$name**'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Chcete tento plugin nainstalovat?'; - - @override - String get third_party_plugin_warning => - 'Tento plugin pochází z repozitáře třetí strany. Ujistěte se, že důvěřujete zdroji, než ho nainstalujete.'; - - @override - String get author => 'Autor'; - - @override - String get this_plugin_can_do_following => - 'Tento plugin může provádět následující úkony'; - - @override - String get install => 'Instalovat'; - - @override - String get install_a_metadata_provider => - 'Nainstalovat poskytovatele metadat'; - - @override - String get no_tracks_playing => 'Momentálně není přehrávána žádná skladba'; - - @override - String get synced_lyrics_not_available => - 'Synchronizované texty nejsou k dispozici k této písni. Prosím použijte'; - - @override - String get plain_lyrics => 'Prostý text'; - - @override - String get tab_instead => 'místo toho použijte tabulátor.'; - - @override - String get disclaimer => 'Prohlášení'; - - @override - String get third_party_plugin_dmca_notice => - 'Tým Spotube nenese žádnou odpovědnost (včetně právní) za pluginy „třetích stran“.\nPoužívejte je na vlastní riziko. Pro chyby/problémy je nahlaste do repozitáře pluginu.\n\nPokud jakýkoli plugin „třetí strany“ porušuje podmínky služby nebo DMCA kteréhokoli poskytovatele či právního subjektu, požádejte autora pluginu nebo hostingovou platformu (např. GitHub/Codeberg), aby podnikla kroky. Pluginy označené jako „třetí strana“ jsou otevřené a spravovány komunitou; nespravujeme je, tudíž nemůžeme jednat.\n\n'; - - @override - String get input_does_not_match_format => - 'Vstup neodpovídá požadovanému formátu'; - - @override - String get plugins => 'Pluginy'; - - @override - String get paste_plugin_download_url => - 'Vložte URL ke stažení nebo GitHub/Codeberg repozitář či přímý odkaz na soubor .smplug'; - - @override - String get download_and_install_plugin_from_url => - 'Stáhnout a nainstalovat plugin z URL'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Nepodařilo se přidat plugin: $error'; - } - - @override - String get upload_plugin_from_file => 'Nahrát plugin ze souboru'; - - @override - String get installed => 'Nainstalováno'; - - @override - String get available_plugins => 'Dostupné pluginy'; - - @override - String get configure_plugins => - 'Konfigurujte své vlastní pluginy poskytovatele metadat a zdroje zvuku'; - - @override - String get audio_scrobblers => 'Audio scrobblers'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Zdroj: '; - - @override - String get uncompressed => 'Nekomprimováno'; - - @override - String get dab_music_source_description => - 'Pro audiofily. Poskytuje vysoce kvalitní/bezztrátové zvukové toky. Přesná shoda skladeb na základě ISRC.'; -} diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart deleted file mode 100644 index 4ab10266..00000000 --- a/lib/l10n/generated/app_localizations_de.dart +++ /dev/null @@ -1,1579 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for German (`de`). -class AppLocalizationsDe extends AppLocalizations { - AppLocalizationsDe([String locale = 'de']) : super(locale); - - @override - String get guest => 'Gast'; - - @override - String get browse => 'Durchsuchen'; - - @override - String get search => 'Suchen'; - - @override - String get library => 'Bibliothek'; - - @override - String get lyrics => 'Songtexte'; - - @override - String get settings => 'Einstellungen'; - - @override - String get genre_categories_filter => 'Filtere Kategorien oder Genres...'; - - @override - String get genre => 'Genre'; - - @override - String get personalized => 'Personalisiert'; - - @override - String get featured => 'Empfohlen'; - - @override - String get new_releases => 'Neue Veröffentlichungen'; - - @override - String get songs => 'Songs'; - - @override - String playing_track(Object track) { - return 'Wiedergabe: $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Dadurch wird die aktuelle Warteschlange gelöscht. $track_length Titel werden entfernt.\nMöchten Sie fortfahren?'; - } - - @override - String get load_more => 'Mehr laden'; - - @override - String get playlists => 'Playlists'; - - @override - String get artists => 'Künstler'; - - @override - String get albums => 'Alben'; - - @override - String get tracks => 'Titel'; - - @override - String get downloads => 'Downloads'; - - @override - String get filter_playlists => 'Filtere deine Playlists...'; - - @override - String get liked_tracks => 'Gefällt mir-Titel'; - - @override - String get liked_tracks_description => 'Alle deine geliketen Titel'; - - @override - String get playlist => 'Playlist'; - - @override - String get create_a_playlist => 'Erstelle eine Playlist'; - - @override - String get update_playlist => 'Wiedergabeliste aktualisieren'; - - @override - String get create => 'Erstellen'; - - @override - String get cancel => 'Abbrechen'; - - @override - String get update => 'Aktualisieren'; - - @override - String get playlist_name => 'Playlist-Name'; - - @override - String get name_of_playlist => 'Name der Playlist'; - - @override - String get description => 'Beschreibung'; - - @override - String get public => 'Öffentlich'; - - @override - String get collaborative => 'Kollaborativ'; - - @override - String get search_local_tracks => 'Lokale Titel durchsuchen...'; - - @override - String get play => 'Wiedergabe'; - - @override - String get delete => 'Löschen'; - - @override - String get none => 'Keine'; - - @override - String get sort_a_z => 'Sortieren nach A-Z'; - - @override - String get sort_z_a => 'Sortieren nach Z-A'; - - @override - String get sort_artist => 'Sortieren nach Künstler'; - - @override - String get sort_album => 'Sortieren nach Album'; - - @override - String get sort_duration => 'Nach Dauer sortieren'; - - @override - String get sort_tracks => 'Titel sortieren'; - - @override - String currently_downloading(Object tracks_length) { - return 'Derzeitige Downloads ($tracks_length)'; - } - - @override - String get cancel_all => 'Alle abbrechen'; - - @override - String get filter_artist => 'Künstler filtern...'; - - @override - String followers(Object followers) { - return '$followers Follower'; - } - - @override - String get add_artist_to_blacklist => - 'Künstler zur Schwarzen Liste hinzufügen'; - - @override - String get top_tracks => 'Top-Titel'; - - @override - String get fans_also_like => 'Fans mögen auch'; - - @override - String get loading => 'Laden...'; - - @override - String get artist => 'Künstler'; - - @override - String get blacklisted => 'Auf der Schwarzen Liste'; - - @override - String get following => 'Folgen'; - - @override - String get follow => 'Folgen'; - - @override - String get artist_url_copied => 'Künstler-URL in Zwischenablage kopiert'; - - @override - String added_to_queue(Object tracks) { - return '$tracks Titel zur Warteschlange hinzugefügt'; - } - - @override - String get filter_albums => 'Alben filtern...'; - - @override - String get synced => 'Synchronisiert'; - - @override - String get plain => 'Einfach'; - - @override - String get shuffle => 'Zufällige Wiedergabe'; - - @override - String get search_tracks => 'Titel durchsuchen...'; - - @override - String get released => 'Veröffentlicht'; - - @override - String error(Object error) { - return 'Fehler $error'; - } - - @override - String get title => 'Titel'; - - @override - String get time => 'Dauer'; - - @override - String get more_actions => 'Weitere Aktionen'; - - @override - String download_count(Object count) { - return 'Download ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Zu Playlist hinzufügen ($count)'; - } - - @override - String add_count_to_queue(Object count) { - return 'Zur Warteschlange hinzufügen ($count)'; - } - - @override - String play_count_next(Object count) { - return 'Als nächstes abspielen ($count)'; - } - - @override - String get album => 'Album'; - - @override - String copied_to_clipboard(Object data) { - return '$data in Zwischenablage kopiert'; - } - - @override - String add_to_following_playlists(Object track) { - return '$track zu folgenden Playlists hinzufügen'; - } - - @override - String get add => 'Hinzufügen'; - - @override - String added_track_to_queue(Object track) { - return '$track zur Warteschlange hinzugefügt'; - } - - @override - String get add_to_queue => 'Zur Warteschlange hinzufügen'; - - @override - String track_will_play_next(Object track) { - return '$track wird als nächstes abgespielt'; - } - - @override - String get play_next => 'Als nächstes abspielen'; - - @override - String removed_track_from_queue(Object track) { - return '$track aus der Warteschlange entfernt'; - } - - @override - String get remove_from_queue => 'Aus der Warteschlange entfernen'; - - @override - String get remove_from_favorites => 'Aus Favoriten entfernen'; - - @override - String get save_as_favorite => 'Als Favorit speichern'; - - @override - String get add_to_playlist => 'Zur Playlist hinzufügen'; - - @override - String get remove_from_playlist => 'Aus der Playlist entfernen'; - - @override - String get add_to_blacklist => 'Zur Schwarzen Liste hinzufügen'; - - @override - String get remove_from_blacklist => 'Aus der Schwarzen Liste entfernen'; - - @override - String get share => 'Teilen'; - - @override - String get mini_player => 'Mini-Player'; - - @override - String get slide_to_seek => 'Zum Vor- oder Zurückspulen ziehen'; - - @override - String get shuffle_playlist => 'Playlist mischen'; - - @override - String get unshuffle_playlist => 'Playlist nicht mehr mischen'; - - @override - String get previous_track => 'Vorheriger Track'; - - @override - String get next_track => 'Nächster Track'; - - @override - String get pause_playback => 'Wiedergabe pausieren'; - - @override - String get resume_playback => 'Wiedergabe fortsetzen'; - - @override - String get loop_track => 'Track wiederholen'; - - @override - String get no_loop => 'Kein Loop'; - - @override - String get repeat_playlist => 'Playlist wiederholen'; - - @override - String get queue => 'Warteschlange'; - - @override - String get alternative_track_sources => 'Alternative Track-Quellen'; - - @override - String get download_track => 'Track herunterladen'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks Tracks in der Warteschlange'; - } - - @override - String get clear_all => 'Alle löschen'; - - @override - String get show_hide_ui_on_hover => 'UI beim Überfahren anzeigen/ausblenden'; - - @override - String get always_on_top => 'Immer im Vordergrund'; - - @override - String get exit_mini_player => 'Mini-Player verlassen'; - - @override - String get download_location => 'Download-Speicherort'; - - @override - String get local_library => 'Lokale Bibliothek'; - - @override - String get add_library_location => 'Zur Bibliothek hinzufügen'; - - @override - String get remove_library_location => 'Aus der Bibliothek entfernen'; - - @override - String get account => 'Konto'; - - @override - String get logout => 'Abmelden'; - - @override - String get logout_of_this_account => 'Von diesem Konto abmelden'; - - @override - String get language_region => 'Sprache & Region'; - - @override - String get language => 'Sprache'; - - @override - String get system_default => 'Systemstandard'; - - @override - String get market_place_region => 'Marktplatzregion'; - - @override - String get recommendation_country => 'Empfehlungsland'; - - @override - String get appearance => 'Erscheinungsbild'; - - @override - String get layout_mode => 'Layout-Modus'; - - @override - String get override_layout_settings => - 'Responsiven Layout-Modus-Einstellungen überschreiben'; - - @override - String get adaptive => 'Adaptiv'; - - @override - String get compact => 'Kompakt'; - - @override - String get extended => 'Erweitert'; - - @override - String get theme => 'Design'; - - @override - String get dark => 'Dunkel'; - - @override - String get light => 'Hell'; - - @override - String get system => 'System'; - - @override - String get accent_color => 'Akzentfarbe'; - - @override - String get sync_album_color => 'Albumfarbe synchronisieren'; - - @override - String get sync_album_color_description => - 'Verwendet die dominante Farbe des Album Covers als Akzentfarbe'; - - @override - String get playback => 'Wiedergabe'; - - @override - String get audio_quality => 'Audioqualität'; - - @override - String get high => 'Hoch'; - - @override - String get low => 'Niedrig'; - - @override - String get pre_download_play => 'Vorab herunterladen und abspielen'; - - @override - String get pre_download_play_description => - 'Anstatt Audio zu streamen, Bytes herunterladen und abspielen (Empfohlen für Benutzer mit hoher Bandbreite)'; - - @override - String get skip_non_music => - 'Überspringe Nicht-Musik-Segmente (SponsorBlock)'; - - @override - String get blacklist_description => 'Gesperrte Titel und Künstler'; - - @override - String get wait_for_download_to_finish => - 'Bitte warten Sie, bis der aktuelle Download abgeschlossen ist'; - - @override - String get desktop => 'Desktop'; - - @override - String get close_behavior => 'Verhalten beim Schließen'; - - @override - String get close => 'Schließen'; - - @override - String get minimize_to_tray => 'In Taskleiste minimieren'; - - @override - String get show_tray_icon => 'Systemsymbol anzeigen'; - - @override - String get about => 'Über'; - - @override - String get u_love_spotube => 'Wir wissen, dass Sie Spotube lieben'; - - @override - String get check_for_updates => 'Nach Updates suchen'; - - @override - String get about_spotube => 'Über Spotube'; - - @override - String get blacklist => 'Gesperrte Titel'; - - @override - String get please_sponsor => 'Bitte unterstützen/Spenden Sie'; - - @override - String get spotube_description => - 'Spotube, ein leichtgewichtiger, plattformübergreifender und kostenloser Spotify-Client'; - - @override - String get version => 'Version'; - - @override - String get build_number => 'Build-Nummer'; - - @override - String get founder => 'Gründer'; - - @override - String get repository => 'Repository'; - - @override - String get bug_issues => 'Fehler und Probleme'; - - @override - String get made_with => 'Entwickelt mit ❤️ in Bangladesch 🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Lizenz'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Keine Sorge, Ihre Anmeldeinformationen werden nicht erfasst oder mit anderen geteilt'; - - @override - String get know_how_to_login => 'Wissen Sie nicht, wie es geht?'; - - @override - String get follow_step_by_step_guide => - 'Befolgen Sie die schrittweise Anleitung'; - - @override - String cookie_name_cookie(Object name) { - return '$name Cookie'; - } - - @override - String get fill_in_all_fields => 'Bitte füllen Sie alle Felder aus'; - - @override - String get submit => 'Senden'; - - @override - String get exit => 'Beenden'; - - @override - String get previous => 'Zurück'; - - @override - String get next => 'Weiter'; - - @override - String get done => 'Fertig'; - - @override - String get step_1 => 'Schritt 1'; - - @override - String get first_go_to => 'Gehe zuerst zu'; - - @override - String get something_went_wrong => 'Etwas ist schiefgelaufen'; - - @override - String get piped_instance => 'Piped-Serverinstanz'; - - @override - String get piped_description => - 'Die Piped-Serverinstanz, die zur Titelzuordnung verwendet werden soll'; - - @override - String get piped_warning => - 'Einige von ihnen funktionieren möglicherweise nicht gut. Verwende sie also auf eigenes Risiko'; - - @override - String get invidious_instance => 'Invidious-Serverinstanz'; - - @override - String get invidious_description => - 'Die Invidious-Serverinstanz zur Titelerkennung'; - - @override - String get invidious_warning => - 'Einige Instanzen funktionieren möglicherweise nicht gut. Benutzung auf eigene Gefahr'; - - @override - String get generate => 'Generieren'; - - @override - String track_exists(Object track) { - return 'Track $track existiert bereits'; - } - - @override - String get replace_downloaded_tracks => - 'Alle heruntergeladenen Titel ersetzen'; - - @override - String get skip_download_tracks => - 'Das Herunterladen aller heruntergeladenen Titel überspringen'; - - @override - String get do_you_want_to_replace => - 'Möchtest du den vorhandenen Track ersetzen?'; - - @override - String get replace => 'Ersetzen'; - - @override - String get skip => 'Überspringen'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Wähle bis zu $count $type aus'; - } - - @override - String get select_genres => 'Genres auswählen'; - - @override - String get add_genres => 'Genres hinzufügen'; - - @override - String get country => 'Land'; - - @override - String get number_of_tracks_generate => 'Anzahl der zu generierenden Titel'; - - @override - String get acousticness => 'Akustik'; - - @override - String get danceability => 'Tanzbarkeit'; - - @override - String get energy => 'Energie'; - - @override - String get instrumentalness => 'Instrumentalität'; - - @override - String get liveness => 'Lebendigkeit'; - - @override - String get loudness => 'Lautstärke'; - - @override - String get speechiness => 'Sprechanteil'; - - @override - String get valence => 'Stimmung'; - - @override - String get popularity => 'Beliebtheit'; - - @override - String get key => 'Tonart'; - - @override - String get duration => 'Dauer (s)'; - - @override - String get tempo => 'Tempo (BPM)'; - - @override - String get mode => 'Modus'; - - @override - String get time_signature => 'Taktart'; - - @override - String get short => 'Kurz'; - - @override - String get medium => 'Mittel'; - - @override - String get long => 'Lang'; - - @override - String get min => 'Min'; - - @override - String get max => 'Max'; - - @override - String get target => 'Ziel'; - - @override - String get moderate => 'Mäßig'; - - @override - String get deselect_all => 'Alle abwählen'; - - @override - String get select_all => 'Alle auswählen'; - - @override - String get are_you_sure => 'Bist du sicher?'; - - @override - String get generating_playlist => - 'Erstelle deine individuelle Wiedergabeliste...'; - - @override - String selected_count_tracks(Object count) { - return '$count Titel ausgewählt'; - } - - @override - String get download_warning => - 'Wenn du alle Titel in großen Mengen herunterlädst, betreibst du eindeutig Raubkopien von Musik und schadest der kreativen Gesellschaft der Musik. Ich hoffe, dir ist dies bewusst. Versuche immer, die harte Arbeit der Künstler zu respektieren und zu unterstützen.'; - - @override - String get download_ip_ban_warning => - 'Übrigens, deine IP-Adresse kann aufgrund übermäßiger Downloadanfragen von YouTube gesperrt werden. Eine IP-Sperre bedeutet, dass du YouTube (auch wenn du angemeldet bist) für mindestens 2-3 Monate von diesem IP-Gerät aus nicht nutzen kannst. Spotube übernimmt keine Verantwortung, falls dies jemals geschieht.'; - - @override - String get by_clicking_accept_terms => - 'Durch Klicken auf \'Akzeptieren\' stimmst du den folgenden Bedingungen zu:'; - - @override - String get download_agreement_1 => - 'Ich weiß, dass ich Raubkopien von Musik betreibe. Ich bin böse.'; - - @override - String get download_agreement_2 => - 'Ich werde die Künstler, wo immer ich kann, unterstützen, und ich tue dies nur, weil ich kein Geld habe, um ihre Kunst zu kaufen.'; - - @override - String get download_agreement_3 => - 'Mir ist vollkommen bewusst, dass meine IP-Adresse auf YouTube gesperrt werden kann, und ich halte Spotube oder seine Eigentümer/Mitarbeiter nicht für etwaige Unfälle verantwortlich, die durch meine derzeitige Handlung verursacht werden.'; - - @override - String get decline => 'Ablehnen'; - - @override - String get accept => 'Akzeptieren'; - - @override - String get details => 'Details'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Kanal'; - - @override - String get likes => 'Likes'; - - @override - String get dislikes => 'Dislikes'; - - @override - String get views => 'Aufrufe'; - - @override - String get streamUrl => 'Stream-URL'; - - @override - String get stop => 'Stopp'; - - @override - String get sort_newest => 'Nach neuesten Hinzufügungen sortieren'; - - @override - String get sort_oldest => 'Nach ältesten Hinzufügungen sortieren'; - - @override - String get sleep_timer => 'Schlaftimer'; - - @override - String mins(Object minutes) { - return '$minutes Minuten'; - } - - @override - String hours(Object hours) { - return '$hours Stunden'; - } - - @override - String hour(Object hours) { - return '$hours Stunde'; - } - - @override - String get custom_hours => 'Benutzerdefinierte Stunden'; - - @override - String get logs => 'Protokolle'; - - @override - String get developers => 'Entwickler'; - - @override - String get not_logged_in => 'Sie sind nicht angemeldet'; - - @override - String get search_mode => 'Suchmodus'; - - @override - String get audio_source => 'Audioquelle'; - - @override - String get ok => 'OK'; - - @override - String get failed_to_encrypt => 'Verschlüsselung fehlgeschlagen'; - - @override - String get encryption_failed_warning => - 'Spotube verwendet Verschlüsselung, um Ihre Daten sicher zu speichern. Dies ist jedoch fehlgeschlagen. Daher wird es auf unsichere Speicherung zurückgreifen\nWenn Sie Linux verwenden, stellen Sie bitte sicher, dass Sie Secret-Services wie gnome-keyring, kde-wallet und keepassxc installiert haben'; - - @override - String get querying_info => 'Abfrageinformationen...'; - - @override - String get piped_api_down => 'Die Piped API ist ausgefallen'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'Die Piped-Instanz $pipedInstance ist derzeit nicht verfügbar\n\nEntweder ändern Sie die Instanz oder wechseln Sie den \'API-Typ\' zur offiziellen YouTube API\n\nStellen Sie sicher, dass Sie die App nach der Änderung neu starten'; - } - - @override - String get you_are_offline => 'Sie sind derzeit offline'; - - @override - String get connection_restored => - 'Ihre Internetverbindung wurde wiederhergestellt'; - - @override - String get use_system_title_bar => 'System-Titelleiste verwenden'; - - @override - String get crunching_results => 'Ergebnisse werden verarbeitet...'; - - @override - String get search_to_get_results => 'Suche, um Ergebnisse zu erhalten'; - - @override - String get use_amoled_mode => 'AMOLED-Modus verwenden'; - - @override - String get pitch_dark_theme => 'Pitch Black Dart Theme'; - - @override - String get normalize_audio => 'Audio normalisieren'; - - @override - String get change_cover => 'Cover ändern'; - - @override - String get add_cover => 'Cover hinzufügen'; - - @override - String get restore_defaults => 'Standardeinstellungen wiederherstellen'; - - @override - String get download_music_format => 'Musik-Downloadformat'; - - @override - String get streaming_music_format => 'Musik-Streamingformat'; - - @override - String get download_music_quality => 'Musik-Downloadqualität'; - - @override - String get streaming_music_quality => 'Musik-Streamingqualität'; - - @override - String get login_with_lastfm => 'Mit Last.fm anmelden'; - - @override - String get connect => 'Verbinden'; - - @override - String get disconnect_lastfm => 'Last.fm trennen'; - - @override - String get disconnect => 'Trennen'; - - @override - String get username => 'Benutzername'; - - @override - String get password => 'Passwort'; - - @override - String get login => 'Anmelden'; - - @override - String get login_with_your_lastfm => 'Mit Ihrem Last.fm-Konto anmelden'; - - @override - String get scrobble_to_lastfm => 'Auf Last.fm scrobbeln'; - - @override - String get go_to_album => 'Zum Album gehen'; - - @override - String get discord_rich_presence => 'Discord Rich Presence'; - - @override - String get browse_all => 'Alles durchsuchen'; - - @override - String get genres => 'Genres'; - - @override - String get explore_genres => 'Genres erkunden'; - - @override - String get friends => 'Freunde'; - - @override - String get no_lyrics_available => - 'Entschuldigung, Texte für diesen Track konnten nicht gefunden werden'; - - @override - String get start_a_radio => 'Radio starten'; - - @override - String get how_to_start_radio => 'Wie möchten Sie das Radio starten?'; - - @override - String get replace_queue_question => - 'Möchten Sie die aktuelle Wiedergabeliste ersetzen oder hinzufügen?'; - - @override - String get endless_playback => 'Endlose Wiedergabe'; - - @override - String get delete_playlist => 'Wiedergabeliste löschen'; - - @override - String get delete_playlist_confirmation => - 'Sind Sie sicher, dass Sie diese Wiedergabeliste löschen möchten?'; - - @override - String get local_tracks => 'Lokale Titel'; - - @override - String get local_tab => 'Lokal'; - - @override - String get song_link => 'Lied-Link'; - - @override - String get skip_this_nonsense => 'Diesen Unsinn überspringen'; - - @override - String get freedom_of_music => '“Freiheit der Musik”'; - - @override - String get freedom_of_music_palm => - '“Freiheit der Musik in Ihrer Handfläche”'; - - @override - String get get_started => 'Lass uns anfangen'; - - @override - String get youtube_source_description => - 'Empfohlen und funktioniert am besten.'; - - @override - String get piped_source_description => - 'Fühlen Sie sich frei? Wie YouTube, aber viel freier.'; - - @override - String get jiosaavn_source_description => - 'Am besten für die südasiatische Region.'; - - @override - String get invidious_source_description => - 'Ähnlich wie Piped, aber mit höherer Verfügbarkeit'; - - @override - String highest_quality(Object quality) { - return 'Höchste Qualität: $quality'; - } - - @override - String get select_audio_source => 'Audioquelle auswählen'; - - @override - String get endless_playback_description => - 'Neue Lieder automatisch\nam Ende der Wiedergabeliste hinzufügen'; - - @override - String get choose_your_region => 'Wählen Sie Ihre Region'; - - @override - String get choose_your_region_description => - 'Dies wird Spotube helfen, Ihnen den richtigen Inhalt\nfür Ihren Standort anzuzeigen.'; - - @override - String get choose_your_language => 'Wählen Sie Ihre Sprache'; - - @override - String get help_project_grow => 'Helfen Sie diesem Projekt zu wachsen'; - - @override - String get help_project_grow_description => - 'Spotube ist ein Open-Source-Projekt. Sie können diesem Projekt helfen, indem Sie zum Projekt beitragen, Fehler melden oder neue Funktionen vorschlagen.'; - - @override - String get contribute_on_github => 'Auf GitHub beitragen'; - - @override - String get donate_on_open_collective => 'Auf Open Collective spenden'; - - @override - String get browse_anonymously => 'Anonym durchsuchen'; - - @override - String get enable_connect => 'Verbindung aktivieren'; - - @override - String get enable_connect_description => - 'Spotube von anderen Geräten steuern'; - - @override - String get devices => 'Geräte'; - - @override - String get select => 'Auswählen'; - - @override - String connect_client_alert(Object client) { - return 'Du wirst von $client gesteuert'; - } - - @override - String get this_device => 'Dieses Gerät'; - - @override - String get remote => 'Fernbedienung'; - - @override - String get stats => 'Statistiken'; - - @override - String and_n_more(Object count) { - return 'und $count mehr'; - } - - @override - String get recently_played => 'Zuletzt gespielt'; - - @override - String get browse_more => 'Mehr durchsuchen'; - - @override - String get no_title => 'Kein Titel'; - - @override - String get not_playing => 'Wird nicht abgespielt'; - - @override - String get epic_failure => 'Episches Versagen!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return '$tracks_length Titel zur Warteschlange hinzugefügt'; - } - - @override - String get spotube_has_an_update => 'Spotube hat ein Update'; - - @override - String get download_now => 'Jetzt herunterladen'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum wurde veröffentlicht'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version wurde veröffentlicht'; - } - - @override - String get read_the_latest => 'Lese die neuesten '; - - @override - String get release_notes => 'Versionshinweise'; - - @override - String get pick_color_scheme => 'Farbschema wählen'; - - @override - String get save => 'Speichern'; - - @override - String get choose_the_device => 'Wähle das Gerät:'; - - @override - String get multiple_device_connected => - 'Es sind mehrere Geräte verbunden.\nWähle das Gerät, auf dem diese Aktion ausgeführt werden soll'; - - @override - String get nothing_found => 'Nichts gefunden'; - - @override - String get the_box_is_empty => 'Die Box ist leer'; - - @override - String get top_artists => 'Top-Künstler'; - - @override - String get top_albums => 'Top-Alben'; - - @override - String get this_week => 'Diese Woche'; - - @override - String get this_month => 'Diesen Monat'; - - @override - String get last_6_months => 'Letzte 6 Monate'; - - @override - String get this_year => 'Dieses Jahr'; - - @override - String get last_2_years => 'Letzte 2 Jahre'; - - @override - String get all_time => 'Alle Zeiten'; - - @override - String powered_by_provider(Object providerName) { - return 'Bereitgestellt von $providerName'; - } - - @override - String get email => 'Email'; - - @override - String get profile_followers => 'Follower'; - - @override - String get birthday => 'Geburtstag'; - - @override - String get subscription => 'Abonnement'; - - @override - String get not_born => 'Nicht geboren'; - - @override - String get hacker => 'Hacker'; - - @override - String get profile => 'Profil'; - - @override - String get no_name => 'Kein Name'; - - @override - String get edit => 'Bearbeiten'; - - @override - String get user_profile => 'Benutzerprofil'; - - @override - String count_plays(Object count) { - return '$count Wiedergaben'; - } - - @override - String get streaming_fees_hypothetical => 'Streaming-Gebühren (hypothetisch)'; - - @override - String get minutes_listened => 'Gehörte Minuten'; - - @override - String get streamed_songs => 'Gestreamte Lieder'; - - @override - String count_streams(Object count) { - return '$count Streams'; - } - - @override - String get owned_by_you => 'In Ihrem Besitz'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl in die Zwischenablage kopiert'; - } - - @override - String get hipotetical_calculation => - '*Diese Berechnung basiert auf der durchschnittlichen Auszahlung pro Stream (0,003 USD bis 0,005 USD) auf Online-Musik-Streaming-Plattformen. Sie ist hypothetisch und soll dem Nutzer veranschaulichen, wie viel er den Künstlern bezahlt hätte, wenn er ihren Song auf verschiedenen Streaming-Plattformen gehört hätte.'; - - @override - String count_mins(Object minutes) { - return '$minutes Minuten'; - } - - @override - String get summary_minutes => 'Minuten'; - - @override - String get summary_listened_to_music => 'Hat Musik gehört'; - - @override - String get summary_songs => 'Lieder'; - - @override - String get summary_streamed_overall => 'Insgesamt gestreamt'; - - @override - String get summary_owed_to_artists => - 'Den Künstlern geschuldet\nDiesen Monat'; - - @override - String get summary_artists => 'Künstler'; - - @override - String get summary_music_reached_you => 'Musik hat Sie erreicht'; - - @override - String get summary_full_albums => 'volle Alben'; - - @override - String get summary_got_your_love => 'Hat Ihre Liebe gewonnen'; - - @override - String get summary_playlists => 'Wiedergabelisten'; - - @override - String get summary_were_on_repeat => 'Wurden wiederholt'; - - @override - String total_money(Object money) { - return 'Gesamt $money'; - } - - @override - String get webview_not_found => 'Webview nicht gefunden'; - - @override - String get webview_not_found_description => - 'Es ist keine Webview-Laufzeitumgebung auf Ihrem Gerät installiert.\nFalls installiert, stellen Sie sicher, dass es im environment PATH ist\n\nNach der Installation starten Sie die App neu'; - - @override - String get unsupported_platform => 'Nicht unterstützte Plattform'; - - @override - String get cache_music => 'Musik zwischenspeichern'; - - @override - String get open => 'Öffnen'; - - @override - String get cache_folder => 'Cache-Ordner'; - - @override - String get export => 'Exportieren'; - - @override - String get clear_cache => 'Cache leeren'; - - @override - String get clear_cache_confirmation => 'Möchten Sie den Cache leeren?'; - - @override - String get export_cache_files => 'Cachedateien exportieren'; - - @override - String found_n_files(Object count) { - return '$count Dateien gefunden'; - } - - @override - String get export_cache_confirmation => - 'Möchten Sie diese Dateien exportieren nach'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return '$filesExported von $files Dateien exportiert'; - } - - @override - String get undo => 'Rückgängig'; - - @override - String get download_all => 'Alle herunterladen'; - - @override - String get add_all_to_playlist => 'Alle zur Playlist hinzufügen'; - - @override - String get add_all_to_queue => 'Alle zur Warteschlange hinzufügen'; - - @override - String get play_all_next => 'Alle als Nächstes abspielen'; - - @override - String get pause => 'Pause'; - - @override - String get view_all => 'Alle ansehen'; - - @override - String get no_tracks_added_yet => 'Sie haben noch keine Titel hinzugefügt.'; - - @override - String get no_tracks => 'Es sieht so aus, als ob hier keine Titel sind.'; - - @override - String get no_tracks_listened_yet => - 'Es scheint, dass Sie noch nichts gehört haben.'; - - @override - String get not_following_artists => 'Sie folgen noch keinem Künstler.'; - - @override - String get no_favorite_albums_yet => - 'Es sieht so aus, als ob Sie noch keine Alben zu Ihren Favoriten hinzugefügt haben.'; - - @override - String get no_logs_found => 'Keine Protokolle gefunden'; - - @override - String get youtube_engine => 'YouTube-Engine'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine ist nicht installiert'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine ist nicht auf Ihrem System installiert.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Stellen Sie sicher, dass es im PATH verfügbar ist oder\nsetzen Sie den absoluten Pfad zur $engine ausführbaren Datei unten.'; - } - - @override - String get youtube_engine_unix_issue_message => - 'In macOS/Linux/unixähnlichen Betriebssystemen funktioniert das Setzen des Pfads in .zshrc/.bashrc/.bash_profile usw. nicht.\nSie müssen den Pfad in der Shell-Konfigurationsdatei festlegen.'; - - @override - String get download => 'Herunterladen'; - - @override - String get file_not_found => 'Datei nicht gefunden'; - - @override - String get custom => 'Benutzerdefiniert'; - - @override - String get add_custom_url => 'Benutzerdefinierte URL hinzufügen'; - - @override - String get edit_port => 'Port bearbeiten'; - - @override - String get port_helper_msg => - 'Der Standardwert ist -1, was eine zufällige Zahl bedeutet. Wenn Sie eine Firewall konfiguriert haben, wird empfohlen, dies einzustellen.'; - - @override - String connect_request(Object client) { - return '$client die Verbindung erlauben?'; - } - - @override - String get connection_request_denied => - 'Verbindung abgelehnt. Benutzer hat den Zugriff verweigert.'; - - @override - String get an_error_occurred => 'Ein Fehler ist aufgetreten'; - - @override - String get copy_to_clipboard => 'In die Zwischenablage kopieren'; - - @override - String get view_logs => 'Protokolle anzeigen'; - - @override - String get retry => 'Erneut versuchen'; - - @override - String get no_default_metadata_provider_selected => - 'Sie haben keinen Standard-Metadatenanbieter festgelegt'; - - @override - String get manage_metadata_providers => 'Metadatenanbieter verwalten'; - - @override - String get open_link_in_browser => 'Link im Browser öffnen?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Möchten Sie folgenden Link öffnen?'; - - @override - String get unsafe_url_warning => - 'Das Öffnen von Links aus nicht vertrauenswürdigen Quellen kann unsicher sein. Seien Sie vorsichtig!\nSie können den Link auch in Ihre Zwischenablage kopieren.'; - - @override - String get copy_link => 'Link kopieren'; - - @override - String get building_your_timeline => - 'Ihr Zeitverlauf wird basierend auf Ihren Hördaten erstellt…'; - - @override - String get official => 'Offiziell'; - - @override - String author_name(Object author) { - return 'Autor: $author'; - } - - @override - String get third_party => 'Drittanbieter'; - - @override - String get plugin_requires_authentication => - 'Plugin erfordert Authentifizierung'; - - @override - String get update_available => 'Update verfügbar'; - - @override - String get supports_scrobbling => 'Unterstützt Scrobbling'; - - @override - String get plugin_scrobbling_info => - 'Dieses Plugin scrobbelt Ihre Musik, um Ihre Hörhistorie zu erstellen.'; - - @override - String get default_metadata_source => 'Standard-Metadatenquelle'; - - @override - String get set_default_metadata_source => - 'Standard-Metadatenquelle festlegen'; - - @override - String get default_audio_source => 'Standard-Audioquelle'; - - @override - String get set_default_audio_source => 'Standard-Audioquelle festlegen'; - - @override - String get set_default => 'Als Standard festlegen'; - - @override - String get support => 'Unterstützung'; - - @override - String get support_plugin_development => 'Plugin-Entwicklung unterstützen'; - - @override - String can_access_name_api(Object name) { - return '- Kann auf **$name**-API zugreifen'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Möchten Sie dieses Plugin installieren?'; - - @override - String get third_party_plugin_warning => - 'Dieses Plugin stammt aus einem Drittanbieter-Repository. Bitte stellen Sie sicher, dass Sie der Quelle vertrauen, bevor Sie es installieren.'; - - @override - String get author => 'Autor'; - - @override - String get this_plugin_can_do_following => 'Dieses Plugin kann Folgendes:'; - - @override - String get install => 'Installieren'; - - @override - String get install_a_metadata_provider => - 'Einen Metadatenanbieter installieren'; - - @override - String get no_tracks_playing => 'Derzeit wird kein Titel abgespielt'; - - @override - String get synced_lyrics_not_available => - 'Synchronisierte Liedtexte sind für dieses Lied nicht verfügbar. Bitte verwenden Sie stattdessen'; - - @override - String get plain_lyrics => 'Einfache Liedtexte'; - - @override - String get tab_instead => 'stattdessen die Tab-Taste verwenden.'; - - @override - String get disclaimer => 'Haftungsausschluss'; - - @override - String get third_party_plugin_dmca_notice => - 'Das Spotube-Team übernimmt keine Verantwortung (auch nicht rechtlicher Art) für Plugins \"Drittanbieter\". Nutzen Sie diese auf eigenes Risiko. Für Fehler/Probleme melden Sie sich bitte beim Plugin-Repository.\n\nWenn ein Plugin \"Drittanbieter\" gegen die ToS/DMCA eines Dienstes bzw. gesetzlicher Vorschriften verstößt, wenden Sie sich bitte an den Plugin-Autor oder die Hosting-Plattform (z. B. GitHub/Codeberg), um Maßnahmen zu ergreifen. Die genannten Plugins (mit \"Drittanbieter\"-Kennzeichnung) werden öffentlich und gemeinschaftlich gepflegt. Wir kuratieren sie nicht und können keine Maßnahmen ergreifen.\n\n'; - - @override - String get input_does_not_match_format => - 'Eingabe entspricht nicht dem geforderten Format'; - - @override - String get plugins => 'Plugins'; - - @override - String get paste_plugin_download_url => - 'Download-URL, GitHub/Codeberg-Repo-URL oder direkten Link zur .smplug-Datei einfügen'; - - @override - String get download_and_install_plugin_from_url => - 'Plugin per URL herunterladen und installieren'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Plugin konnte nicht hinzugefügt werden: $error'; - } - - @override - String get upload_plugin_from_file => 'Plugin per Datei hochladen'; - - @override - String get installed => 'Installiert'; - - @override - String get available_plugins => 'Verfügbare Plugins'; - - @override - String get configure_plugins => - 'Richte deine eigenen Metadatenanbieter- und Audioquellen-Plugins ein'; - - @override - String get audio_scrobblers => 'Audio-Scrobbler'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Quelle: '; - - @override - String get uncompressed => 'Unkomprimiert'; - - @override - String get dab_music_source_description => - 'Für Audiophile. Bietet hochwertige/verlustfreie Audiostreams. Präzises ISRC-basiertes Track-Matching.'; -} diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart deleted file mode 100644 index 83a2c24c..00000000 --- a/lib/l10n/generated/app_localizations_en.dart +++ /dev/null @@ -1,1564 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for English (`en`). -class AppLocalizationsEn extends AppLocalizations { - AppLocalizationsEn([String locale = 'en']) : super(locale); - - @override - String get guest => 'Guest'; - - @override - String get browse => 'Browse'; - - @override - String get search => 'Search'; - - @override - String get library => 'Library'; - - @override - String get lyrics => 'Lyrics'; - - @override - String get settings => 'Settings'; - - @override - String get genre_categories_filter => 'Filter categories or genres...'; - - @override - String get genre => 'Genre'; - - @override - String get personalized => 'Personalized'; - - @override - String get featured => 'Featured'; - - @override - String get new_releases => 'New Releases'; - - @override - String get songs => 'Songs'; - - @override - String playing_track(Object track) { - return 'Playing $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'This will clear the current queue. $track_length tracks will be removed\nDo you want to continue?'; - } - - @override - String get load_more => 'Load more'; - - @override - String get playlists => 'Playlists'; - - @override - String get artists => 'Artists'; - - @override - String get albums => 'Albums'; - - @override - String get tracks => 'Tracks'; - - @override - String get downloads => 'Downloads'; - - @override - String get filter_playlists => 'Filter your playlists...'; - - @override - String get liked_tracks => 'Liked Tracks'; - - @override - String get liked_tracks_description => 'All your liked tracks'; - - @override - String get playlist => 'Playlist'; - - @override - String get create_a_playlist => 'Create a playlist'; - - @override - String get update_playlist => 'Update playlist'; - - @override - String get create => 'Create'; - - @override - String get cancel => 'Cancel'; - - @override - String get update => 'Update'; - - @override - String get playlist_name => 'Playlist Name'; - - @override - String get name_of_playlist => 'Name of the playlist'; - - @override - String get description => 'Description'; - - @override - String get public => 'Public'; - - @override - String get collaborative => 'Collaborative'; - - @override - String get search_local_tracks => 'Search local tracks...'; - - @override - String get play => 'Play'; - - @override - String get delete => 'Delete'; - - @override - String get none => 'None'; - - @override - String get sort_a_z => 'Sort by A-Z'; - - @override - String get sort_z_a => 'Sort by Z-A'; - - @override - String get sort_artist => 'Sort by Artist'; - - @override - String get sort_album => 'Sort by Album'; - - @override - String get sort_duration => 'Sort by Duration'; - - @override - String get sort_tracks => 'Sort Tracks'; - - @override - String currently_downloading(Object tracks_length) { - return 'Currently Downloading ($tracks_length)'; - } - - @override - String get cancel_all => 'Cancel All'; - - @override - String get filter_artist => 'Filter artists...'; - - @override - String followers(Object followers) { - return '$followers Followers'; - } - - @override - String get add_artist_to_blacklist => 'Add artist to blacklist'; - - @override - String get top_tracks => 'Top Tracks'; - - @override - String get fans_also_like => 'Fans also like'; - - @override - String get loading => 'Loading...'; - - @override - String get artist => 'Artist'; - - @override - String get blacklisted => 'Blacklisted'; - - @override - String get following => 'Following'; - - @override - String get follow => 'Follow'; - - @override - String get artist_url_copied => 'Artist URL copied to clipboard'; - - @override - String added_to_queue(Object tracks) { - return 'Added $tracks tracks to queue'; - } - - @override - String get filter_albums => 'Filter albums...'; - - @override - String get synced => 'Synced'; - - @override - String get plain => 'Plain'; - - @override - String get shuffle => 'Shuffle'; - - @override - String get search_tracks => 'Search tracks...'; - - @override - String get released => 'Released'; - - @override - String error(Object error) { - return 'Error $error'; - } - - @override - String get title => 'Title'; - - @override - String get time => 'Time'; - - @override - String get more_actions => 'More actions'; - - @override - String download_count(Object count) { - return 'Download ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Add ($count) to Playlist'; - } - - @override - String add_count_to_queue(Object count) { - return 'Add ($count) to Queue'; - } - - @override - String play_count_next(Object count) { - return 'Play ($count) next'; - } - - @override - String get album => 'Album'; - - @override - String copied_to_clipboard(Object data) { - return 'Copied $data to clipboard'; - } - - @override - String add_to_following_playlists(Object track) { - return 'Add $track to following Playlists'; - } - - @override - String get add => 'Add'; - - @override - String added_track_to_queue(Object track) { - return 'Added $track to queue'; - } - - @override - String get add_to_queue => 'Add to queue'; - - @override - String track_will_play_next(Object track) { - return '$track will play next'; - } - - @override - String get play_next => 'Play next'; - - @override - String removed_track_from_queue(Object track) { - return 'Removed $track from queue'; - } - - @override - String get remove_from_queue => 'Remove from queue'; - - @override - String get remove_from_favorites => 'Remove from favorites'; - - @override - String get save_as_favorite => 'Save as favorite'; - - @override - String get add_to_playlist => 'Add to playlist'; - - @override - String get remove_from_playlist => 'Remove from playlist'; - - @override - String get add_to_blacklist => 'Add to blacklist'; - - @override - String get remove_from_blacklist => 'Remove from blacklist'; - - @override - String get share => 'Share'; - - @override - String get mini_player => 'Mini Player'; - - @override - String get slide_to_seek => 'Slide to seek forward or backward'; - - @override - String get shuffle_playlist => 'Shuffle playlist'; - - @override - String get unshuffle_playlist => 'Unshuffle playlist'; - - @override - String get previous_track => 'Previous track'; - - @override - String get next_track => 'Next track'; - - @override - String get pause_playback => 'Pause Playback'; - - @override - String get resume_playback => 'Resume Playback'; - - @override - String get loop_track => 'Loop track'; - - @override - String get no_loop => 'No loop'; - - @override - String get repeat_playlist => 'Repeat playlist'; - - @override - String get queue => 'Queue'; - - @override - String get alternative_track_sources => 'Alternative track sources'; - - @override - String get download_track => 'Download track'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks tracks in queue'; - } - - @override - String get clear_all => 'Clear all'; - - @override - String get show_hide_ui_on_hover => 'Show/Hide UI on hover'; - - @override - String get always_on_top => 'Always on top'; - - @override - String get exit_mini_player => 'Exit Mini player'; - - @override - String get download_location => 'Download location'; - - @override - String get local_library => 'Local library'; - - @override - String get add_library_location => 'Add to library'; - - @override - String get remove_library_location => 'Remove from library'; - - @override - String get account => 'Account'; - - @override - String get logout => 'Logout'; - - @override - String get logout_of_this_account => 'Logout of this account'; - - @override - String get language_region => 'Language & Region'; - - @override - String get language => 'Language'; - - @override - String get system_default => 'System Default'; - - @override - String get market_place_region => 'Marketplace Region'; - - @override - String get recommendation_country => 'Recommendation Country'; - - @override - String get appearance => 'Appearance'; - - @override - String get layout_mode => 'Layout Mode'; - - @override - String get override_layout_settings => - 'Override responsive layout mode settings'; - - @override - String get adaptive => 'Adaptive'; - - @override - String get compact => 'Compact'; - - @override - String get extended => 'Extended'; - - @override - String get theme => 'Theme'; - - @override - String get dark => 'Dark'; - - @override - String get light => 'Light'; - - @override - String get system => 'System'; - - @override - String get accent_color => 'Accent Color'; - - @override - String get sync_album_color => 'Sync album color'; - - @override - String get sync_album_color_description => - 'Uses the dominant color of the album art as the accent color'; - - @override - String get playback => 'Playback'; - - @override - String get audio_quality => 'Audio Quality'; - - @override - String get high => 'High'; - - @override - String get low => 'Low'; - - @override - String get pre_download_play => 'Pre-download and play'; - - @override - String get pre_download_play_description => - 'Instead of streaming audio, download bytes and play instead (Recommended for higher bandwidth users)'; - - @override - String get skip_non_music => 'Skip non-music segments (SponsorBlock)'; - - @override - String get blacklist_description => 'Blacklisted tracks and artists'; - - @override - String get wait_for_download_to_finish => - 'Please wait for the current download to finish'; - - @override - String get desktop => 'Desktop'; - - @override - String get close_behavior => 'Close Behavior'; - - @override - String get close => 'Close'; - - @override - String get minimize_to_tray => 'Minimize to tray'; - - @override - String get show_tray_icon => 'Show System tray icon'; - - @override - String get about => 'About'; - - @override - String get u_love_spotube => 'We know you love Spotube'; - - @override - String get check_for_updates => 'Check for updates'; - - @override - String get about_spotube => 'About Spotube'; - - @override - String get blacklist => 'Blacklist'; - - @override - String get please_sponsor => 'Please Sponsor/Donate'; - - @override - String get spotube_description => - 'Open source extensible music streaming platform and app, based on BYOMM (Bring your own music metadata) concept'; - - @override - String get version => 'Version'; - - @override - String get build_number => 'Build Number'; - - @override - String get founder => 'Founder'; - - @override - String get repository => 'Repository'; - - @override - String get bug_issues => 'Bug+Issues'; - - @override - String get made_with => 'Made with ❤️ in Bangladesh🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'License'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Don\'t worry, any of your credentials won\'t be collected or shared with anyone'; - - @override - String get know_how_to_login => 'Don\'t know how to do this?'; - - @override - String get follow_step_by_step_guide => 'Follow along the Step by Step guide'; - - @override - String cookie_name_cookie(Object name) { - return '$name Cookie'; - } - - @override - String get fill_in_all_fields => 'Please fill in all the fields'; - - @override - String get submit => 'Submit'; - - @override - String get exit => 'Exit'; - - @override - String get previous => 'Previous'; - - @override - String get next => 'Next'; - - @override - String get done => 'Done'; - - @override - String get step_1 => 'Step 1'; - - @override - String get first_go_to => 'First, Go to'; - - @override - String get something_went_wrong => 'Something went wrong'; - - @override - String get piped_instance => 'Piped Server Instance'; - - @override - String get piped_description => - 'The Piped server instance to use for track matching'; - - @override - String get piped_warning => - 'Some of them might not work well. So use at your own risk'; - - @override - String get invidious_instance => 'Invidious Server Instance'; - - @override - String get invidious_description => - 'The Invidious server instance to use for track matching'; - - @override - String get invidious_warning => - 'Some of them might not work well. So use at your own risk'; - - @override - String get generate => 'Generate'; - - @override - String track_exists(Object track) { - return 'Track $track already exists'; - } - - @override - String get replace_downloaded_tracks => 'Replace all downloaded tracks'; - - @override - String get skip_download_tracks => 'Skip downloading all downloaded tracks'; - - @override - String get do_you_want_to_replace => - 'Do you want to replace the existing track??'; - - @override - String get replace => 'Replace'; - - @override - String get skip => 'Skip'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Select up to $count $type'; - } - - @override - String get select_genres => 'Select Genres'; - - @override - String get add_genres => 'Add Genres'; - - @override - String get country => 'Country'; - - @override - String get number_of_tracks_generate => 'Number of tracks to generate'; - - @override - String get acousticness => 'Acousticness'; - - @override - String get danceability => 'Danceability'; - - @override - String get energy => 'Energy'; - - @override - String get instrumentalness => 'Instrumentalness'; - - @override - String get liveness => 'Liveness'; - - @override - String get loudness => 'Loudness'; - - @override - String get speechiness => 'Speechiness'; - - @override - String get valence => 'Valence'; - - @override - String get popularity => 'Popularity'; - - @override - String get key => 'Key'; - - @override - String get duration => 'Duration (s)'; - - @override - String get tempo => 'Tempo (BPM)'; - - @override - String get mode => 'Mode'; - - @override - String get time_signature => 'Time Signature'; - - @override - String get short => 'Short'; - - @override - String get medium => 'Medium'; - - @override - String get long => 'Long'; - - @override - String get min => 'Min'; - - @override - String get max => 'Max'; - - @override - String get target => 'Target'; - - @override - String get moderate => 'Moderate'; - - @override - String get deselect_all => 'Deselect All'; - - @override - String get select_all => 'Select All'; - - @override - String get are_you_sure => 'Are you sure?'; - - @override - String get generating_playlist => 'Generating your custom playlist...'; - - @override - String selected_count_tracks(Object count) { - return 'Selected $count tracks'; - } - - @override - String get download_warning => - 'If you download all Tracks at bulk you\'re clearly pirating Music & causing damage to the creative society of Music. I hope you are aware of this. Always, try respecting & supporting Artist\'s hard work'; - - @override - String get download_ip_ban_warning => - 'BTW, your IP can get blocked on YouTube due excessive download requests than usual. IP block means you can\'t use YouTube (even if you\'re logged in) for at least 2-3 months from that IP device. And Spotube doesn\'t hold any responsibility if this ever happens'; - - @override - String get by_clicking_accept_terms => - 'By clicking \'accept\' you agree to following terms:'; - - @override - String get download_agreement_1 => 'I know I\'m pirating Music. I\'m bad'; - - @override - String get download_agreement_2 => - 'I\'ll support the Artist wherever I can and I\'m only doing this because I don\'t have money to buy their art'; - - @override - String get download_agreement_3 => - 'I\'m completely aware that my IP can get blocked on YouTube & I don\'t hold Spotube or his owners/contributors responsible for any accidents caused by my current action'; - - @override - String get decline => 'Decline'; - - @override - String get accept => 'Accept'; - - @override - String get details => 'Details'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Channel'; - - @override - String get likes => 'Likes'; - - @override - String get dislikes => 'Dislikes'; - - @override - String get views => 'Views'; - - @override - String get streamUrl => 'Stream URL'; - - @override - String get stop => 'Stop'; - - @override - String get sort_newest => 'Sort by newest added'; - - @override - String get sort_oldest => 'Sort by oldest added'; - - @override - String get sleep_timer => 'Sleep Timer'; - - @override - String mins(Object minutes) { - return '$minutes Minutes'; - } - - @override - String hours(Object hours) { - return '$hours Hours'; - } - - @override - String hour(Object hours) { - return '$hours Hour'; - } - - @override - String get custom_hours => 'Custom Hours'; - - @override - String get logs => 'Logs'; - - @override - String get developers => 'Developers'; - - @override - String get not_logged_in => 'You\'re not logged in'; - - @override - String get search_mode => 'Search Mode'; - - @override - String get audio_source => 'Audio Source'; - - @override - String get ok => 'Ok'; - - @override - String get failed_to_encrypt => 'Failed to encrypt'; - - @override - String get encryption_failed_warning => - 'Spotube uses encryption to securely store your data. But failed to do so. So it\'ll fallback to insecure storage\nIf you\'re using linux, please make sure you\'ve any secret-service (gnome-keyring, kde-wallet, keepassxc etc) installed'; - - @override - String get querying_info => 'Querying info...'; - - @override - String get piped_api_down => 'Piped API is down'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'The Piped instance $pipedInstance is currently down\n\nEither change the instance or change the \'API type\' to official YouTube API\n\nMake sure to restart the app after change'; - } - - @override - String get you_are_offline => 'You are currently offline'; - - @override - String get connection_restored => 'Your internet connection was restored'; - - @override - String get use_system_title_bar => 'Use system title bar'; - - @override - String get crunching_results => 'Crunching results...'; - - @override - String get search_to_get_results => 'Search to get results'; - - @override - String get use_amoled_mode => 'Pitch black dark theme'; - - @override - String get pitch_dark_theme => 'AMOLED Mode'; - - @override - String get normalize_audio => 'Normalize audio'; - - @override - String get change_cover => 'Change cover'; - - @override - String get add_cover => 'Add cover'; - - @override - String get restore_defaults => 'Restore defaults'; - - @override - String get download_music_format => 'Download music format'; - - @override - String get streaming_music_format => 'Streaming music format'; - - @override - String get download_music_quality => 'Download music quality'; - - @override - String get streaming_music_quality => 'Streaming music quality'; - - @override - String get login_with_lastfm => 'Login with Last.fm'; - - @override - String get connect => 'Connect'; - - @override - String get disconnect_lastfm => 'Disconnect Last.fm'; - - @override - String get disconnect => 'Disconnect'; - - @override - String get username => 'Username'; - - @override - String get password => 'Password'; - - @override - String get login => 'Login'; - - @override - String get login_with_your_lastfm => 'Login with your Last.fm account'; - - @override - String get scrobble_to_lastfm => 'Scrobble to Last.fm'; - - @override - String get go_to_album => 'Go to Album'; - - @override - String get discord_rich_presence => 'Discord Rich Presence'; - - @override - String get browse_all => 'Browse All'; - - @override - String get genres => 'Genres'; - - @override - String get explore_genres => 'Explore Genres'; - - @override - String get friends => 'Friends'; - - @override - String get no_lyrics_available => 'Sorry, unable find lyrics for this track'; - - @override - String get start_a_radio => 'Start a Radio'; - - @override - String get how_to_start_radio => 'How do you want to start the radio?'; - - @override - String get replace_queue_question => - 'Do you want to replace the current queue or append to it?'; - - @override - String get endless_playback => 'Endless Playback'; - - @override - String get delete_playlist => 'Delete Playlist'; - - @override - String get delete_playlist_confirmation => - 'Are you sure you want to delete this playlist?'; - - @override - String get local_tracks => 'Local Tracks'; - - @override - String get local_tab => 'Local'; - - @override - String get song_link => 'Song Link'; - - @override - String get skip_this_nonsense => 'Skip this nonsense'; - - @override - String get freedom_of_music => '“Freedom of Music”'; - - @override - String get freedom_of_music_palm => - '“Freedom of Music in the palm of your hand”'; - - @override - String get get_started => 'Let\'s get started'; - - @override - String get youtube_source_description => 'Recommended and works best.'; - - @override - String get piped_source_description => - 'Feeling free? Same as YouTube but a lot free.'; - - @override - String get jiosaavn_source_description => 'Best for South Asian region.'; - - @override - String get invidious_source_description => - 'Similar to Piped but with higher availability.'; - - @override - String highest_quality(Object quality) { - return 'Highest Quality: $quality'; - } - - @override - String get select_audio_source => 'Select Audio Source'; - - @override - String get endless_playback_description => - 'Automatically append new songs\nto the end of the queue'; - - @override - String get choose_your_region => 'Choose your region'; - - @override - String get choose_your_region_description => - 'This will help Spotube show you the right content\nfor your location.'; - - @override - String get choose_your_language => 'Choose your language'; - - @override - String get help_project_grow => 'Help this project grow'; - - @override - String get help_project_grow_description => - 'Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.'; - - @override - String get contribute_on_github => 'Contribute on GitHub'; - - @override - String get donate_on_open_collective => 'Donate on Open Collective'; - - @override - String get browse_anonymously => 'Browse Anonymously'; - - @override - String get enable_connect => 'Enable Connect'; - - @override - String get enable_connect_description => 'Control Spotube from other devices'; - - @override - String get devices => 'Devices'; - - @override - String get select => 'Select'; - - @override - String connect_client_alert(Object client) { - return 'You\'re being controlled by $client'; - } - - @override - String get this_device => 'This Device'; - - @override - String get remote => 'Remote'; - - @override - String get stats => 'Stats'; - - @override - String and_n_more(Object count) { - return 'and $count more'; - } - - @override - String get recently_played => 'Recently Played'; - - @override - String get browse_more => 'Browse More'; - - @override - String get no_title => 'No Title'; - - @override - String get not_playing => 'Not playing'; - - @override - String get epic_failure => 'Epic failure!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'Added $tracks_length tracks to queue'; - } - - @override - String get spotube_has_an_update => 'Spotube has an update'; - - @override - String get download_now => 'Download Now'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum has been released'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version has been released'; - } - - @override - String get read_the_latest => 'Read the latest '; - - @override - String get release_notes => 'release notes'; - - @override - String get pick_color_scheme => 'Pick color scheme'; - - @override - String get save => 'Save'; - - @override - String get choose_the_device => 'Choose the device:'; - - @override - String get multiple_device_connected => - 'There are multiple device connected.\nChoose the device you want this action to take place'; - - @override - String get nothing_found => 'Nothing found'; - - @override - String get the_box_is_empty => 'The box is empty'; - - @override - String get top_artists => 'Top Artists'; - - @override - String get top_albums => 'Top Albums'; - - @override - String get this_week => 'This week'; - - @override - String get this_month => 'This month'; - - @override - String get last_6_months => 'Last 6 months'; - - @override - String get this_year => 'This year'; - - @override - String get last_2_years => 'Last 2 years'; - - @override - String get all_time => 'All time'; - - @override - String powered_by_provider(Object providerName) { - return 'Powered by $providerName'; - } - - @override - String get email => 'Email'; - - @override - String get profile_followers => 'Followers'; - - @override - String get birthday => 'Birthday'; - - @override - String get subscription => 'Subscription'; - - @override - String get not_born => 'Not born'; - - @override - String get hacker => 'Hacker'; - - @override - String get profile => 'Profile'; - - @override - String get no_name => 'No Name'; - - @override - String get edit => 'Edit'; - - @override - String get user_profile => 'User Profile'; - - @override - String count_plays(Object count) { - return '$count plays'; - } - - @override - String get streaming_fees_hypothetical => 'Streaming fees (hypothetical)'; - - @override - String get minutes_listened => 'Minutes listened'; - - @override - String get streamed_songs => 'Streamed songs'; - - @override - String count_streams(Object count) { - return '$count streams'; - } - - @override - String get owned_by_you => 'Owned by you'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return 'Copied $shareUrl to clipboard'; - } - - @override - String get hipotetical_calculation => - '*This is calculated based on average online music streaming platform\'s per stream\npayout of \$0.003 to \$0.005. This is a hypothetical\ncalculation to give user insight about how much they\nwould have paid to the artists if they were to listen\ntheir song in different music streaming platform.'; - - @override - String count_mins(Object minutes) { - return '$minutes mins'; - } - - @override - String get summary_minutes => 'minutes'; - - @override - String get summary_listened_to_music => 'Listened to music'; - - @override - String get summary_songs => 'songs'; - - @override - String get summary_streamed_overall => 'Streamed overall'; - - @override - String get summary_owed_to_artists => 'Owed to artists\nthis month'; - - @override - String get summary_artists => 'artist\'s'; - - @override - String get summary_music_reached_you => 'Music reached you'; - - @override - String get summary_full_albums => 'full albums'; - - @override - String get summary_got_your_love => 'Got your love'; - - @override - String get summary_playlists => 'playlists'; - - @override - String get summary_were_on_repeat => 'Were on repeat'; - - @override - String total_money(Object money) { - return 'Total $money'; - } - - @override - String get webview_not_found => 'Webview not found'; - - @override - String get webview_not_found_description => - 'No webview runtime is installed in your device.\nIf it\'s installed make sure it\'s in the Environment PATH\n\nAfter installing, restart the app'; - - @override - String get unsupported_platform => 'Unsupported platform'; - - @override - String get cache_music => 'Cache music'; - - @override - String get open => 'Open'; - - @override - String get cache_folder => 'Cache folder'; - - @override - String get export => 'Export'; - - @override - String get clear_cache => 'Clear cache'; - - @override - String get clear_cache_confirmation => 'Do you want to clear the cache?'; - - @override - String get export_cache_files => 'Export Cached Files'; - - @override - String found_n_files(Object count) { - return 'Found $count files'; - } - - @override - String get export_cache_confirmation => - 'Do you want to export these files to'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return 'Exported $filesExported out of $files files'; - } - - @override - String get undo => 'Undo'; - - @override - String get download_all => 'Download all'; - - @override - String get add_all_to_playlist => 'Add all to playlist'; - - @override - String get add_all_to_queue => 'Add all to queue'; - - @override - String get play_all_next => 'Play all next'; - - @override - String get pause => 'Pause'; - - @override - String get view_all => 'View all'; - - @override - String get no_tracks_added_yet => - 'Looks like you haven\'t added any tracks yet'; - - @override - String get no_tracks => 'Looks like there are no tracks here'; - - @override - String get no_tracks_listened_yet => - 'Looks like you haven\'t listened to anything yet'; - - @override - String get not_following_artists => 'You\'re not following any artists'; - - @override - String get no_favorite_albums_yet => - 'Looks like you haven\'t added any albums to your favorites yet'; - - @override - String get no_logs_found => 'No logs found'; - - @override - String get youtube_engine => 'YouTube Engine'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine is not installed'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine is not installed in your system.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Make sure it\'s available in the PATH variable or\nset the absolute path to the $engine executable below'; - } - - @override - String get youtube_engine_unix_issue_message => - 'In macOS/Linux/unix like OS\'s, setting path on .zshrc/.bashrc/.bash_profile etc. won\'t work.\nYou need to set the path in the shell configuration file'; - - @override - String get download => 'Download'; - - @override - String get file_not_found => 'File not found'; - - @override - String get custom => 'Custom'; - - @override - String get add_custom_url => 'Add custom URL'; - - @override - String get edit_port => 'Edit port'; - - @override - String get port_helper_msg => - 'Default is -1 which indicates random number. If you\'ve firewall configured, setting this is recommended.'; - - @override - String connect_request(Object client) { - return 'Allow $client to connect?'; - } - - @override - String get connection_request_denied => - 'Connection denied. User denied access.'; - - @override - String get an_error_occurred => 'An error occurred'; - - @override - String get copy_to_clipboard => 'Copy to clipboard'; - - @override - String get view_logs => 'View logs'; - - @override - String get retry => 'Retry'; - - @override - String get no_default_metadata_provider_selected => - 'You\'ve no default metadata provider set'; - - @override - String get manage_metadata_providers => 'Manage metadata providers'; - - @override - String get open_link_in_browser => 'Open Link in Browser?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Do you want to open the following link'; - - @override - String get unsafe_url_warning => - 'It can be unsafe to open links from untrusted sources. Be cautious!\nYou can also copy the link to your clipboard.'; - - @override - String get copy_link => 'Copy Link'; - - @override - String get building_your_timeline => - 'Building your timeline based on your listenings...'; - - @override - String get official => 'Official'; - - @override - String author_name(Object author) { - return 'Author: $author'; - } - - @override - String get third_party => 'Third-party'; - - @override - String get plugin_requires_authentication => 'Plugin requires authentication'; - - @override - String get update_available => 'Update available'; - - @override - String get supports_scrobbling => 'Supports scrobbling'; - - @override - String get plugin_scrobbling_info => - 'This plugin scrobbles your music to generate your listening history.'; - - @override - String get default_metadata_source => 'Default metadata source'; - - @override - String get set_default_metadata_source => 'Set default metadata source'; - - @override - String get default_audio_source => 'Default audio source'; - - @override - String get set_default_audio_source => 'Set default audio source'; - - @override - String get set_default => 'Set default'; - - @override - String get support => 'Support'; - - @override - String get support_plugin_development => 'Support plugin development'; - - @override - String can_access_name_api(Object name) { - return '- Can access **$name** API'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Do you want to install this plugin?'; - - @override - String get third_party_plugin_warning => - 'This plugin is from a third-party repository. Please ensure you trust the source before installing.'; - - @override - String get author => 'Author'; - - @override - String get this_plugin_can_do_following => 'This plugin can do following'; - - @override - String get install => 'Install'; - - @override - String get install_a_metadata_provider => 'Install a Metadata Provider'; - - @override - String get no_tracks_playing => 'No Track being played currently'; - - @override - String get synced_lyrics_not_available => - 'Synced lyrics are not available for this song. Please use the'; - - @override - String get plain_lyrics => 'Plain Lyrics'; - - @override - String get tab_instead => 'tab instead.'; - - @override - String get disclaimer => 'Disclaimer'; - - @override - String get third_party_plugin_dmca_notice => - 'The Spotube team does not hold any responsibility (including legal) for any \"Third-party\" plugins.\nPlease use them at your own risk. For any bugs/issues, please report them to the plugin repository.\n\nIf any \"Third-party\" plugin is breaking ToS/DMCA of any service/legal entity, please ask the \"Third-party\" plugin author or the hosting platform .e.g GitHub/Codeberg to take action. Above listed (\"Third-party\" labelled) are all public/community maintained plugins. We\'re not curating them, so we cannot take any action on them.\n\n'; - - @override - String get input_does_not_match_format => - 'Input doesn\'t match the required format'; - - @override - String get plugins => 'Plugins'; - - @override - String get paste_plugin_download_url => - 'Paste download url or GitHub/Codeberg repo url or direct link to .smplug file'; - - @override - String get download_and_install_plugin_from_url => - 'Download and install plugin from url'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Failed to add plugin: $error'; - } - - @override - String get upload_plugin_from_file => 'Upload plugin from file'; - - @override - String get installed => 'Installed'; - - @override - String get available_plugins => 'Available plugins'; - - @override - String get configure_plugins => - 'Configure your own metadata provider and audio source plugins'; - - @override - String get audio_scrobblers => 'Audio Scrobblers'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Source: '; - - @override - String get uncompressed => 'Uncompressed'; - - @override - String get dab_music_source_description => - 'For audiophiles. Provides high-quality/lossless audio streams. Accurate ISRC based track matching.'; -} diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart deleted file mode 100644 index 0fcd6739..00000000 --- a/lib/l10n/generated/app_localizations_es.dart +++ /dev/null @@ -1,1580 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Spanish Castilian (`es`). -class AppLocalizationsEs extends AppLocalizations { - AppLocalizationsEs([String locale = 'es']) : super(locale); - - @override - String get guest => 'Invitado'; - - @override - String get browse => 'Explorar'; - - @override - String get search => 'Buscar'; - - @override - String get library => 'Biblioteca'; - - @override - String get lyrics => 'Letras'; - - @override - String get settings => 'Configuración'; - - @override - String get genre_categories_filter => 'Filtrar categorías o géneros...'; - - @override - String get genre => 'Género'; - - @override - String get personalized => 'Personalizado'; - - @override - String get featured => 'Destacado'; - - @override - String get new_releases => 'Nuevos Lanzamientos'; - - @override - String get songs => 'Canciones'; - - @override - String playing_track(Object track) { - return 'Reproduciendo $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Esto eliminará la lista actual. Se eliminarán $track_length canciones.\n¿Deseas continuar?'; - } - - @override - String get load_more => 'Cargar más'; - - @override - String get playlists => 'Listas de reproducción'; - - @override - String get artists => 'Artistas'; - - @override - String get albums => 'Álbumes'; - - @override - String get tracks => 'Canciones'; - - @override - String get downloads => 'Descargas'; - - @override - String get filter_playlists => 'Filtrar tus listas de reproducción...'; - - @override - String get liked_tracks => 'Canciones Favoritas'; - - @override - String get liked_tracks_description => 'Todas tus canciones favoritas'; - - @override - String get playlist => 'Lista de reproducción'; - - @override - String get create_a_playlist => 'Crear una lista de reproducción'; - - @override - String get update_playlist => 'Actualizar lista de reproducción'; - - @override - String get create => 'Crear'; - - @override - String get cancel => 'Cancelar'; - - @override - String get update => 'Actualizar'; - - @override - String get playlist_name => 'Nombre de la lista'; - - @override - String get name_of_playlist => 'Nombre de la lista'; - - @override - String get description => 'Descripción'; - - @override - String get public => 'Pública'; - - @override - String get collaborative => 'Colaborativa'; - - @override - String get search_local_tracks => 'Buscar canciones locales...'; - - @override - String get play => 'Reproducir'; - - @override - String get delete => 'Eliminar'; - - @override - String get none => 'Ninguno'; - - @override - String get sort_a_z => 'Ordenar de la A a la Z'; - - @override - String get sort_z_a => 'Ordenar de la Z a la A'; - - @override - String get sort_artist => 'Ordenar por Artista'; - - @override - String get sort_album => 'Ordenar por Álbum'; - - @override - String get sort_duration => 'Ordenar por Duración'; - - @override - String get sort_tracks => 'Ordenar Canciones'; - - @override - String currently_downloading(Object tracks_length) { - return 'Descargando en curso ($tracks_length)'; - } - - @override - String get cancel_all => 'Cancelar todo'; - - @override - String get filter_artist => 'Filtrar artistas...'; - - @override - String followers(Object followers) { - return '$followers Seguidores'; - } - - @override - String get add_artist_to_blacklist => 'Agregar artista a la lista negra'; - - @override - String get top_tracks => 'Mejores Canciones'; - - @override - String get fans_also_like => 'A los fans también les gusta'; - - @override - String get loading => 'Cargando...'; - - @override - String get artist => 'Artista'; - - @override - String get blacklisted => 'En la lista negra'; - - @override - String get following => 'Siguiendo'; - - @override - String get follow => 'Seguir'; - - @override - String get artist_url_copied => 'URL del artista copiada al portapapeles'; - - @override - String added_to_queue(Object tracks) { - return 'Agregadas $tracks canciones a la lista'; - } - - @override - String get filter_albums => 'Filtrar álbumes...'; - - @override - String get synced => 'Sincronizado'; - - @override - String get plain => 'Normal'; - - @override - String get shuffle => 'Aleatorio'; - - @override - String get search_tracks => 'Buscar canciones...'; - - @override - String get released => 'Lanzado'; - - @override - String error(Object error) { - return 'Error $error'; - } - - @override - String get title => 'Título'; - - @override - String get time => 'Duración'; - - @override - String get more_actions => 'Más acciones'; - - @override - String download_count(Object count) { - return 'Descargas ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Agregar ($count) a la lista'; - } - - @override - String add_count_to_queue(Object count) { - return 'Agregar ($count) a la lista'; - } - - @override - String play_count_next(Object count) { - return 'Reproducir ($count) a continuación'; - } - - @override - String get album => 'Álbum'; - - @override - String copied_to_clipboard(Object data) { - return '$data copiado al portapapeles'; - } - - @override - String add_to_following_playlists(Object track) { - return 'Agregar $track a las listas de reproducción siguientes'; - } - - @override - String get add => 'Agregar'; - - @override - String added_track_to_queue(Object track) { - return '$track agregada a la lista'; - } - - @override - String get add_to_queue => 'Agregar a la lista'; - - @override - String track_will_play_next(Object track) { - return '$track se reproducirá a continuación'; - } - - @override - String get play_next => 'Reproducir a continuación'; - - @override - String removed_track_from_queue(Object track) { - return '$track eliminada de la lista'; - } - - @override - String get remove_from_queue => 'Eliminar de la lista'; - - @override - String get remove_from_favorites => 'Eliminar de favoritos'; - - @override - String get save_as_favorite => 'Guardar como favorito'; - - @override - String get add_to_playlist => 'Agregar a la lista'; - - @override - String get remove_from_playlist => 'Eliminar de la lista'; - - @override - String get add_to_blacklist => 'Agregar a la lista negra'; - - @override - String get remove_from_blacklist => 'Eliminar de la lista negra'; - - @override - String get share => 'Compartir'; - - @override - String get mini_player => 'Reproductor Mini'; - - @override - String get slide_to_seek => 'Desliza para buscar adelante o atrás'; - - @override - String get shuffle_playlist => 'Reproducir lista en orden aleatorio'; - - @override - String get unshuffle_playlist => 'Desactivar reproducción aleatoria'; - - @override - String get previous_track => 'Pista anterior'; - - @override - String get next_track => 'Pista siguiente'; - - @override - String get pause_playback => 'Pausar reproducción'; - - @override - String get resume_playback => 'Reanudar reproducción'; - - @override - String get loop_track => 'Repetir pista'; - - @override - String get no_loop => 'Sin bucle'; - - @override - String get repeat_playlist => 'Repetir lista'; - - @override - String get queue => 'Lista'; - - @override - String get alternative_track_sources => 'Fuentes alternativas de canciones'; - - @override - String get download_track => 'Descargar canción'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks canciones en la lista'; - } - - @override - String get clear_all => 'Limpiar todo'; - - @override - String get show_hide_ui_on_hover => - 'Mostrar/Ocultar interfaz al pasar el cursor'; - - @override - String get always_on_top => 'Siempre visible'; - - @override - String get exit_mini_player => 'Salir del reproductor mini'; - - @override - String get download_location => 'Ubicación de descargas'; - - @override - String get local_library => 'Biblioteca local'; - - @override - String get add_library_location => 'Añadir a la biblioteca'; - - @override - String get remove_library_location => 'Eliminar de la biblioteca'; - - @override - String get account => 'Cuenta'; - - @override - String get logout => 'Cerrar sesión'; - - @override - String get logout_of_this_account => 'Cerrar sesión de esta cuenta'; - - @override - String get language_region => 'Idioma y Región'; - - @override - String get language => 'Idioma'; - - @override - String get system_default => 'Predeterminado del sistema'; - - @override - String get market_place_region => 'Región de la tienda'; - - @override - String get recommendation_country => 'País de recomendación'; - - @override - String get appearance => 'Apariencia'; - - @override - String get layout_mode => 'Modo de diseño'; - - @override - String get override_layout_settings => - 'Anular la configuración del modo de diseño responsive'; - - @override - String get adaptive => 'Adaptable'; - - @override - String get compact => 'Compacto'; - - @override - String get extended => 'Extendido'; - - @override - String get theme => 'Tema'; - - @override - String get dark => 'Oscuro'; - - @override - String get light => 'Claro'; - - @override - String get system => 'Sistema'; - - @override - String get accent_color => 'Color de acento'; - - @override - String get sync_album_color => 'Sincronizar color del álbum'; - - @override - String get sync_album_color_description => - 'Usa el color dominante del arte del álbum como color de acento'; - - @override - String get playback => 'Reproducción'; - - @override - String get audio_quality => 'Calidad de audio'; - - @override - String get high => 'Alta'; - - @override - String get low => 'Baja'; - - @override - String get pre_download_play => 'Pre-descargar y reproducir'; - - @override - String get pre_download_play_description => - 'En lugar de transmitir audio, descarga bytes y reproduce en su lugar (recomendado para usuarios con mayor ancho de banda)'; - - @override - String get skip_non_music => - 'Omitir segmentos que no son música (SponsorBlock)'; - - @override - String get blacklist_description => 'Canciones y artistas en la lista negra'; - - @override - String get wait_for_download_to_finish => - 'Por favor, espera a que termine la descarga actual'; - - @override - String get desktop => 'Escritorio'; - - @override - String get close_behavior => 'Comportamiento al cerrar'; - - @override - String get close => 'Cerrar'; - - @override - String get minimize_to_tray => 'Minimizar en la bandeja del sistema'; - - @override - String get show_tray_icon => 'Mostrar icono en la bandeja del sistema'; - - @override - String get about => 'Acerca de'; - - @override - String get u_love_spotube => 'Sabemos que te encanta Spotube'; - - @override - String get check_for_updates => 'Buscar actualizaciones'; - - @override - String get about_spotube => 'Acerca de Spotube'; - - @override - String get blacklist => 'Lista negra'; - - @override - String get please_sponsor => 'Por favor, apoya/dona'; - - @override - String get spotube_description => - 'Spotube, un cliente ligero, multiplataforma y gratuito de Spotify'; - - @override - String get version => 'Versión'; - - @override - String get build_number => 'Número de compilación'; - - @override - String get founder => 'Fundador'; - - @override - String get repository => 'Repositorio'; - - @override - String get bug_issues => 'Errores y problemas'; - - @override - String get made_with => 'Hecho con ❤️ en Bangladesh🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Licencia'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'No te preocupes, tus credenciales no serán recopiladas ni compartidas con nadie'; - - @override - String get know_how_to_login => '¿No sabes cómo hacerlo?'; - - @override - String get follow_step_by_step_guide => 'Sigue la guía paso a paso'; - - @override - String cookie_name_cookie(Object name) { - return 'Cookie $name'; - } - - @override - String get fill_in_all_fields => 'Por favor, completa todos los campos'; - - @override - String get submit => 'Enviar'; - - @override - String get exit => 'Salir'; - - @override - String get previous => 'Anterior'; - - @override - String get next => 'Siguiente'; - - @override - String get done => 'Listo'; - - @override - String get step_1 => 'Paso 1'; - - @override - String get first_go_to => 'Primero, ve a'; - - @override - String get something_went_wrong => 'Algo salió mal'; - - @override - String get piped_instance => 'Instancia del servidor Piped'; - - @override - String get piped_description => - 'La instancia del servidor Piped a utilizar para la coincidencia de pistas'; - - @override - String get piped_warning => - 'Algunas pueden no funcionar bien, úsalas bajo tu propio riesgo'; - - @override - String get invidious_instance => 'Instancia del Servidor Invidious'; - - @override - String get invidious_description => - 'La instancia del servidor Invidious para identificar pistas'; - - @override - String get invidious_warning => - 'Algunas instancias podrían no funcionar bien. Úselas bajo su propio riesgo'; - - @override - String get generate => 'Generar'; - - @override - String track_exists(Object track) { - return 'La canción $track ya existe'; - } - - @override - String get replace_downloaded_tracks => - 'Reemplazar todas las canciones descargadas'; - - @override - String get skip_download_tracks => - 'Omitir la descarga de todas las canciones descargadas'; - - @override - String get do_you_want_to_replace => - '¿Deseas reemplazar la canción existente?'; - - @override - String get replace => 'Reemplazar'; - - @override - String get skip => 'Omitir'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Seleccionar hasta $count $type'; - } - - @override - String get select_genres => 'Seleccionar Géneros'; - - @override - String get add_genres => 'Agregar Géneros'; - - @override - String get country => 'País'; - - @override - String get number_of_tracks_generate => 'Número de canciones a generar'; - - @override - String get acousticness => 'Acousticness'; - - @override - String get danceability => 'Danceability'; - - @override - String get energy => 'Energía'; - - @override - String get instrumentalness => 'Instrumentalidad'; - - @override - String get liveness => 'En vivo'; - - @override - String get loudness => 'Volumen'; - - @override - String get speechiness => 'Habla'; - - @override - String get valence => 'Valencia'; - - @override - String get popularity => 'Popularidad'; - - @override - String get key => 'Tono'; - - @override - String get duration => 'Duración (s)'; - - @override - String get tempo => 'Tempo (BPM)'; - - @override - String get mode => 'Modo'; - - @override - String get time_signature => 'Compás'; - - @override - String get short => 'Corto'; - - @override - String get medium => 'Medio'; - - @override - String get long => 'Largo'; - - @override - String get min => 'Mín.'; - - @override - String get max => 'Máx.'; - - @override - String get target => 'Objetivo'; - - @override - String get moderate => 'Moderado'; - - @override - String get deselect_all => 'Deseleccionar todo'; - - @override - String get select_all => 'Seleccionar todo'; - - @override - String get are_you_sure => '¿Estás seguro?'; - - @override - String get generating_playlist => - 'Generando tu lista de reproducción personalizada...'; - - @override - String selected_count_tracks(Object count) { - return 'Seleccionadas $count canciones'; - } - - @override - String get download_warning => - 'Si descargas todas las canciones de golpe, estás claramente pirateando música y causando daño a la sociedad creativa de la música. Espero que seas consciente de esto y siempre intentes respetar y apoyar el arduo trabajo de los artistas'; - - @override - String get download_ip_ban_warning => - 'Por cierto, tu IP puede ser bloqueada en YouTube debido a solicitudes de descarga excesivas. El bloqueo de IP significa que no podrás usar YouTube (incluso si has iniciado sesión) durante al menos 2-3 meses desde esa dirección IP. Y Spotube no se hace responsable si esto ocurre alguna vez'; - - @override - String get by_clicking_accept_terms => - 'Al hacer clic en \'Aceptar\', aceptas los siguientes términos:'; - - @override - String get download_agreement_1 => 'Sé que estoy pirateando música. Soy malo'; - - @override - String get download_agreement_2 => - 'Apoyaré al artista donde pueda y solo lo hago porque no tengo dinero para comprar su arte'; - - @override - String get download_agreement_3 => - 'Soy completamente consciente de que mi IP puede ser bloqueada en YouTube y no responsabilizo a Spotube ni a sus dueños/contribuyentes por cualquier incidente causado por mi acción actual'; - - @override - String get decline => 'Rechazar'; - - @override - String get accept => 'Aceptar'; - - @override - String get details => 'Detalles'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Canal'; - - @override - String get likes => 'Me gusta'; - - @override - String get dislikes => 'No me gusta'; - - @override - String get views => 'Vistas'; - - @override - String get streamUrl => 'URL del streaming'; - - @override - String get stop => 'Detener'; - - @override - String get sort_newest => 'Ordenar por más recientes'; - - @override - String get sort_oldest => 'Ordenar por más antiguos'; - - @override - String get sleep_timer => 'Temporizador de apagado'; - - @override - String mins(Object minutes) { - return '$minutes minutos'; - } - - @override - String hours(Object hours) { - return '$hours horas'; - } - - @override - String hour(Object hours) { - return '$hours hora'; - } - - @override - String get custom_hours => 'Horas personalizadas'; - - @override - String get logs => 'Registros'; - - @override - String get developers => 'Desarrolladores'; - - @override - String get not_logged_in => 'No has iniciado sesión'; - - @override - String get search_mode => 'Modo de búsqueda'; - - @override - String get audio_source => 'Fuente de audio'; - - @override - String get ok => 'OK'; - - @override - String get failed_to_encrypt => 'Error al cifrar'; - - @override - String get encryption_failed_warning => - 'Spotube utiliza el cifrado para almacenar sus datos de forma segura. Pero ha fallado. Por lo tanto, volverá a un almacenamiento no seguro\nSi está utilizando Linux, asegúrese de tener instalados servicios secretos como gnome-keyring, kde-wallet y keepassxc'; - - @override - String get querying_info => 'Consultando información...'; - - @override - String get piped_api_down => 'La API de Piped no está disponible'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'La instancia de Piped $pipedInstance no está funcionando en este momento\n\nCambie la instancia o cambie el \'Tipo de API\' a la API oficial de YouTube\n\nAsegúrese de reiniciar la aplicación después del cambio'; - } - - @override - String get you_are_offline => 'Actualmente estás sin conexión'; - - @override - String get connection_restored => 'Se ha restablecido tu conexión a internet'; - - @override - String get use_system_title_bar => 'Usar la barra de título del sistema'; - - @override - String get crunching_results => 'Procesando resultados...'; - - @override - String get search_to_get_results => 'Buscar para obtener resultados'; - - @override - String get use_amoled_mode => 'Usar modo AMOLED'; - - @override - String get pitch_dark_theme => 'Tema oscuro de dart'; - - @override - String get normalize_audio => 'Normalizar audio'; - - @override - String get change_cover => 'Cambiar portada'; - - @override - String get add_cover => 'Agregar portada'; - - @override - String get restore_defaults => 'Restaurar valores predeterminados'; - - @override - String get download_music_format => 'Formato de descarga de música'; - - @override - String get streaming_music_format => 'Formato de transmisión de música'; - - @override - String get download_music_quality => 'Calidad de descarga de música'; - - @override - String get streaming_music_quality => 'Calidad de transmisión de música'; - - @override - String get login_with_lastfm => 'Iniciar sesión con Last.fm'; - - @override - String get connect => 'Conectar'; - - @override - String get disconnect_lastfm => 'Desconectar de Last.fm'; - - @override - String get disconnect => 'Desconectar'; - - @override - String get username => 'Nombre de usuario'; - - @override - String get password => 'Contraseña'; - - @override - String get login => 'Iniciar sesión'; - - @override - String get login_with_your_lastfm => - 'Iniciar sesión con tu cuenta de Last.fm'; - - @override - String get scrobble_to_lastfm => 'Scrobble a Last.fm'; - - @override - String get go_to_album => 'Ir al álbum'; - - @override - String get discord_rich_presence => 'Presencia rica en Discord'; - - @override - String get browse_all => 'Explorar todo'; - - @override - String get genres => 'Géneros'; - - @override - String get explore_genres => 'Explorar géneros'; - - @override - String get friends => 'Amigos'; - - @override - String get no_lyrics_available => - 'Lo siento, no se pueden encontrar las letras de esta pista'; - - @override - String get start_a_radio => 'Iniciar una Radio'; - - @override - String get how_to_start_radio => '¿Cómo quieres iniciar la radio?'; - - @override - String get replace_queue_question => - '¿Quieres reemplazar la lista de reproducción actual o añadir a ella?'; - - @override - String get endless_playback => 'Reproducción Infinita'; - - @override - String get delete_playlist => 'Eliminar Lista de Reproducción'; - - @override - String get delete_playlist_confirmation => - '¿Estás seguro de que quieres eliminar esta lista de reproducción?'; - - @override - String get local_tracks => 'Pistas Locales'; - - @override - String get local_tab => 'Local'; - - @override - String get song_link => 'Enlace de la Canción'; - - @override - String get skip_this_nonsense => 'Saltar esta tontería'; - - @override - String get freedom_of_music => '“Libertad de la Música”'; - - @override - String get freedom_of_music_palm => - '“Libertad de la Música en la palma de tu mano”'; - - @override - String get get_started => 'Empecemos'; - - @override - String get youtube_source_description => 'Recomendado y funciona mejor.'; - - @override - String get piped_source_description => - '¿Te sientes libre? Igual que YouTube pero más libre.'; - - @override - String get jiosaavn_source_description => - 'Lo mejor para la región del sur de Asia.'; - - @override - String get invidious_source_description => - 'Similar a Piped, pero con mayor disponibilidad'; - - @override - String highest_quality(Object quality) { - return 'Mayor Calidad: $quality'; - } - - @override - String get select_audio_source => 'Seleccionar Fuente de Audio'; - - @override - String get endless_playback_description => - 'Añadir automáticamente nuevas canciones\nal final de la cola de reproducción'; - - @override - String get choose_your_region => 'Elige tu región'; - - @override - String get choose_your_region_description => - 'Esto ayudará a Spotube a mostrarte el contenido adecuado\npara tu ubicación.'; - - @override - String get choose_your_language => 'Elige tu idioma'; - - @override - String get help_project_grow => 'Ayuda a que este proyecto crezca'; - - @override - String get help_project_grow_description => - 'Spotube es un proyecto de código abierto. Puedes ayudar a que este proyecto crezca contribuyendo al proyecto, informando errores o sugiriendo nuevas funciones.'; - - @override - String get contribute_on_github => 'Contribuir en GitHub'; - - @override - String get donate_on_open_collective => 'Donar en Open Collective'; - - @override - String get browse_anonymously => 'Navegar Anónimamente'; - - @override - String get enable_connect => 'Habilitar conexión'; - - @override - String get enable_connect_description => - 'Controla Spotube desde otros dispositivos'; - - @override - String get devices => 'Dispositivos'; - - @override - String get select => 'Seleccionar'; - - @override - String connect_client_alert(Object client) { - return 'Estás siendo controlado por $client'; - } - - @override - String get this_device => 'Este dispositivo'; - - @override - String get remote => 'Remoto'; - - @override - String get stats => 'Estadísticas'; - - @override - String and_n_more(Object count) { - return 'y $count más'; - } - - @override - String get recently_played => 'Recién reproducido'; - - @override - String get browse_more => 'Explorar más'; - - @override - String get no_title => 'Sin título'; - - @override - String get not_playing => 'No reproduciendo'; - - @override - String get epic_failure => '¡Fallo épico!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'Se añadieron $tracks_length canciones a la cola'; - } - - @override - String get spotube_has_an_update => 'Spotube tiene una actualización'; - - @override - String get download_now => 'Descargar ahora'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum ha sido lanzado'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version ha sido lanzado'; - } - - @override - String get read_the_latest => 'Lee las últimas '; - - @override - String get release_notes => 'notas de la versión'; - - @override - String get pick_color_scheme => 'Elige esquema de color'; - - @override - String get save => 'Guardar'; - - @override - String get choose_the_device => 'Elige el dispositivo:'; - - @override - String get multiple_device_connected => - 'Hay múltiples dispositivos conectados.\nElige el dispositivo en el que deseas realizar esta acción'; - - @override - String get nothing_found => 'Nada encontrado'; - - @override - String get the_box_is_empty => 'La caja está vacía'; - - @override - String get top_artists => 'Artistas principales'; - - @override - String get top_albums => 'Álbumes principales'; - - @override - String get this_week => 'Esta semana'; - - @override - String get this_month => 'Este mes'; - - @override - String get last_6_months => 'Últimos 6 meses'; - - @override - String get this_year => 'Este año'; - - @override - String get last_2_years => 'Últimos 2 años'; - - @override - String get all_time => 'Todos los tiempos'; - - @override - String powered_by_provider(Object providerName) { - return 'Impulsado por $providerName'; - } - - @override - String get email => 'Correo electrónico'; - - @override - String get profile_followers => 'Seguidores'; - - @override - String get birthday => 'Cumpleaños'; - - @override - String get subscription => 'Suscripción'; - - @override - String get not_born => 'No nacido'; - - @override - String get hacker => 'Hacker'; - - @override - String get profile => 'Perfil'; - - @override - String get no_name => 'Sin nombre'; - - @override - String get edit => 'Editar'; - - @override - String get user_profile => 'Perfil de usuario'; - - @override - String count_plays(Object count) { - return '$count reproducciones'; - } - - @override - String get streaming_fees_hypothetical => - 'Tarifas de streaming (hipotéticas)'; - - @override - String get minutes_listened => 'Minutos escuchados'; - - @override - String get streamed_songs => 'Canciones reproducidas'; - - @override - String count_streams(Object count) { - return '$count streams'; - } - - @override - String get owned_by_you => 'En tu posesión'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return 'Copiado $shareUrl al portapapeles'; - } - - @override - String get hipotetical_calculation => - '*Este cálculo se basa en el pago promedio por reproducción en plataformas de música en línea (de 0,003 a 0,005 USD). Es hipotético y sirve para dar al usuario una idea de cuánto habría pagado a los artistas si hubiera escuchado su canción en distintas plataformas.'; - - @override - String count_mins(Object minutes) { - return '$minutes minutos'; - } - - @override - String get summary_minutes => 'minutos'; - - @override - String get summary_listened_to_music => 'Escuchó música'; - - @override - String get summary_songs => 'canciones'; - - @override - String get summary_streamed_overall => 'Transmitido en general'; - - @override - String get summary_owed_to_artists => 'Debido a los artistas\nEste mes'; - - @override - String get summary_artists => 'artistas'; - - @override - String get summary_music_reached_you => 'La música te alcanzó'; - - @override - String get summary_full_albums => 'álbumes completos'; - - @override - String get summary_got_your_love => 'Obtuvo tu amor'; - - @override - String get summary_playlists => 'listas de reproducción'; - - @override - String get summary_were_on_repeat => 'Estaban en repetición'; - - @override - String total_money(Object money) { - return 'Total $money'; - } - - @override - String get webview_not_found => 'No se encontró el Webview'; - - @override - String get webview_not_found_description => - 'No hay tiempo de ejecución de Webview instalado en su dispositivo.\nSi está instalado, asegúrese de que esté en el environment PATH\n\nDespués de instalar, reinicie la aplicación'; - - @override - String get unsupported_platform => 'Plataforma no soportada'; - - @override - String get cache_music => 'Caché de música'; - - @override - String get open => 'Abrir'; - - @override - String get cache_folder => 'Carpeta de caché'; - - @override - String get export => 'Exportar'; - - @override - String get clear_cache => 'Limpiar caché'; - - @override - String get clear_cache_confirmation => '¿Desea limpiar la caché?'; - - @override - String get export_cache_files => 'Exportar archivos en caché'; - - @override - String found_n_files(Object count) { - return 'Se encontraron $count archivos'; - } - - @override - String get export_cache_confirmation => '¿Desea exportar estos archivos a'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return 'Se exportaron $filesExported de $files archivos'; - } - - @override - String get undo => 'Deshacer'; - - @override - String get download_all => 'Descargar todo'; - - @override - String get add_all_to_playlist => 'Agregar todo a la lista de reproducción'; - - @override - String get add_all_to_queue => 'Agregar todo a la cola'; - - @override - String get play_all_next => 'Reproducir todo a continuación'; - - @override - String get pause => 'Pausa'; - - @override - String get view_all => 'Ver todo'; - - @override - String get no_tracks_added_yet => - 'Parece que aún no has agregado ninguna canción.'; - - @override - String get no_tracks => 'Parece que no hay canciones aquí.'; - - @override - String get no_tracks_listened_yet => - 'Parece que no has escuchado nada todavía.'; - - @override - String get not_following_artists => 'No sigues a ningún artista.'; - - @override - String get no_favorite_albums_yet => - 'Parece que aún no has agregado ningún álbum a tus favoritos.'; - - @override - String get no_logs_found => 'No se encontraron registros'; - - @override - String get youtube_engine => 'Motor de YouTube'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine no está instalado'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine no está instalado en tu sistema.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Asegúrate de que esté disponible en la variable PATH o\nestablece la ruta absoluta del ejecutable de $engine a continuación.'; - } - - @override - String get youtube_engine_unix_issue_message => - 'En macOS/Linux/sistemas operativos similares a Unix, establecer la ruta en .zshrc/.bashrc/.bash_profile etc. no funcionará.\nNecesitas establecer la ruta en el archivo de configuración del shell.'; - - @override - String get download => 'Descargar'; - - @override - String get file_not_found => 'Archivo no encontrado'; - - @override - String get custom => 'Personalizado'; - - @override - String get add_custom_url => 'Agregar URL personalizada'; - - @override - String get edit_port => 'Editar puerto'; - - @override - String get port_helper_msg => - 'El valor predeterminado es -1, lo que indica un número aleatorio. Si tienes un firewall configurado, se recomienda establecer esto.'; - - @override - String connect_request(Object client) { - return '¿Permitir que $client se conecte?'; - } - - @override - String get connection_request_denied => - 'Conexión denegada. El usuario denegó el acceso.'; - - @override - String get an_error_occurred => 'Ocurrió un error'; - - @override - String get copy_to_clipboard => 'Copiar al portapapeles'; - - @override - String get view_logs => 'Ver registros'; - - @override - String get retry => 'Reintentar'; - - @override - String get no_default_metadata_provider_selected => - 'No has configurado un proveedor de metadatos predeterminado'; - - @override - String get manage_metadata_providers => 'Gestionar proveedores de metadatos'; - - @override - String get open_link_in_browser => '¿Abrir enlace en el navegador?'; - - @override - String get do_you_want_to_open_the_following_link => - '¿Quieres abrir el siguiente enlace?'; - - @override - String get unsafe_url_warning => - 'Abrir enlaces de fuentes no confiables puede ser inseguro. ¡Ten cuidado!\nTambién puedes copiar el enlace al portapapeles.'; - - @override - String get copy_link => 'Copiar enlace'; - - @override - String get building_your_timeline => - 'Construyendo tu línea de tiempo según tus escuchas…'; - - @override - String get official => 'Oficial'; - - @override - String author_name(Object author) { - return 'Autor: $author'; - } - - @override - String get third_party => 'Terceros'; - - @override - String get plugin_requires_authentication => - 'El complemento requiere autenticación'; - - @override - String get update_available => 'Actualización disponible'; - - @override - String get supports_scrobbling => 'Admite scrobbling'; - - @override - String get plugin_scrobbling_info => - 'Este complemento scrobblea tu música para generar tu historial de reproducción.'; - - @override - String get default_metadata_source => 'Fuente de metadatos predeterminada'; - - @override - String get set_default_metadata_source => - 'Establecer fuente de metadatos predeterminada'; - - @override - String get default_audio_source => 'Fuente de audio predeterminada'; - - @override - String get set_default_audio_source => - 'Establecer fuente de audio predeterminada'; - - @override - String get set_default => 'Establecer como predeterminado'; - - @override - String get support => 'Soporte'; - - @override - String get support_plugin_development => - 'Apoyar el desarrollo del complemento'; - - @override - String can_access_name_api(Object name) { - return '- Puede acceder a la API de **$name**'; - } - - @override - String get do_you_want_to_install_this_plugin => - '¿Deseas instalar este complemento?'; - - @override - String get third_party_plugin_warning => - 'Este complemento proviene de un repositorio de terceros. Asegúrate de confiar en la fuente antes de instalarlo.'; - - @override - String get author => 'Autor'; - - @override - String get this_plugin_can_do_following => - 'Este complemento puede hacer lo siguiente'; - - @override - String get install => 'Instalar'; - - @override - String get install_a_metadata_provider => - 'Instalar un proveedor de metadatos'; - - @override - String get no_tracks_playing => - 'No hay ninguna pista reproduciéndose actualmente'; - - @override - String get synced_lyrics_not_available => - 'Las letras sincronizadas no están disponibles para esta canción. Por favor, utiliza'; - - @override - String get plain_lyrics => 'Letras sin formato'; - - @override - String get tab_instead => 'en su lugar, usa la tecla Tab.'; - - @override - String get disclaimer => 'Descargo de responsabilidad'; - - @override - String get third_party_plugin_dmca_notice => - 'El equipo de Spotube no asume ninguna responsabilidad (incluida la legal) por complementos de \"terceros\". Úsalos bajo tu propio riesgo. Para errores o problemas, repórtalos en el repositorio del complemento.\n\nSi algún complemento de “terceros” infringe los ToS/DMCA de algún servicio o entidad legal, por favor, solicita al autor del complemento o a la plataforma de alojamiento (p. ej., GitHub/Codeberg) que tome medidas. Los complementos etiquetados como “de terceros” son mantenidos públicamente por la comunidad; no los gestionamos y no podemos intervenir.\n\n'; - - @override - String get input_does_not_match_format => - 'La entrada no coincide con el formato requerido'; - - @override - String get plugins => 'Plugins'; - - @override - String get paste_plugin_download_url => - 'Pega la URL de descarga, el repositorio de GitHub/Codeberg o el enlace directo al archivo .smplug'; - - @override - String get download_and_install_plugin_from_url => - 'Descargar e instalar el complemento desde una URL'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Error al añadir el complemento: $error'; - } - - @override - String get upload_plugin_from_file => 'Subir complemento desde archivo'; - - @override - String get installed => 'Instalado'; - - @override - String get available_plugins => 'Complementos disponibles'; - - @override - String get configure_plugins => - 'Configura tus propios plugins de proveedor de metadatos y fuente de audio'; - - @override - String get audio_scrobblers => 'Scrobblers de audio'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Fuente: '; - - @override - String get uncompressed => 'Sin comprimir'; - - @override - String get dab_music_source_description => - 'Para audiófilos. Proporciona transmisiones de audio de alta calidad/sin pérdida. Coincidencia precisa de pistas basada en ISRC.'; -} diff --git a/lib/l10n/generated/app_localizations_eu.dart b/lib/l10n/generated/app_localizations_eu.dart deleted file mode 100644 index 5f80397e..00000000 --- a/lib/l10n/generated/app_localizations_eu.dart +++ /dev/null @@ -1,1577 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Basque (`eu`). -class AppLocalizationsEu extends AppLocalizations { - AppLocalizationsEu([String locale = 'eu']) : super(locale); - - @override - String get guest => 'Gonbidatua'; - - @override - String get browse => 'Arakatu'; - - @override - String get search => 'Bilatu'; - - @override - String get library => 'Liburutegia'; - - @override - String get lyrics => 'Hitzak'; - - @override - String get settings => 'Ezarpenak'; - - @override - String get genre_categories_filter => 'Kategoria edo generoak filtratu...'; - - @override - String get genre => 'Generoa'; - - @override - String get personalized => 'Pertsonalizatua'; - - @override - String get featured => 'Nabarmenduak'; - - @override - String get new_releases => 'Argitaratze berriak'; - - @override - String get songs => 'Abestiak'; - - @override - String playing_track(Object track) { - return '$track erreproduzitzen'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Uneko zerrenda ezabatuko da. $track_length abesti ezabatuko dira.\nJarraitu nahi duzu?'; - } - - @override - String get load_more => 'Gehiago kargatu'; - - @override - String get playlists => 'Zerrendak'; - - @override - String get artists => 'Artistak'; - - @override - String get albums => 'Albumak'; - - @override - String get tracks => 'Kantak'; - - @override - String get downloads => 'Deskargak'; - - @override - String get filter_playlists => 'Zure zerrendak filtratu...'; - - @override - String get liked_tracks => 'Gustuko Kantak'; - - @override - String get liked_tracks_description => 'Zure gustuko kanta guztiak'; - - @override - String get playlist => 'Playlist'; - - @override - String get create_a_playlist => 'Sortu zerrenda bat'; - - @override - String get update_playlist => 'Eguneratu zerrenda'; - - @override - String get create => 'Sortu'; - - @override - String get cancel => 'Ezeztatu'; - - @override - String get update => 'Eguneratu'; - - @override - String get playlist_name => 'Zerrenda Izena'; - - @override - String get name_of_playlist => 'Zerrendaren izena'; - - @override - String get description => 'Deskribapena'; - - @override - String get public => 'Publikoa'; - - @override - String get collaborative => 'Kolaboratiboa'; - - @override - String get search_local_tracks => 'Bilatu kanta lokalak...'; - - @override - String get play => 'Erreproduzitu'; - - @override - String get delete => 'Ezabatu'; - - @override - String get none => 'Batere ez'; - - @override - String get sort_a_z => 'Ordenatu A-Z'; - - @override - String get sort_z_a => 'Ordenatu Z-A'; - - @override - String get sort_artist => 'Ordenatu Artistaren arabera'; - - @override - String get sort_album => 'Ordenatu Albumaren arabera'; - - @override - String get sort_duration => 'Ordenar Iraupenaren arabera'; - - @override - String get sort_tracks => 'Ordenatu Kantak'; - - @override - String currently_downloading(Object tracks_length) { - return 'Oraintxe ($tracks_length) deskargatzen'; - } - - @override - String get cancel_all => 'Ezeztatu dena'; - - @override - String get filter_artist => 'Filtratu artistak...'; - - @override - String followers(Object followers) { - return '$followers Jarraitzaile'; - } - - @override - String get add_artist_to_blacklist => 'Gehitu artista zerrenda beltzera'; - - @override - String get top_tracks => 'Top Kantak'; - - @override - String get fans_also_like => 'Fan-ek hau ere gustuko dute'; - - @override - String get loading => 'Kargatzen...'; - - @override - String get artist => 'Artista'; - - @override - String get blacklisted => 'Zerrenda beltzean'; - - @override - String get following => 'Jarraitzen'; - - @override - String get follow => 'Jarraitu'; - - @override - String get artist_url_copied => 'Artistaren URL-a arbelera kopiatua'; - - @override - String added_to_queue(Object tracks) { - return '$tracks kanta zerrendara gehituak'; - } - - @override - String get filter_albums => 'Albumak filtratu...'; - - @override - String get synced => 'Sinkronizatuta'; - - @override - String get plain => 'Arrunta'; - - @override - String get shuffle => 'Ausaz'; - - @override - String get search_tracks => 'Bilatu kantak...'; - - @override - String get released => 'Argitaratua'; - - @override - String error(Object error) { - return 'Errorea: $error'; - } - - @override - String get title => 'Izenburua'; - - @override - String get time => 'Iraupena'; - - @override - String get more_actions => 'Ekintza gehiago'; - - @override - String download_count(Object count) { - return '($count) deskarga'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Gehitu ($count) zerrendara'; - } - - @override - String add_count_to_queue(Object count) { - return 'Gehitu ($count) ilarara'; - } - - @override - String play_count_next(Object count) { - return 'Erreproduzitu hurrengo ($count)-ak'; - } - - @override - String get album => 'Albuma'; - - @override - String copied_to_clipboard(Object data) { - return '$data arbelean kopiatua'; - } - - @override - String add_to_following_playlists(Object track) { - return 'Gehitu $track hurrengo erreprodukzio-zerrendetara'; - } - - @override - String get add => 'Gehitu'; - - @override - String added_track_to_queue(Object track) { - return '$track zerrendan gehitua'; - } - - @override - String get add_to_queue => 'Gehitu zerrendan'; - - @override - String track_will_play_next(Object track) { - return '$track erreproduzituko da ondoren'; - } - - @override - String get play_next => 'Hurrengo erreprodukzioa'; - - @override - String removed_track_from_queue(Object track) { - return '$track zerrendatik ezabatua'; - } - - @override - String get remove_from_queue => 'Ezabatu ilaratik'; - - @override - String get remove_from_favorites => 'Ezabatu gogokoetatik'; - - @override - String get save_as_favorite => 'Gorde gogokoetan'; - - @override - String get add_to_playlist => 'Gehitu zerrendara'; - - @override - String get remove_from_playlist => 'Ezabatu zerrendatik'; - - @override - String get add_to_blacklist => 'Gehitu zerrenda beltzera'; - - @override - String get remove_from_blacklist => 'Ezabatu zerrenda beltzetik'; - - @override - String get share => 'Elkarbanatu'; - - @override - String get mini_player => 'Mini Erreproduzitzailea'; - - @override - String get slide_to_seek => 'Arrastatu aurrerantz edo atzearantz bilatzeko'; - - @override - String get shuffle_playlist => 'Erreproduzitu zerrenda ausazko ordenean'; - - @override - String get unshuffle_playlist => 'Desgaitu ausazko erreprodukzioa'; - - @override - String get previous_track => 'Aurreko pista'; - - @override - String get next_track => 'Hurrengo pista'; - - @override - String get pause_playback => 'Pausatu erreprodukzioa'; - - @override - String get resume_playback => 'Berrabiarazi erreprodukzioa'; - - @override - String get loop_track => 'Kanta begiztan'; - - @override - String get no_loop => 'Ez dago loop-ik'; - - @override - String get repeat_playlist => 'Errepikatu lista'; - - @override - String get queue => 'Ilara'; - - @override - String get alternative_track_sources => 'Kanten iturri alternatiboak'; - - @override - String get download_track => 'Deskargatu kanta'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks kanta zerrendan'; - } - - @override - String get clear_all => 'Garbitu dena'; - - @override - String get show_hide_ui_on_hover => - 'Erakutsi/Ezkutatu interfazea kurtsorea pasatzean'; - - @override - String get always_on_top => 'Beti ikusgai'; - - @override - String get exit_mini_player => 'Irten mini erreproduzitzailetik'; - - @override - String get download_location => 'Deskargen kokapena'; - - @override - String get local_library => 'Liburutegi lokala'; - - @override - String get add_library_location => 'Gehitu liburutegira'; - - @override - String get remove_library_location => 'Kendu liburutegitik'; - - @override - String get account => 'Kontua'; - - @override - String get logout => 'Itxi saioa'; - - @override - String get logout_of_this_account => 'Itxi kontu honen saioa'; - - @override - String get language_region => 'Hizkuntza eta Herrialdea'; - - @override - String get language => 'Hizkuntza'; - - @override - String get system_default => 'Sisteman lehenetsia'; - - @override - String get market_place_region => 'Dendaren herrialdea'; - - @override - String get recommendation_country => 'Gomendio herrialdea'; - - @override - String get appearance => 'Itxura'; - - @override - String get layout_mode => 'Diseinua'; - - @override - String get override_layout_settings => - 'Responsive diseinuaren ezarpenak ezeztatu'; - - @override - String get adaptive => 'Moldagarria'; - - @override - String get compact => 'Trinkoa'; - - @override - String get extended => 'Hedatua'; - - @override - String get theme => 'Gaia'; - - @override - String get dark => 'Iluna'; - - @override - String get light => 'Argia'; - - @override - String get system => 'Sistema'; - - @override - String get accent_color => 'Azentu kolorea'; - - @override - String get sync_album_color => 'Sinkronizatu albumaren kolorea'; - - @override - String get sync_album_color_description => - 'Albumaren artearen kolore nagusia erabili azentu kolore bezala'; - - @override - String get playback => 'Erreprodukzioa'; - - @override - String get audio_quality => 'Audioaren kalitatea'; - - @override - String get high => 'Altua'; - - @override - String get low => 'Baxua'; - - @override - String get pre_download_play => 'Aurre-deskargatu eta erreproduzitu'; - - @override - String get pre_download_play_description => - 'Streaming egin beharrean, byte-ak deskargatu eta erreproduzitu (banda-zabalera handia duten erabiltzaileentzat gomendagarria)'; - - @override - String get skip_non_music => - 'Musika ez diren segmentuak baztertu (SponsorBlock)'; - - @override - String get blacklist_description => 'Zerrenda beltzeko abesti eta artistak'; - - @override - String get wait_for_download_to_finish => - 'Mesedez, itxaron uneko deskarga bukatu arte'; - - @override - String get desktop => 'Mahaigaina'; - - @override - String get close_behavior => 'Ixterako Portaera'; - - @override - String get close => 'Itxi'; - - @override - String get minimize_to_tray => 'Sistemako erretilura minimizatu'; - - @override - String get show_tray_icon => 'Erakutsi ikonoa sistemaren erretiluan'; - - @override - String get about => 'Honi buruz'; - - @override - String get u_love_spotube => 'Badakigu Spotube maite duzula'; - - @override - String get check_for_updates => 'Bilatu eguneraketak'; - - @override - String get about_spotube => 'Spotube-ri buruz'; - - @override - String get blacklist => 'Zerrenda beltza'; - - @override - String get please_sponsor => 'Mesedez, babestu/diruz lagundu'; - - @override - String get spotube_description => - 'Spotube, arina, plataforma-anitza eta doakoa den Spotify-ren bezeroa'; - - @override - String get version => 'Bertsioa'; - - @override - String get build_number => 'Konpilazio zenbakia'; - - @override - String get founder => 'Sortzailea'; - - @override - String get repository => 'Errepositorioa'; - - @override - String get bug_issues => 'Erroreak eta arazoak'; - - @override - String get made_with => 'Bangladesh🇧🇩-en ❤️-z egina'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Lizentzia'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Ez arduratu, zure kredentzialak ez ditugu bilduko edo inorekin elkarbanatuko'; - - @override - String get know_how_to_login => 'Ez dakizu nola egin?'; - - @override - String get follow_step_by_step_guide => 'Jarraitu pausoz-pausoko gida'; - - @override - String cookie_name_cookie(Object name) { - return '$name cookiea'; - } - - @override - String get fill_in_all_fields => 'Mesedez, osatu eremu guztiak'; - - @override - String get submit => 'Bidali'; - - @override - String get exit => 'Irten'; - - @override - String get previous => 'Aurrekoa'; - - @override - String get next => 'Hurrengoa'; - - @override - String get done => 'Eginda'; - - @override - String get step_1 => '1. pausua'; - - @override - String get first_go_to => 'Hasteko, joan hona'; - - @override - String get something_went_wrong => 'Zerbaitek huts egin du'; - - @override - String get piped_instance => 'Piped zerbitzariaren instantzia'; - - @override - String get piped_description => - 'Kanten koizidentzietan erabiltzeko Piped zerbitzariaren instantzia'; - - @override - String get piped_warning => - 'Batzuk agian ez dute ongi funtzionatuko, zure ardurapean erabili'; - - @override - String get invidious_instance => 'Invidious zerbitzari instantzia'; - - @override - String get invidious_description => - 'Invidious zerbitzari instantzia, pistak bat egiteko'; - - @override - String get invidious_warning => - 'Instantzia batzuek ez dute ondo funtzionatuko. Zure erantzukizunpean erabili'; - - @override - String get generate => 'Sortu'; - - @override - String track_exists(Object track) { - return '$track kanta dagoeneko badago'; - } - - @override - String get replace_downloaded_tracks => - 'Ordezkatu deskargatutako kanta guztiak'; - - @override - String get skip_download_tracks => - 'Deskargatutako kanta guztien deskarga baztertu'; - - @override - String get do_you_want_to_replace => 'Dagoen kanta ordezkatu nahi duzu??'; - - @override - String get replace => 'Ordezkatu'; - - @override - String get skip => 'Baztertu'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Aukertu $count $type'; - } - - @override - String get select_genres => 'Aukeratu Generoak'; - - @override - String get add_genres => 'Gehitu Generoak'; - - @override - String get country => 'Herrialdea'; - - @override - String get number_of_tracks_generate => 'Sortzeko kanta kopurua'; - - @override - String get acousticness => 'Akustikotasuna'; - - @override - String get danceability => 'Dantzagarritasuna'; - - @override - String get energy => 'Energia'; - - @override - String get instrumentalness => 'Instrumentaltasuna'; - - @override - String get liveness => 'Zuzenean'; - - @override - String get loudness => 'Ozentasuna'; - - @override - String get speechiness => 'Hitzaldia'; - - @override - String get valence => 'Balentzia'; - - @override - String get popularity => 'Populartasuna'; - - @override - String get key => 'Tonua'; - - @override - String get duration => 'Iraupena (s)'; - - @override - String get tempo => 'Tenpoa (BPM)'; - - @override - String get mode => 'Modua'; - - @override - String get time_signature => 'Konpasa'; - - @override - String get short => 'Motza'; - - @override - String get medium => 'Ertaina'; - - @override - String get long => 'Luzea'; - - @override - String get min => 'Min.'; - - @override - String get max => 'Max.'; - - @override - String get target => 'Helburua'; - - @override - String get moderate => 'Moderatua'; - - @override - String get deselect_all => 'Desaukeratu dena'; - - @override - String get select_all => 'Aukeratu dena'; - - @override - String get are_you_sure => 'Ziur zaude?'; - - @override - String get generating_playlist => - 'Zure pertsonalizatutako zerrenda sortzen...'; - - @override - String selected_count_tracks(Object count) { - return '$count kanta aukeratuta'; - } - - @override - String get download_warning => - 'Abesti guztiak aldi berean deskargatuz gero, argi dago musika pirateatzen ari zarela eta musikaren gizarte sortzaileari kalte egiten diozula. Honen jakitun izan eta artisten lan gogorra errespetatu eta babestea espero dut'; - - @override - String get download_ip_ban_warning => - 'Bidenabar, baliteke zure IPa YouTuben blokeatzea deskarga eskera gehiegi egiten badituzu. IPa blokeatzeak esan nahi du ezin izango duzula YouTube erabili (nahiz eta saioa hasia izan) gutxienez 2-3 hilabetez IP helbide horretatik. Eta Spotube ez da erantzule izango hori gertatzen bazaizu'; - - @override - String get by_clicking_accept_terms => - '\'Onartu\' klikatzean, ondorengo baldintzak onartzen dituzu:'; - - @override - String get download_agreement_1 => - 'Badakit musika pirateatzen ari naizela. Gaiztoa naiz'; - - @override - String get download_agreement_2 => - 'Ahal dudanean lagunduko diot artistari baina oraingoz ez dut bere artea erosteko dirurik'; - - @override - String get download_agreement_3 => - 'Erabat jakitun naiz YouTubek nire IPa blokea dezakeela eta ez diot Spotube-ri edo bere jabe/laguntzaileei erantzukizunik eskatuko nire oraingo jokaerak ekar ditzakeen arazoengatik'; - - @override - String get decline => 'Baztertu'; - - @override - String get accept => 'Onartu'; - - @override - String get details => 'Xehetasunak'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Kanala'; - - @override - String get likes => 'Gustukoak'; - - @override - String get dislikes => 'Ez gustukoak'; - - @override - String get views => 'Ikuspenak'; - - @override - String get streamUrl => 'Streaming-aren URLa'; - - @override - String get stop => 'Gelditu'; - - @override - String get sort_newest => 'Ordenatu gehitu berrienetik'; - - @override - String get sort_oldest => 'Ordenatu gehitu zaharrenetik'; - - @override - String get sleep_timer => 'Itzaltzeko tenporizadorea'; - - @override - String mins(Object minutes) { - return '$minutes minutu'; - } - - @override - String hours(Object hours) { - return '$hours ordu'; - } - - @override - String hour(Object hours) { - return '$hours ordu'; - } - - @override - String get custom_hours => 'Ordu pertsonalizatuak'; - - @override - String get logs => 'Log-ak'; - - @override - String get developers => 'Garatzaileak'; - - @override - String get not_logged_in => 'Ez duzu saioa hasi'; - - @override - String get search_mode => 'Bilaketa modua'; - - @override - String get audio_source => 'Audio Iturria'; - - @override - String get ok => 'OK'; - - @override - String get failed_to_encrypt => 'Errorea zifratzean'; - - @override - String get encryption_failed_warning => - 'Spotube-ek zifratzea darabil datuak modu seguruan biltegiratzeko. Baina huts egin du. Hori dela eta, biltegiratzea ez da segurua izango\nLinux erabiltzen ari bazara, ziurtatu edozein sekretu-zerbitzu (gnome-keyring, kde-wallet, keepassxc etab.) instalatuta duzula'; - - @override - String get querying_info => 'Informazioa egiaztatzen...'; - - @override - String get piped_api_down => 'Piped-en APIa ez dago eskuragarri'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'Piped-en $pipedInstance instantzia ez dago martxan une honetan\n\nAldatu instantzia edo aldatu \'API mota\' YouTuberen API ofizialera\n\nZiurtatu aplikazioa berrabiarazten duzula aldaketa eta gero'; - } - - @override - String get you_are_offline => 'Une honetan konexiorik gabe zaude'; - - @override - String get connection_restored => 'Internet konexioa berrezarri egin da'; - - @override - String get use_system_title_bar => 'Erabili sistemako izenburu barra'; - - @override - String get crunching_results => 'Emaitzak prozesatzen...'; - - @override - String get search_to_get_results => 'Bilatu emaitzak lortzeko'; - - @override - String get use_amoled_mode => 'Erabili AMOLED modua'; - - @override - String get pitch_dark_theme => 'Dart-en gai iluna'; - - @override - String get normalize_audio => 'Normalizatu audioa'; - - @override - String get change_cover => 'Aldatu azala'; - - @override - String get add_cover => 'Gehitu azala'; - - @override - String get restore_defaults => 'Berrezarri berezko balioak'; - - @override - String get download_music_format => 'Musika deskargatzeko formatua'; - - @override - String get streaming_music_format => 'Musika streaming bidezko formatua'; - - @override - String get download_music_quality => 'Musika deskargaren kalitatea'; - - @override - String get streaming_music_quality => 'Streaming bidezko musika kalitatea'; - - @override - String get login_with_lastfm => 'Hasi saioa Last.fm-n'; - - @override - String get connect => 'Konektatu'; - - @override - String get disconnect_lastfm => 'Deskonektatu Last.fm-tik'; - - @override - String get disconnect => 'Deskonektatu'; - - @override - String get username => 'Erabiltzaile izena'; - - @override - String get password => 'Pasahitza'; - - @override - String get login => 'Hasi saioa'; - - @override - String get login_with_your_lastfm => 'Hasi saioa Last.fm-ko zure kontuarekin'; - - @override - String get scrobble_to_lastfm => 'Scrobble Last.fm-ra'; - - @override - String get go_to_album => 'Albumera joan'; - - @override - String get discord_rich_presence => 'Discord-en presentzia aberatsa'; - - @override - String get browse_all => 'Esploratu dena'; - - @override - String get genres => 'Generoak'; - - @override - String get explore_genres => 'Esploratu generoak'; - - @override - String get friends => 'Lagunak'; - - @override - String get no_lyrics_available => - 'Sentitzen dugu, ezin dira kanta honen hitzak aurkitu'; - - @override - String get start_a_radio => 'Hasi Irrati bat'; - - @override - String get how_to_start_radio => 'Nola hasi nahi duzu irratia?'; - - @override - String get replace_queue_question => - 'Uneko zerrenda ordezkatu nahi duzu edo bertan gehitu?'; - - @override - String get endless_playback => 'Amaigabeko erreprodukzioa'; - - @override - String get delete_playlist => 'Ezabatu zerrenda'; - - @override - String get delete_playlist_confirmation => - 'Ziur zaude zerrenda ezabatu nahi duzula?'; - - @override - String get local_tracks => 'Kanta lokalak'; - - @override - String get local_tab => 'Lokalean'; - - @override - String get song_link => 'Kantaren lotura'; - - @override - String get skip_this_nonsense => 'Utzi txorakeria hau'; - - @override - String get freedom_of_music => '“Musika Askatasuna”'; - - @override - String get freedom_of_music_palm => '“Musika Askatasuna zure eskuetan”'; - - @override - String get get_started => 'Has gaitezen'; - - @override - String get youtube_source_description => 'Gomendatua eta hobekien dabilena.'; - - @override - String get piped_source_description => - 'Aske zara? YouTube bezala, baino askeago.'; - - @override - String get jiosaavn_source_description => - 'Asia hegoaldeko herrialdeetarako hoberena.'; - - @override - String get invidious_source_description => - 'Piped-en antzekoa, baina eskuragarritasun handiagoarekin'; - - @override - String highest_quality(Object quality) { - return 'Kalitate Onena: $quality'; - } - - @override - String get select_audio_source => 'Aukeratu Audio Iturria'; - - @override - String get endless_playback_description => - 'Gehitu automatikoki kanta berriak\n ilararen bukaeran'; - - @override - String get choose_your_region => 'Aukeratu zure herrialdea'; - - @override - String get choose_your_region_description => - 'Honekin Spotube-k zure kokalerakuari dagokion edukia\neskeiniko dizu.'; - - @override - String get choose_your_language => 'Aukeratu zure hizkuntza'; - - @override - String get help_project_grow => 'Lagundu proiektu honi hazten'; - - @override - String get help_project_grow_description => - 'Spotube kode irekiko proiektu bat da. Proiektu hau hazten lagundu dezakezu, erroreak jakinaraziz edo ezaugarri berriak proposatuz.'; - - @override - String get contribute_on_github => 'GitHub-en lagundu'; - - @override - String get donate_on_open_collective => 'Open Collective-en diruz lagundu'; - - @override - String get browse_anonymously => 'Nabigatu Anonimoki'; - - @override - String get enable_connect => 'Gaitu konexioa'; - - @override - String get enable_connect_description => - 'Kontrolatu Spotube beste gailu batzuetatik'; - - @override - String get devices => 'Gailuak'; - - @override - String get select => 'Aukeratu'; - - @override - String connect_client_alert(Object client) { - return '$client gailuak kontrolatzen zaitu'; - } - - @override - String get this_device => 'Gailu hau'; - - @override - String get remote => 'Urrunekoa'; - - @override - String get stats => 'Estatistikak'; - - @override - String and_n_more(Object count) { - return 'eta $count gehiago'; - } - - @override - String get recently_played => 'Berriki entzunak'; - - @override - String get browse_more => 'Gehiago Bilatu'; - - @override - String get no_title => 'Titulurik ez'; - - @override - String get not_playing => 'Erreprodukziorik ez'; - - @override - String get epic_failure => 'Sekulako errorea!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return '$tracks_length kanta gehitu dira zerrendara'; - } - - @override - String get spotube_has_an_update => 'Spotube-ren eguneraketa bat dago'; - - @override - String get download_now => 'Orain deskargatu'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube $nightlyBuildNum Nightly-a argitaratu da'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version argitaratu da'; - } - - @override - String get read_the_latest => 'Irakurri azken '; - - @override - String get release_notes => 'argitatratze oharrak'; - - @override - String get pick_color_scheme => 'Aukeratu kolore eskema'; - - @override - String get save => 'Gorde'; - - @override - String get choose_the_device => 'Aukeratu gailua:'; - - @override - String get multiple_device_connected => - 'Hainbat gailu daude konektatuta.\nAukeratu zein gailutan aplikatu nahi duzun ekintza hau'; - - @override - String get nothing_found => 'Ezer ez da aurkitu'; - - @override - String get the_box_is_empty => 'Kaxa hutsik dago'; - - @override - String get top_artists => 'Top Artistak'; - - @override - String get top_albums => 'Top Albumak'; - - @override - String get this_week => 'Aste honetan'; - - @override - String get this_month => 'Hilabete honetan'; - - @override - String get last_6_months => 'Azken 6 hilabeteetan'; - - @override - String get this_year => 'Aurten'; - - @override - String get last_2_years => 'Azken 2 urtetan'; - - @override - String get all_time => 'Betidanik'; - - @override - String powered_by_provider(Object providerName) { - return '$providerName-ren eskutik'; - } - - @override - String get email => 'Email'; - - @override - String get profile_followers => 'Jarraitzaileak'; - - @override - String get birthday => 'Jaiotze-data'; - - @override - String get subscription => 'Harpidetzak'; - - @override - String get not_born => 'Jaio gabe'; - - @override - String get hacker => 'Hacker'; - - @override - String get profile => 'Profila'; - - @override - String get no_name => 'Izenik Ez'; - - @override - String get edit => 'Editatu'; - - @override - String get user_profile => 'Erabiltzaile Profila'; - - @override - String count_plays(Object count) { - return '$count erreprodukzio'; - } - - @override - String get streaming_fees_hypothetical => - 'Streaming ordainketa (hipotetikoa)'; - - @override - String get minutes_listened => 'Entzundako minutuak'; - - @override - String get streamed_songs => 'Streaming-ez entzundako kantak'; - - @override - String count_streams(Object count) { - return '$count stream'; - } - - @override - String get owned_by_you => 'Zure jabetzakoa'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl arbelera kopiatua'; - } - - @override - String get hipotetical_calculation => - '*Kalkulu hau online musika-streaming plataformetako batez besteko irteerako ordainari (0,003–0,005 USD) oinarrituta dago. Hipotetikoa da eta erabiltzaileari ideia bat ematen laguntzen dio artista nork zenbat kobratu zuen jakiteko, bere abestia plataform desberdinetan entzungo balu.'; - - @override - String count_mins(Object minutes) { - return '$minutes minutu'; - } - - @override - String get summary_minutes => 'minutu'; - - @override - String get summary_listened_to_music => 'Musika entzuten'; - - @override - String get summary_songs => 'kanta'; - - @override - String get summary_streamed_overall => 'Streaming abesti oro har'; - - @override - String get summary_owed_to_artists => 'Hilabete honetan\nartistei zor zaiena'; - - @override - String get summary_artists => 'artisten'; - - @override - String get summary_music_reached_you => 'Musika ailegatu zaizu'; - - @override - String get summary_full_albums => 'album osok'; - - @override - String get summary_got_your_love => 'Jaso dute zure maitasuna'; - - @override - String get summary_playlists => 'zerrenda'; - - @override - String get summary_were_on_repeat => 'Dituzu errepikatze moduan'; - - @override - String total_money(Object money) { - return 'Guztira $money'; - } - - @override - String get webview_not_found => 'Ez da Webview aurkitu'; - - @override - String get webview_not_found_description => - 'Ez dago Webview abiarazte denbora-instalaziorik zure gailuan.\nInstalatuta badago, ziurtatu environment PATH-an dagoela\n\nInstalatu ondoren, berrabiarazi aplikazioa'; - - @override - String get unsupported_platform => 'Plataforma ez onartua'; - - @override - String get cache_music => 'Musika cachean'; - - @override - String get open => 'Ireki'; - - @override - String get cache_folder => 'Cache karpeta'; - - @override - String get export => 'Esportatu'; - - @override - String get clear_cache => 'Garbitu cachea'; - - @override - String get clear_cache_confirmation => 'Cachea garbitu nahi al duzu?'; - - @override - String get export_cache_files => 'Esportatu cache fitxategiak'; - - @override - String found_n_files(Object count) { - return '$count fitxategi aurkitu dira'; - } - - @override - String get export_cache_confirmation => - 'Fitxategi hauek esportatu nahi al dituzu'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return '$filesExported fitxategi esportatu dira $files -tik'; - } - - @override - String get undo => 'Desegondu'; - - @override - String get download_all => 'Guztia deskargatu'; - - @override - String get add_all_to_playlist => 'Guztia playlist-era gehitu'; - - @override - String get add_all_to_queue => 'Guztia zerrendara gehitu'; - - @override - String get play_all_next => 'Guztia hurrengoan jolastu'; - - @override - String get pause => 'Pausatu'; - - @override - String get view_all => 'Ikusi guztia'; - - @override - String get no_tracks_added_yet => - 'Dirudienez, oraindik ez duzu abestirik gehitu.'; - - @override - String get no_tracks => 'Ez dirudi hemen abestirik dagoenik.'; - - @override - String get no_tracks_listened_yet => - 'Dirudienez, oraindik ez duzu ezer entzun.'; - - @override - String get not_following_artists => 'Ez zaude artisten atzetik.'; - - @override - String get no_favorite_albums_yet => - 'Dirudienez, oraindik ez duzu albumik gehitu zure gogokoen artean.'; - - @override - String get no_logs_found => 'Ez dira log-ak aurkitu'; - - @override - String get youtube_engine => 'YouTube Motorra'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine ez dago instalatuta'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine ez dago zure sisteman instalatuta.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Ziurtatu PATH aldagaiaren barruan dagoela edo\nezarri $engine exekutagarriaren helbide absolutua behean.'; - } - - @override - String get youtube_engine_unix_issue_message => - 'macOS/Linux/Unix bezalako sistemetan, .zshrc/.bashrc/.bash_profile bezalako fitxategietan bidearen ezarpenak ez dira funtzionatuko.\nBidearen ezarpena shell konfigurazio fitxategian egin behar duzu.'; - - @override - String get download => 'Deskargatu'; - - @override - String get file_not_found => 'Fitxategia ez da aurkitu'; - - @override - String get custom => 'Pertsonalizatua'; - - @override - String get add_custom_url => 'Gehitu URL pertsonalizatua'; - - @override - String get edit_port => 'Editatu portua'; - - @override - String get port_helper_msg => - 'Lehenetsitako balioa -1 da, zenbaki aleatorioa adierazten duena. Su firewall konfiguratu baduzu, gomendatzen da hau ezartzea.'; - - @override - String connect_request(Object client) { - return '$client konektatzea baimendu?'; - } - - @override - String get connection_request_denied => - 'Konektatzea ukatu da. Erabiltzaileak sarbidea ukatu du.'; - - @override - String get an_error_occurred => 'Errore bat gertatu da'; - - @override - String get copy_to_clipboard => 'Hiztegiraino kopiatzea'; - - @override - String get view_logs => 'Erregistroak ikusi'; - - @override - String get retry => 'Berriro saiatu'; - - @override - String get no_default_metadata_provider_selected => - 'Ezarri ez duzu metadaten hornitzaile lehenetsirik'; - - @override - String get manage_metadata_providers => 'Metadaten hornitzaileak kudeatu'; - - @override - String get open_link_in_browser => 'Esteka nabigatzailean irekiko duzu?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Hurrengo esteka irekiko duzu?'; - - @override - String get unsafe_url_warning => - 'Iturri seguru gabeko estekak irekiz gero, ez da seguru suerta daiteke. Arduratu zaitez!\nEsteka ere hiztegirainokoan kopiatu dezakezu.'; - - @override - String get copy_link => 'Esteka kopiatu'; - - @override - String get building_your_timeline => - 'Zure entzuteen arabera zure kronologia eraikitzen…'; - - @override - String get official => 'Ofiziala'; - - @override - String author_name(Object author) { - return 'Egilea: $author'; - } - - @override - String get third_party => 'Hirugarrena'; - - @override - String get plugin_requires_authentication => - 'Pluginak autentifikazioa eskatzen du'; - - @override - String get update_available => 'Eguneratze bat dago eskuragarri'; - - @override - String get supports_scrobbling => 'Scrobbling-a onartzen du'; - - @override - String get plugin_scrobbling_info => - 'Plugin honek zure musika scrobbled egiten du zure entzuteen historia sortzeko.'; - - @override - String get default_metadata_source => 'Metadatu-iturburu lehenetsia'; - - @override - String get set_default_metadata_source => - 'Ezarri metadatu-iturburu lehenetsia'; - - @override - String get default_audio_source => 'Audio-iturburu lehenetsia'; - - @override - String get set_default_audio_source => 'Ezarri audio-iturburu lehenetsia'; - - @override - String get set_default => 'Lehenetsi gisa ezarri'; - - @override - String get support => 'Laguntza'; - - @override - String get support_plugin_development => 'Pluginaren garapena lagundu'; - - @override - String can_access_name_api(Object name) { - return '- **$name** API-ra sar daiteke'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Plugin hau instalatu nahiko zenuke?'; - - @override - String get third_party_plugin_warning => - 'Plugin hau hirugarrenen biltegi batetik dator. Instalatu aurretik iturriari konfiantza behar diozu.'; - - @override - String get author => 'Egilea'; - - @override - String get this_plugin_can_do_following => - 'Plugin honek honako hau egin dezake:'; - - @override - String get install => 'Instalatu'; - - @override - String get install_a_metadata_provider => - 'Metadaten hornitzaile bat instalatu'; - - @override - String get no_tracks_playing => - 'Une honetan ez dago abestirik erreproduzitzen'; - - @override - String get synced_lyrics_not_available => - 'Abestiarentzako letra sinkronizatua ez dago erabilgarri. Mesedez, erabili'; - - @override - String get plain_lyrics => 'Letra arrunta'; - - @override - String get tab_instead => 'horren ordez, Tab teklatxaza erabili.'; - - @override - String get disclaimer => 'Aldez aurreko oharra'; - - @override - String get third_party_plugin_dmca_notice => - 'Spotube taldea ezin da arduratu (“hirugarrenen”) plugin-en>gatik (barne legala). Erabili zure arriskuarekin. Erroreak/ arazoak dituzu, jakinarazi pluginaren biltegiari.\n\nPlugin batek edozein zerbitzu/legalki entitate baten ToS/DMCA hautsi baditu, eska iezaiozu pluginaren egileari edo hosting plataformari (adibidez GitHub/Codeberg) neurriak har ditzaten. “Hirugarrena” etiketatutako plugin guztiak komunitate publikoaren bidez mantentzen dira; ez ditugu kuratoriatu, beraz ezin dugu inplikatu.\n\n'; - - @override - String get input_does_not_match_format => - 'Sarrera ezin da beharrezko formatutik desberdina izan'; - - @override - String get plugins => 'Pluginak'; - - @override - String get paste_plugin_download_url => - 'Kopiatu deskarga-URLa, GitHub/Codeberg biltegi-URLa edo .smplug fitxategiaren esteka zuzena'; - - @override - String get download_and_install_plugin_from_url => - 'Download eta instalatu plugin-a URL batetik'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Plugin gehitu ezin izan da: $error'; - } - - @override - String get upload_plugin_from_file => 'Plugin fitxategi batetik igo'; - - @override - String get installed => 'Instalatuta'; - - @override - String get available_plugins => 'Eskaintzen diren pluginak'; - - @override - String get configure_plugins => - 'Konfiguratu zure metadatu-hornitzaile eta audio-iturburu pluginak'; - - @override - String get audio_scrobblers => 'Audio scrobbler-ak'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Iturburua: '; - - @override - String get uncompressed => 'Konprimitu gabea'; - - @override - String get dab_music_source_description => - 'Audiozaleentzat. Kalitate handiko/galerarik gabeko audio-streamak eskaintzen ditu. ISRC oinarritutako pistaren parekatze zehatza.'; -} diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart deleted file mode 100644 index 5c0b7c2b..00000000 --- a/lib/l10n/generated/app_localizations_fa.dart +++ /dev/null @@ -1,1564 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Persian (`fa`). -class AppLocalizationsFa extends AppLocalizations { - AppLocalizationsFa([String locale = 'fa']) : super(locale); - - @override - String get guest => 'مهمان'; - - @override - String get browse => 'مرور'; - - @override - String get search => 'جستجو'; - - @override - String get library => 'مجموعه'; - - @override - String get lyrics => 'متن'; - - @override - String get settings => 'تنظیمات'; - - @override - String get genre_categories_filter => 'دسته ها یا ژانر ها را فیلتر کنید'; - - @override - String get genre => 'ژانر'; - - @override - String get personalized => ' شخصی سازی شده'; - - @override - String get featured => 'ویژه'; - - @override - String get new_releases => 'آخرین انتشارات'; - - @override - String get songs => 'آهنگ ها'; - - @override - String playing_track(Object track) { - return 'درحال پخش $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'با این کار صف فعلی پاک می شود. $track_length آهنگ از صف حذف میشود\n؟آیا ادامه میدهید'; - } - - @override - String get load_more => 'بارگذاری بیشتر'; - - @override - String get playlists => 'لیست های پخش'; - - @override - String get artists => 'هنرمندان'; - - @override - String get albums => 'آلبوم ها'; - - @override - String get tracks => 'آهنگ ها'; - - @override - String get downloads => 'بارگیری شده ها'; - - @override - String get filter_playlists => 'لیست پخش خود را فیلتر کنید...'; - - @override - String get liked_tracks => 'آهنگ های مورد علاقه'; - - @override - String get liked_tracks_description => 'همه آهنگ های دوست داشتنی شما'; - - @override - String get playlist => 'لیست پخش'; - - @override - String get create_a_playlist => 'ساخت لیست پخش'; - - @override - String get update_playlist => 'بروز کردن لیست پخش'; - - @override - String get create => 'ساختن'; - - @override - String get cancel => 'لغو'; - - @override - String get update => 'بروز رسانی'; - - @override - String get playlist_name => 'نام لیست پخش'; - - @override - String get name_of_playlist => 'نام لیست پخش'; - - @override - String get description => 'توضیحات'; - - @override - String get public => 'عمومی'; - - @override - String get collaborative => 'مبتنی بر همکاری'; - - @override - String get search_local_tracks => 'جستجوی آهنگ های محلی...'; - - @override - String get play => 'پخش'; - - @override - String get delete => 'حذف'; - - @override - String get none => 'هیچ کدام'; - - @override - String get sort_a_z => 'مرتب سازی بر اساس حروف الفبا'; - - @override - String get sort_z_a => 'مرتب سازی برعکس حروف الفبا'; - - @override - String get sort_artist => 'مرتب سازی بر اساس هنرمند'; - - @override - String get sort_album => 'مرتب سازی بر اساس آلبوم'; - - @override - String get sort_duration => 'مرتب کردن بر اساس مدت زمان'; - - @override - String get sort_tracks => 'مرتب سازی آهنگ ها'; - - @override - String currently_downloading(Object tracks_length) { - return 'در حال بارگیری ($tracks_length)'; - } - - @override - String get cancel_all => 'لغو همه'; - - @override - String get filter_artist => 'فیلتر کردن هنرمند...'; - - @override - String followers(Object followers) { - return '$followers دنبال کننده'; - } - - @override - String get add_artist_to_blacklist => 'اضافه کردن هنرمند به لیست سیاه'; - - @override - String get top_tracks => 'بهترین آهنگ ها'; - - @override - String get fans_also_like => 'طرفداران هم دوست داشتند'; - - @override - String get loading => 'بارگزاری...'; - - @override - String get artist => 'هنرمند'; - - @override - String get blacklisted => 'در لیست سیاه قرار گرفته است'; - - @override - String get following => 'دنبال کننده'; - - @override - String get follow => 'دنبال کردن'; - - @override - String get artist_url_copied => 'لینک هنرمند در کلیپ بورد کپی شد'; - - @override - String added_to_queue(Object tracks) { - return 'تعداد $tracks آهنگ به صف اضافه شد'; - } - - @override - String get filter_albums => 'فیلتر کردن آلبوم...'; - - @override - String get synced => 'همگام سازی شد'; - - @override - String get plain => 'ساده'; - - @override - String get shuffle => 'تصادفی'; - - @override - String get search_tracks => 'جستجوی آهنگ ها...'; - - @override - String get released => 'منتشر شده'; - - @override - String error(Object error) { - return 'خطا $error'; - } - - @override - String get title => 'عنوان'; - - @override - String get time => 'زمان'; - - @override - String get more_actions => 'اقدامات بیشتر'; - - @override - String download_count(Object count) { - return 'دانلود ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'اضافه کردن ($count) به لیست پخش'; - } - - @override - String add_count_to_queue(Object count) { - return 'اضافه کردن ($count) به صف'; - } - - @override - String play_count_next(Object count) { - return 'پخش ($count) بعدی'; - } - - @override - String get album => 'آلبوم'; - - @override - String copied_to_clipboard(Object data) { - return '$data در کلیپ بورد کپی شد'; - } - - @override - String add_to_following_playlists(Object track) { - return 'اضافه کردن $track به لیست پخش زیر'; - } - - @override - String get add => 'اضافه کردن'; - - @override - String added_track_to_queue(Object track) { - return '$track به لیست پخش اضافه شد'; - } - - @override - String get add_to_queue => 'اضافه کردن به صف'; - - @override - String track_will_play_next(Object track) { - return '$track پخش خواهد شد'; - } - - @override - String get play_next => 'پخش آهنگ بعدی'; - - @override - String removed_track_from_queue(Object track) { - return '$track از لیست پخش حذف شد'; - } - - @override - String get remove_from_queue => 'از لیست پخش حذف شد'; - - @override - String get remove_from_favorites => 'از علاقمندی ها حدف شد'; - - @override - String get save_as_favorite => 'ذخیره به عنوان علاقمندی ها'; - - @override - String get add_to_playlist => 'به لیست پخش اضافه کردن'; - - @override - String get remove_from_playlist => 'از لیست پخش حذف کردن'; - - @override - String get add_to_blacklist => 'به لیست سیاه اضافه کردن'; - - @override - String get remove_from_blacklist => 'از لیست سیاه حذف کردن'; - - @override - String get share => 'اشتراک گذاری'; - - @override - String get mini_player => 'پخش کننده '; - - @override - String get slide_to_seek => 'برای جستجو عقب یا جلو بکشید'; - - @override - String get shuffle_playlist => 'پخش تصادفی'; - - @override - String get unshuffle_playlist => 'خاموش کردن پخش تصادفی'; - - @override - String get previous_track => 'آهنگ قبلی'; - - @override - String get next_track => 'آهنگ بعدی'; - - @override - String get pause_playback => 'توقف آهنگ'; - - @override - String get resume_playback => 'ادامه آهنگ'; - - @override - String get loop_track => 'تکرار آهنگ'; - - @override - String get no_loop => 'بدون حلقه'; - - @override - String get repeat_playlist => 'تکرار لیست پخش'; - - @override - String get queue => 'صف'; - - @override - String get alternative_track_sources => ' منبع آهنگ را جاگزین کردن '; - - @override - String get download_track => 'بارگیری آهنگ'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks آهنگ در صف'; - } - - @override - String get clear_all => 'همه را حدف کن'; - - @override - String get show_hide_ui_on_hover => 'نمایش/پنهان رابط کاربری در حالت شناور'; - - @override - String get always_on_top => 'همیشه روشن'; - - @override - String get exit_mini_player => 'از پخش کننده خارج شوید'; - - @override - String get download_location => 'محل بارگیری'; - - @override - String get local_library => 'کتابخانه محلی'; - - @override - String get add_library_location => 'اضافه کردن به کتابخانه'; - - @override - String get remove_library_location => 'حذف از کتابخانه'; - - @override - String get account => 'حساب کاربری'; - - @override - String get logout => 'خارج شدن'; - - @override - String get logout_of_this_account => 'از حساب کاربری خارج شوید'; - - @override - String get language_region => 'زبان و منطقه '; - - @override - String get language => 'زبان '; - - @override - String get system_default => 'پیش فرض سیستم'; - - @override - String get market_place_region => 'منطقه'; - - @override - String get recommendation_country => 'کشور های پیشنهادی'; - - @override - String get appearance => 'ظاهر'; - - @override - String get layout_mode => 'حالت چیدمان'; - - @override - String get override_layout_settings => - 'تنطیمات حالت واکنشگرای چیدمان را لغو کن'; - - @override - String get adaptive => 'قابل تطبیق'; - - @override - String get compact => 'فشرده'; - - @override - String get extended => 'گسترده'; - - @override - String get theme => 'تم'; - - @override - String get dark => 'تاریک'; - - @override - String get light => 'روشن'; - - @override - String get system => 'سیستم'; - - @override - String get accent_color => 'رنگ تاکیدی'; - - @override - String get sync_album_color => 'هنگام سازی رنگ البوم'; - - @override - String get sync_album_color_description => - 'از رنگ البوم هنرمند به عنوان رنگ تاکیدی استفاده میکند'; - - @override - String get playback => 'پخش'; - - @override - String get audio_quality => 'کیفیت صدا'; - - @override - String get high => 'زیاد'; - - @override - String get low => 'کم'; - - @override - String get pre_download_play => 'دانلود و پخش کنید'; - - @override - String get pre_download_play_description => - 'به جای پخش جریانی صدا، بایت ها را دانلود کنید و به جای آن پخش کنید (برای کاربران با پهنای باند بالاتر توصیه می شود)'; - - @override - String get skip_non_music => 'رد شدن از پخش های غیر موسیقی (SponsorBlock)'; - - @override - String get blacklist_description => 'آهنگ ها و هنرمند های در لیست سیاه'; - - @override - String get wait_for_download_to_finish => - 'لطفا صبر کنید تا دانلود آهنگ جاری تمام شود'; - - @override - String get desktop => 'میز کار'; - - @override - String get close_behavior => 'رفتار نزدیک'; - - @override - String get close => 'بستن'; - - @override - String get minimize_to_tray => 'پتجره را کوچک کنید'; - - @override - String get show_tray_icon => 'نماد را نمایش بده'; - - @override - String get about => 'درباره'; - - @override - String get u_love_spotube => 'دوست داریدSpotubeما میدانیم شما '; - - @override - String get check_for_updates => 'بروزرسانی را بررسی کنید'; - - @override - String get about_spotube => 'Spotube درباره'; - - @override - String get blacklist => 'لیست سیاه'; - - @override - String get please_sponsor => 'لطفا کمک/حمایت کنید'; - - @override - String get spotube_description => - 'یک برنامه سبک و مولتی پلتفرم و رایگان برای همه استSpotube'; - - @override - String get version => 'نسخه'; - - @override - String get build_number => 'شماره ساخت'; - - @override - String get founder => 'بنیانگذار'; - - @override - String get repository => 'مخزن'; - - @override - String get bug_issues => 'اشکال+مسایل'; - - @override - String get made_with => '🇧🇩ساخته شده با ❤️ در بنگلادش'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'مجوز'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'نگران نباشید هیچ کدوما از اعتبارات شما جمع اوری نمیشود یا با کسی اشتراک گزاشته نمیشود'; - - @override - String get know_how_to_login => 'نمیدانی چگونه این کار را انجام بدهی؟'; - - @override - String get follow_step_by_step_guide => 'راهنما را گام به گام دنبال کنید'; - - @override - String cookie_name_cookie(Object name) { - return '$name کوکی'; - } - - @override - String get fill_in_all_fields => 'لطفا تمام فلید ها را پر کنید'; - - @override - String get submit => 'ثبت'; - - @override - String get exit => 'خروج'; - - @override - String get previous => 'قبلی'; - - @override - String get next => 'بعدی '; - - @override - String get done => 'اتمام'; - - @override - String get step_1 => 'گام 1'; - - @override - String get first_go_to => 'اول برو داخل '; - - @override - String get something_went_wrong => 'اشتباهی رخ داده'; - - @override - String get piped_instance => 'مشکل در ارتباط با سرور'; - - @override - String get piped_description => 'مشکل در ارتباط با سرور در دریافت آهنگ ها'; - - @override - String get piped_warning => - 'برخی از آنها ممکن است خوب کارنکند.بنابراین با مسولیت خود استفاده کنید'; - - @override - String get invidious_instance => 'نمونه سرور Invidious'; - - @override - String get invidious_description => 'نمونه سرور Invidious برای تطبیق آهنگ'; - - @override - String get invidious_warning => - 'برخی از نمونه‌ها ممکن است به خوبی کار نکنند. با احتیاط استفاده کنید'; - - @override - String get generate => 'ایجاد'; - - @override - String track_exists(Object track) { - return 'آهنگ $track وجود دارد'; - } - - @override - String get replace_downloaded_tracks => - 'همه ی آهنگ های دانلود شده را جایگزین کنید'; - - @override - String get skip_download_tracks => 'همه ی آهنگ های دانلود شده را رد کنید'; - - @override - String get do_you_want_to_replace => - 'ایا میخواهید آهنگ های موجود جایگزین کنید؟'; - - @override - String get replace => 'جایگزین کردن'; - - @override - String get skip => 'رد کردن'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'انتخاب کنید تا $count $type'; - } - - @override - String get select_genres => 'ژانر ها را انتخاب کنید'; - - @override - String get add_genres => 'ژانر را اطافه کنید'; - - @override - String get country => 'کشور'; - - @override - String get number_of_tracks_generate => 'تعداد آهنگ های ساخته شده'; - - @override - String get acousticness => 'آکوستیک'; - - @override - String get danceability => 'رقصیدن'; - - @override - String get energy => 'انرژی'; - - @override - String get instrumentalness => 'بی کلام'; - - @override - String get liveness => 'حس زندگی'; - - @override - String get loudness => 'صدای بلند'; - - @override - String get speechiness => 'دکلمه'; - - @override - String get valence => 'ظرفیت'; - - @override - String get popularity => 'محبوبیت'; - - @override - String get key => 'کلید'; - - @override - String get duration => 'مدت زمان (ثانیه)'; - - @override - String get tempo => 'تمپو (BPM)'; - - @override - String get mode => 'حالت'; - - @override - String get time_signature => 'امضای زمان'; - - @override - String get short => 'کوتاه'; - - @override - String get medium => 'متوسط'; - - @override - String get long => 'بلند'; - - @override - String get min => 'حداقل'; - - @override - String get max => 'حداکثر'; - - @override - String get target => 'هدف'; - - @override - String get moderate => 'حد وسط'; - - @override - String get deselect_all => 'همه را لغو انتخاب کنید'; - - @override - String get select_all => 'همه را انتخاب کنید'; - - @override - String get are_you_sure => 'ایا مطمعن هستید؟'; - - @override - String get generating_playlist => ' درحال ایجاد لیست پخش سفارشی شما'; - - @override - String selected_count_tracks(Object count) { - return 'آهنگ انتخاب شده $count'; - } - - @override - String get download_warning => - 'اگر همه ی آهنگ ها را به صورت انبو دانلود کنید به وضوح در حال دزدی موسقی هستید و در حال اسیب وارد کردن به جامه ی خلاق هنری می باشید .امیدوارم که از این موضوع اگاه باشید .همیشه سعی کنید به کار سخت هنرمند اخترام بگذارید.'; - - @override - String get download_ip_ban_warning => - 'راستی آی پی شما می تواند در یوتوب به دلیل درخواست های دانلود بیش از حد معمول مسدود شود. بلوک آی پی به این معنی است که شما نمی توانید از یوتوب (حتی اگر وارد سیستم شده باشید) حداقل 2-3 ماه از آن دستگاه آی پی استفاده کنید. و Spotube هیچ مسئولیتی در صورت وقوع این اتفاق ندارد'; - - @override - String get by_clicking_accept_terms => - 'با کلیک بر روی قبول با شرایط زیر موافقت می کنید:'; - - @override - String get download_agreement_1 => 'من میدانم در حال دزدی هستم .من بد هستم'; - - @override - String get download_agreement_2 => - 'من هر کجا ک بتوانم از هنرمندان حمایت میکنم اما این کارا فقط به دلیل اینکه توانایی مالی ندارم انجام میدهم'; - - @override - String get download_agreement_3 => - 'من کاملا میدانم که از طرف یوتوب بلاک میشم و این برنامه و مالکان را مسول این حادثه نمیدانم.'; - - @override - String get decline => 'قبول نکردن'; - - @override - String get accept => 'قبول'; - - @override - String get details => 'جزئیات'; - - @override - String get youtube => 'یوتیوب'; - - @override - String get channel => 'کانال'; - - @override - String get likes => 'دوست داشتن'; - - @override - String get dislikes => 'دوست نداشتن'; - - @override - String get views => 'بازدید'; - - @override - String get streamUrl => 'لینک اثر'; - - @override - String get stop => 'توقف'; - - @override - String get sort_newest => 'مرتب سازی بر اساس جدید ترین اضافه شده'; - - @override - String get sort_oldest => 'مرتب سازی بر اساس قدیمی ترین اضافه شده'; - - @override - String get sleep_timer => 'زمان خواب'; - - @override - String mins(Object minutes) { - return '$minutes دقیقه'; - } - - @override - String hours(Object hours) { - return '$hours ساعت'; - } - - @override - String hour(Object hours) { - return '$hours ساعت'; - } - - @override - String get custom_hours => 'ساعت سفارشی'; - - @override - String get logs => 'رسید خطا'; - - @override - String get developers => 'توسعه دهنده ها'; - - @override - String get not_logged_in => 'شما وارد نشده اید '; - - @override - String get search_mode => 'حالت جستجو'; - - @override - String get audio_source => 'منبع صدا'; - - @override - String get ok => 'باشد'; - - @override - String get failed_to_encrypt => 'رمز گذاری نشده'; - - @override - String get encryption_failed_warning => - 'Spotube از رمزگذاری برای ذخیره ایمن داده های شما استفاده می کند. اما موفق به انجام این کار نشد. بنابراین به فضای ذخیره‌سازی ناامن تبدیل می‌شود\nاگر از لینوکس استفاده می‌کنید، لطفاً مطمئن شوید که سرویس مخفی (gnome-keyring، kde-wallet، keepassxc و غیره) را نصب کرده‌اید.'; - - @override - String get querying_info => 'جستجو درباره '; - - @override - String get piped_api_down => 'ایراد در سرور'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'به دلیل مشکل $pipedInstance ارتباط با سرور مقدور نیست\n\nنمونه را تغییر دهید یا «نوع API» را به API رسمی YouTube تغییر دهید\n\nحتماً پس از تغییر، برنامه را دوباره راه‌اندازی کنید'; - } - - @override - String get you_are_offline => 'شما در حال حاضر افلاین هستید '; - - @override - String get connection_restored => 'اتصال به اینترنت شما بازیابی شد '; - - @override - String get use_system_title_bar => 'از نوار عنوان سیستم استفاده کنید '; - - @override - String get crunching_results => 'نتایج خرد کردن...'; - - @override - String get search_to_get_results => 'جستجو کنید تا به نتیجه برسید'; - - @override - String get use_amoled_mode => 'استفاده از حالت AMOLED'; - - @override - String get pitch_dark_theme => 'تم تیره دارت'; - - @override - String get normalize_audio => 'نرمال کردن صدا'; - - @override - String get change_cover => 'تغییر جلد'; - - @override - String get add_cover => 'افزودن جلد'; - - @override - String get restore_defaults => 'بازیابی پیش فرض ها'; - - @override - String get download_music_format => 'فرمت دانلود موسیقی'; - - @override - String get streaming_music_format => 'فرمت پخش آنلاین موسیقی'; - - @override - String get download_music_quality => 'کیفیت دانلود موسیقی'; - - @override - String get streaming_music_quality => 'کیفیت پخش آنلاین موسیقی'; - - @override - String get login_with_lastfm => 'ورود با Last.fm'; - - @override - String get connect => 'اتصال'; - - @override - String get disconnect_lastfm => 'قطع ارتباط با Last.fm'; - - @override - String get disconnect => 'قطع ارتباط'; - - @override - String get username => 'نام کاربری'; - - @override - String get password => 'رمز عبور'; - - @override - String get login => 'ورود'; - - @override - String get login_with_your_lastfm => 'ورود با حساب کاربری Last.fm خود'; - - @override - String get scrobble_to_lastfm => 'Scrobble به Last.fm'; - - @override - String get go_to_album => 'رفتن به آلبوم'; - - @override - String get discord_rich_presence => 'حضور غنی دیسکورد'; - - @override - String get browse_all => 'مرور همه'; - - @override - String get genres => 'ژانرها'; - - @override - String get explore_genres => 'استکشاف ژانرها'; - - @override - String get friends => 'دوستان'; - - @override - String get no_lyrics_available => - 'متاسفیم، قادر به یافتن متن این قطعه نیستیم'; - - @override - String get start_a_radio => 'شروع یک رادیو'; - - @override - String get how_to_start_radio => 'چگونه می‌خواهید رادیو را شروع کنید؟'; - - @override - String get replace_queue_question => - 'آیا می‌خواهید لیست پخش فعلی را جایگزین کنید یا به آن اضافه کنید؟'; - - @override - String get endless_playback => 'پخش بی‌پایان'; - - @override - String get delete_playlist => 'حذف لیست پخش'; - - @override - String get delete_playlist_confirmation => - 'آیا مطمئن هستید که می‌خواهید این لیست پخش را حذف کنید؟'; - - @override - String get local_tracks => 'موسیقی‌های محلی'; - - @override - String get local_tab => 'محلی'; - - @override - String get song_link => 'پیوند آهنگ'; - - @override - String get skip_this_nonsense => 'این احمقانه را بگذرانید'; - - @override - String get freedom_of_music => '“آزادی موسیقی”'; - - @override - String get freedom_of_music_palm => '“آزادی موسیقی در دستان شما”'; - - @override - String get get_started => 'بیایید شروع کنیم'; - - @override - String get youtube_source_description => 'پیشنهاد شده و بهترین عمل می‌کند.'; - - @override - String get piped_source_description => - 'احساس آزادی می‌کنید؟ مانند یوتیوب اما بیشتر آزاد.'; - - @override - String get jiosaavn_source_description => 'بهترین برای منطقه جنوب آسیا.'; - - @override - String get invidious_source_description => - 'شبیه Piped اما با در دسترس بودن بیشتر'; - - @override - String highest_quality(Object quality) { - return 'بالاترین کیفیت: $quality'; - } - - @override - String get select_audio_source => 'انتخاب منبع صوتی'; - - @override - String get endless_playback_description => - 'خودکار اضافه کردن آهنگ‌های جدید\nبه انتهای صف'; - - @override - String get choose_your_region => 'منطقه خود را انتخاب کنید'; - - @override - String get choose_your_region_description => - 'این به Spotube کمک می‌کند تا محتوای مناسبی را برای موقعیت شما نشان دهد.'; - - @override - String get choose_your_language => 'زبان خود را انتخاب کنید'; - - @override - String get help_project_grow => 'کمک به رشد این پروژه'; - - @override - String get help_project_grow_description => - 'Spotube یک پروژه متن باز است. شما می‌توانید با به پروژه کمک کردن، گزارش دادن اشکالات یا پیشنهاد ویژگی‌های جدید، به این پروژه کمک کنید.'; - - @override - String get contribute_on_github => 'مشارکت در GitHub'; - - @override - String get donate_on_open_collective => 'کمک مالی در Open Collective'; - - @override - String get browse_anonymously => 'مرور به صورت ناشناس'; - - @override - String get enable_connect => 'فعال‌سازی اتصال'; - - @override - String get enable_connect_description => 'کنترل Spotube از دیگر دستگاه‌ها'; - - @override - String get devices => 'دستگاه‌ها'; - - @override - String get select => 'انتخاب'; - - @override - String connect_client_alert(Object client) { - return 'شما توسط $client کنترل می‌شوید'; - } - - @override - String get this_device => 'این دستگاه'; - - @override - String get remote => 'راه‌دور'; - - @override - String get stats => 'آمار'; - - @override - String and_n_more(Object count) { - return 'و $count بیشتر'; - } - - @override - String get recently_played => 'اخیراً پخش شده'; - - @override - String get browse_more => 'بیشتر مرور کنید'; - - @override - String get no_title => 'بدون عنوان'; - - @override - String get not_playing => 'در حال پخش نیست'; - - @override - String get epic_failure => 'شکست حماسی!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return '$tracks_length ترک به صف اضافه شد'; - } - - @override - String get spotube_has_an_update => 'Spotube یک بروزرسانی دارد'; - - @override - String get download_now => 'اکنون دانلود کنید'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'نسخه شبانه Spotube $nightlyBuildNum منتشر شد'; - } - - @override - String release_version(Object version) { - return 'نسخه Spotube v$version منتشر شد'; - } - - @override - String get read_the_latest => 'آخرین‌ها را بخوانید'; - - @override - String get release_notes => 'یادداشت‌های انتشار'; - - @override - String get pick_color_scheme => 'طرح رنگ را انتخاب کنید'; - - @override - String get save => 'ذخیره'; - - @override - String get choose_the_device => 'دستگاه را انتخاب کنید:'; - - @override - String get multiple_device_connected => - 'چندین دستگاه متصل هستند.\nدستگاهی را انتخاب کنید که می‌خواهید این عملیات بر روی آن انجام شود'; - - @override - String get nothing_found => 'چیزی پیدا نشد'; - - @override - String get the_box_is_empty => 'جعبه خالی است'; - - @override - String get top_artists => 'بهترین هنرمندان'; - - @override - String get top_albums => 'بهترین آلبوم‌ها'; - - @override - String get this_week => 'این هفته'; - - @override - String get this_month => 'این ماه'; - - @override - String get last_6_months => '۶ ماه گذشته'; - - @override - String get this_year => 'امسال'; - - @override - String get last_2_years => '۲ سال گذشته'; - - @override - String get all_time => 'همیشه'; - - @override - String powered_by_provider(Object providerName) { - return 'توسط $providerName پشتیبانی شده است'; - } - - @override - String get email => 'ایمیل'; - - @override - String get profile_followers => 'دنبال‌کنندگان'; - - @override - String get birthday => 'تولد'; - - @override - String get subscription => 'اشتراک'; - - @override - String get not_born => 'متولد نشده'; - - @override - String get hacker => 'هکر'; - - @override - String get profile => 'پروفایل'; - - @override - String get no_name => 'بدون نام'; - - @override - String get edit => 'ویرایش'; - - @override - String get user_profile => 'پروفایل کاربر'; - - @override - String count_plays(Object count) { - return '$count پخش'; - } - - @override - String get streaming_fees_hypothetical => 'هزینه‌های پخش (فرضی)'; - - @override - String get minutes_listened => 'دقایق گوش داده شده'; - - @override - String get streamed_songs => 'ترانه‌های پخش شده'; - - @override - String count_streams(Object count) { - return '$count پخش'; - } - - @override - String get owned_by_you => 'توسط شما مالکیت شده'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl به کلیپ‌بورد کپی شد'; - } - - @override - String get hipotetical_calculation => - '*این محاسبه بر اساس میانگین پرداخت به ازای هر پخش (0.003 تا 0.005 دلار) در پلتفرم‌های استریم موزیک آنلاین انجام شده است. این یک محاسبه فرضی است که به کاربر دیدی از مقدار پرداختی به هنرمندان در صورت گوش دادن به آهنگ آن‌ها در پلتفرم‌های مختلف ارائه می‌دهد.'; - - @override - String count_mins(Object minutes) { - return '$minutes دقیقه'; - } - - @override - String get summary_minutes => 'دقیقه‌ها'; - - @override - String get summary_listened_to_music => 'به موسیقی گوش داده شده'; - - @override - String get summary_songs => 'ترانه‌ها'; - - @override - String get summary_streamed_overall => 'پخش شده به طور کلی'; - - @override - String get summary_owed_to_artists => 'به هنرمندان بدهکار است\nاین ماه'; - - @override - String get summary_artists => 'هنرمندان'; - - @override - String get summary_music_reached_you => 'موسیقی به شما رسیده است'; - - @override - String get summary_full_albums => 'آلبوم‌های کامل'; - - @override - String get summary_got_your_love => 'عشق شما را به دست آورد'; - - @override - String get summary_playlists => 'لیست‌های پخش'; - - @override - String get summary_were_on_repeat => 'در تکرار بودند'; - - @override - String total_money(Object money) { - return 'مجموع $money'; - } - - @override - String get webview_not_found => 'وب‌ویو پیدا نشد'; - - @override - String get webview_not_found_description => - 'هیچ اجرای وب‌ویو روی دستگاه شما نصب نشده است.\nدر صورت نصب، مطمئن شوید که در environment PATH قرار دارد\n\nپس از نصب، برنامه را مجدداً راه‌اندازی کنید'; - - @override - String get unsupported_platform => 'پلتفرم پشتیبانی نمی‌شود'; - - @override - String get cache_music => 'موسیقی در حافظه موقت'; - - @override - String get open => 'باز کردن'; - - @override - String get cache_folder => 'پوشه حافظه موقت'; - - @override - String get export => 'صادر کردن'; - - @override - String get clear_cache => 'پاک کردن حافظه موقت'; - - @override - String get clear_cache_confirmation => - 'آیا می‌خواهید حافظه موقت را پاک کنید؟'; - - @override - String get export_cache_files => 'صادر کردن فایل‌های حافظه موقت'; - - @override - String found_n_files(Object count) { - return '$count فایل یافت شد'; - } - - @override - String get export_cache_confirmation => - 'آیا می‌خواهید این فایل‌ها را صادر کنید به'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return '$filesExported از $files فایل صادر شد'; - } - - @override - String get undo => 'بازگشت'; - - @override - String get download_all => 'دانلود همه'; - - @override - String get add_all_to_playlist => 'افزودن همه به لیست پخش'; - - @override - String get add_all_to_queue => 'افزودن همه به صف'; - - @override - String get play_all_next => 'پخش همه بعدی'; - - @override - String get pause => 'مکث'; - - @override - String get view_all => 'مشاهده همه'; - - @override - String get no_tracks_added_yet => - 'به نظر می‌رسد هنوز هیچ آهنگی اضافه نکرده‌اید.'; - - @override - String get no_tracks => 'به نظر می‌رسد هیچ آهنگی در اینجا وجود ندارد.'; - - @override - String get no_tracks_listened_yet => 'به نظر می‌رسد هنوز چیزی نشنیده‌اید.'; - - @override - String get not_following_artists => 'شما هیچ هنرمندی را دنبال نمی‌کنید.'; - - @override - String get no_favorite_albums_yet => - 'به نظر می‌رسد هنوز هیچ آلبومی را به علاقه‌مندی‌هایتان اضافه نکرده‌اید.'; - - @override - String get no_logs_found => 'هیچ لاگی پیدا نشد'; - - @override - String get youtube_engine => 'موتور YouTube'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine نصب نشده است'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine در سیستم شما نصب نشده است.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'اطمینان حاصل کنید که در متغیر PATH موجود است یا\nآدرس مطلق فایل اجرایی $engine را در زیر تنظیم کنید.'; - } - - @override - String get youtube_engine_unix_issue_message => - 'در macOS/Linux/سیستم‌عامل‌های مشابه Unix، تنظیم مسیر در .zshrc/.bashrc/.bash_profile و غیره کار نمی‌کند.\nباید مسیر را در فایل پیکربندی شل تنظیم کنید.'; - - @override - String get download => 'دانلود'; - - @override - String get file_not_found => 'فایل پیدا نشد'; - - @override - String get custom => 'شخصی‌سازی شده'; - - @override - String get add_custom_url => 'اضافه کردن URL سفارشی'; - - @override - String get edit_port => 'ویرایش پورت'; - - @override - String get port_helper_msg => - 'پیش‌فرض -1 است که نشان‌دهنده یک عدد تصادفی است. اگر فایروال شما پیکربندی شده است، توصیه می‌شود این را تنظیم کنید.'; - - @override - String connect_request(Object client) { - return 'آیا اجازه می‌دهید $client متصل شود؟'; - } - - @override - String get connection_request_denied => - 'اتصال رد شد. کاربر دسترسی را رد کرد.'; - - @override - String get an_error_occurred => 'خطایی رخ داد'; - - @override - String get copy_to_clipboard => 'کپی به کلیپ‌بورد'; - - @override - String get view_logs => 'مشاهده لاگ‌ها'; - - @override - String get retry => 'دوباره تلاش کن'; - - @override - String get no_default_metadata_provider_selected => - 'هیچ ارائه‌دهندهٔ پیش‌فرض متادیتا تعیین نکرده‌اید'; - - @override - String get manage_metadata_providers => 'مدیریت ارائه‌دهندگان متادیتا'; - - @override - String get open_link_in_browser => 'باز کردن لینک در مرورگر؟'; - - @override - String get do_you_want_to_open_the_following_link => - 'آیا می‌خواهید لینک زیر را باز کنید؟'; - - @override - String get unsafe_url_warning => - 'باز کردن لینک از منابع نامطمئن می‌تواند ناامن باشد. مراقب باشید!\nهمچنین می‌توانید لینک را در کلیپ‌بورد خود کپی کنید.'; - - @override - String get copy_link => 'کپی لینک'; - - @override - String get building_your_timeline => - 'در حال ساخت جدول زمانی بر اساس شنیده‌هایتان…'; - - @override - String get official => 'رسمی'; - - @override - String author_name(Object author) { - return 'نویسنده: $author'; - } - - @override - String get third_party => 'سوم‌شخص'; - - @override - String get plugin_requires_authentication => 'افزونه نیاز به احراز هویت دارد'; - - @override - String get update_available => 'به‌روزرسانی در دسترس است'; - - @override - String get supports_scrobbling => 'پشتیبانی از اسکراب‌بلینگ'; - - @override - String get plugin_scrobbling_info => - 'این افزونه موسیقی شما را اسکراب می‌کند تا تاریخچهٔ شنیداری‌تان را تولید کند.'; - - @override - String get default_metadata_source => 'منبع پیش‌فرض فراداده'; - - @override - String get set_default_metadata_source => 'تنظیم منبع پیش‌فرض فراداده'; - - @override - String get default_audio_source => 'منبع پیش‌فرض صوت'; - - @override - String get set_default_audio_source => 'تنظیم منبع پیش‌فرض صوت'; - - @override - String get set_default => 'تنظیم به عنوان پیش‌فرض'; - - @override - String get support => 'پشتیبانی'; - - @override - String get support_plugin_development => 'حمایت از توسعهٔ افزونه'; - - @override - String can_access_name_api(Object name) { - return '- می‌تواند به API **$name** دسترسی پیدا کند'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'می‌خواهید این افزونه را نصب کنید؟'; - - @override - String get third_party_plugin_warning => - 'این افزونه از مخزن شخص ثالث آمده است. لطفاً قبل از نصب از منابع آن مطمئن شوید.'; - - @override - String get author => 'نویسنده'; - - @override - String get this_plugin_can_do_following => - 'این افزونه می‌تواند موارد زیر را انجام دهد'; - - @override - String get install => 'نصب'; - - @override - String get install_a_metadata_provider => 'نصب یک ارائه‌دهندهٔ متادیتا'; - - @override - String get no_tracks_playing => 'در حال‌ حاضر هیچ تراکی در حال پخش نیست'; - - @override - String get synced_lyrics_not_available => - 'متن هم‌زمان‌شده برای این آهنگ در دسترس نیست. لطفاً از'; - - @override - String get plain_lyrics => 'متن ساده'; - - @override - String get tab_instead => 'به‌جای آن از کلید Tab استفاده کنید.'; - - @override - String get disclaimer => 'سلب مسئولیت'; - - @override - String get third_party_plugin_dmca_notice => - 'تیم Spotube هیچ مسئولیتی (حتی قانونی) در قبال افزونه‌های \"شخص ثالث\" ندارد. از آن‌ها به‌خاطر خود استفاده کنید. برای خطاها/مشکلات، لطفاً در مخزن افزونه گزارش دهید.\n\nاگر هر افزونهٔ \"شخص ثالث\" قوانین ToS/DMCA سرویس یا نهاد قانونی را نقض کند، لطفاً از نویسندهٔ افزونه یا پلتفرم میزبانی (مثل GitHub/Codeberg) درخواست اقدام کنید. افزونه‌هایی که با برچسب \"شخص ثالث\" مشخص شده‌اند، عمومی هستند و توسط جامعه نگهداری می‌شوند؛ ما آن‌ها را تغییر یا مدیریت نمی‌کنیم و نمی‌توانیم دخالت کنیم.\n\n'; - - @override - String get input_does_not_match_format => - 'ورودی با قالب مورد نیاز تطابق ندارد'; - - @override - String get plugins => 'افزونه‌ها'; - - @override - String get paste_plugin_download_url => - 'URL دانلود یا مخزن GitHub/Codeberg یا لینک مستقیم فایل .smplug را الصاق کنید'; - - @override - String get download_and_install_plugin_from_url => - 'دانلود و نصب افزونه از طریق لینک'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'افزونه اضافه نشد: $error'; - } - - @override - String get upload_plugin_from_file => 'بارگذاری افزونه از فایل'; - - @override - String get installed => 'نصب شد'; - - @override - String get available_plugins => 'افزونه‌های موجود'; - - @override - String get configure_plugins => - 'افزونه‌های منبع صوت و ارائه‌دهنده فراداده خود را پیکربندی کنید'; - - @override - String get audio_scrobblers => 'اسکراب‌بلرهای صوتی'; - - @override - String get scrobbling => 'اسکراب‌بلینگ'; - - @override - String get source => 'منبع: '; - - @override - String get uncompressed => 'بدون فشرده‌سازی'; - - @override - String get dab_music_source_description => - 'مخصوص علاقه‌مندان صدا. ارائه‌دهنده استریم‌های باکیفیت/بدون افت. تطبیق دقیق آهنگ بر اساس ISRC.'; -} diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart deleted file mode 100644 index 3f616849..00000000 --- a/lib/l10n/generated/app_localizations_fi.dart +++ /dev/null @@ -1,1564 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Finnish (`fi`). -class AppLocalizationsFi extends AppLocalizations { - AppLocalizationsFi([String locale = 'fi']) : super(locale); - - @override - String get guest => 'Vieras'; - - @override - String get browse => 'Selaa'; - - @override - String get search => 'Hae'; - - @override - String get library => 'Kirjasto'; - - @override - String get lyrics => 'Lyriikat'; - - @override - String get settings => 'Asetukset'; - - @override - String get genre_categories_filter => 'Suodata kategorioita tai genrejä'; - - @override - String get genre => 'Genre'; - - @override - String get personalized => 'Personoidut'; - - @override - String get featured => 'Esittelyssä'; - - @override - String get new_releases => 'Uusi julkaisu'; - - @override - String get songs => 'Laulut'; - - @override - String playing_track(Object track) { - return 'Soitetaan $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Tämä tulee tyhjentämään jonon. $track_length Kappaleita poistetaan\nHaluatko jatkaa?'; - } - - @override - String get load_more => 'Lataa lisää'; - - @override - String get playlists => 'Soittolistat'; - - @override - String get artists => 'Artistit'; - - @override - String get albums => 'Albumit'; - - @override - String get tracks => 'Kappaleet'; - - @override - String get downloads => 'Lataukset'; - - @override - String get filter_playlists => 'Suodata soittolistasi...'; - - @override - String get liked_tracks => 'Tykätyt kappaleet'; - - @override - String get liked_tracks_description => 'Kaikki tykättysi kappaleet'; - - @override - String get playlist => 'Soittolista'; - - @override - String get create_a_playlist => 'Luo soittolista'; - - @override - String get update_playlist => 'Päivitä soittolista'; - - @override - String get create => 'Luo'; - - @override - String get cancel => 'Peruuta'; - - @override - String get update => 'Päivitä'; - - @override - String get playlist_name => 'Soittolistan nimi'; - - @override - String get name_of_playlist => 'Soittolistan nimi'; - - @override - String get description => 'Kuvaus'; - - @override - String get public => 'Julkinen'; - - @override - String get collaborative => 'Collaborative'; - - @override - String get search_local_tracks => 'Hae paikallisia lauluja...'; - - @override - String get play => 'Soita'; - - @override - String get delete => 'Poista'; - - @override - String get none => 'Ei mitään'; - - @override - String get sort_a_z => 'Suodata A-Z'; - - @override - String get sort_z_a => 'Suodata Z-A'; - - @override - String get sort_artist => 'Suodata Artistilta'; - - @override - String get sort_album => 'Suodata Albumilta'; - - @override - String get sort_duration => 'Suodata Pituudelta'; - - @override - String get sort_tracks => 'Suodata Kappaleet'; - - @override - String currently_downloading(Object tracks_length) { - return 'Ladataan ($tracks_length)'; - } - - @override - String get cancel_all => 'Peru kaikki'; - - @override - String get filter_artist => 'Suodata artistit...'; - - @override - String followers(Object followers) { - return '$followers Seuraajaa'; - } - - @override - String get add_artist_to_blacklist => 'Lisää artisti mustalle listalle'; - - @override - String get top_tracks => 'Suosituimmat kappaleet'; - - @override - String get fans_also_like => 'Fanit myös tykkäsivät'; - - @override - String get loading => 'Ladataan...'; - - @override - String get artist => 'Artisti'; - - @override - String get blacklisted => 'Mustalistattu'; - - @override - String get following => 'Seurataan'; - - @override - String get follow => 'Seuraa'; - - @override - String get artist_url_copied => 'Aristin URL kopioitiin leikepöytään'; - - @override - String added_to_queue(Object tracks) { - return 'Lisättiin $tracks kappaletta jonoon'; - } - - @override - String get filter_albums => 'Suodata albumit...'; - - @override - String get synced => 'Synkronoitu'; - - @override - String get plain => 'Tavallinen'; - - @override - String get shuffle => 'Sekoita'; - - @override - String get search_tracks => 'Hae kappaleita...'; - - @override - String get released => 'Julkaistu'; - - @override - String error(Object error) { - return 'Virhe $error'; - } - - @override - String get title => 'Otsikko'; - - @override - String get time => 'Aika'; - - @override - String get more_actions => 'Lisää toimintoja'; - - @override - String download_count(Object count) { - return 'Lataa ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Lisää ($count) Soittolistaasi'; - } - - @override - String add_count_to_queue(Object count) { - return 'Lisää ($count) Jonoon'; - } - - @override - String play_count_next(Object count) { - return 'Soita ($count) seuraavaksi'; - } - - @override - String get album => 'Albumi'; - - @override - String copied_to_clipboard(Object data) { - return 'Kopioitiin $data leikepöytään'; - } - - @override - String add_to_following_playlists(Object track) { - return 'Lisää $track seuraaviin soittolistoihin'; - } - - @override - String get add => 'Lisää'; - - @override - String added_track_to_queue(Object track) { - return 'Lisättiin $track jonoon'; - } - - @override - String get add_to_queue => 'Lisää jonoon'; - - @override - String track_will_play_next(Object track) { - return '$track Soitetaan seuraavaksi'; - } - - @override - String get play_next => 'Soita seuraavaksi'; - - @override - String removed_track_from_queue(Object track) { - return 'Poistettiin $track jonosta'; - } - - @override - String get remove_from_queue => 'Poista jonosta'; - - @override - String get remove_from_favorites => 'Poista suosikeista'; - - @override - String get save_as_favorite => 'Tallenna soittolistana'; - - @override - String get add_to_playlist => 'Lisää soittolistaan'; - - @override - String get remove_from_playlist => 'Poista soittolistasta'; - - @override - String get add_to_blacklist => 'Lisää mustalle listalle'; - - @override - String get remove_from_blacklist => 'Poista mustalistalta'; - - @override - String get share => 'Jaa'; - - @override - String get mini_player => 'Minisoitin'; - - @override - String get slide_to_seek => 'Liu\'uta mennäkseen eteenpäin tai taaksepäin'; - - @override - String get shuffle_playlist => 'Sekoita soittolista'; - - @override - String get unshuffle_playlist => 'Poista sekoitus soittolistasta'; - - @override - String get previous_track => 'Äskeinen kappale'; - - @override - String get next_track => 'Seuraava kappale'; - - @override - String get pause_playback => 'Pysäytä soittolistan toisto'; - - @override - String get resume_playback => 'Jatka soittolistan toistoa'; - - @override - String get loop_track => 'Uudelleentoista kappale'; - - @override - String get no_loop => 'Ei silmukkaa'; - - @override - String get repeat_playlist => 'Toista soittolista uudelleen'; - - @override - String get queue => 'Jono'; - - @override - String get alternative_track_sources => 'Toinen kappale lähde'; - - @override - String get download_track => 'Lataa kappale'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks kappaletta jonossa'; - } - - @override - String get clear_all => 'Tyhjennä kaikki'; - - @override - String get show_hide_ui_on_hover => 'Näytä/Piilota UI leijumalla'; - - @override - String get always_on_top => 'Aina päällimmäisenä'; - - @override - String get exit_mini_player => 'Lähde minisoittimesta'; - - @override - String get download_location => 'Lataus sijainti'; - - @override - String get local_library => 'Paikallinen kirjasto'; - - @override - String get add_library_location => 'Lisää kirjastoon'; - - @override - String get remove_library_location => 'Poista kirjastosta'; - - @override - String get account => 'Käyttäjä'; - - @override - String get logout => 'Kirjaudu ulos'; - - @override - String get logout_of_this_account => 'Kirjaudu ulos tältä käyttäjältä'; - - @override - String get language_region => 'Kieli ja Maa'; - - @override - String get language => 'Kieli'; - - @override - String get system_default => 'Järjestelmän oletus'; - - @override - String get market_place_region => 'Markkina-alue'; - - @override - String get recommendation_country => 'Suositeltu maa'; - - @override - String get appearance => 'Ulkomuto'; - - @override - String get layout_mode => 'Asettelutila'; - - @override - String get override_layout_settings => - 'Jätä reagoiva asettelutila huomioimatta'; - - @override - String get adaptive => 'Mukautuva'; - - @override - String get compact => 'Kompakti'; - - @override - String get extended => 'Laajennettu'; - - @override - String get theme => 'Teema'; - - @override - String get dark => 'Tumma'; - - @override - String get light => 'Vaalea'; - - @override - String get system => 'Järjestelmä'; - - @override - String get accent_color => 'Korostusväri'; - - @override - String get sync_album_color => 'Synkronoi albumin väri'; - - @override - String get sync_album_color_description => - 'Käyttää albumin kansitaiteen vallitsevaa väirä korostuvärinä'; - - @override - String get playback => 'Toisto'; - - @override - String get audio_quality => 'Äänenlaatu'; - - @override - String get high => 'Korkea'; - - @override - String get low => 'Matala'; - - @override - String get pre_download_play => 'Esilataa ja soita'; - - @override - String get pre_download_play_description => - 'Audion suoratoiston sijaan, lataa tavut ja soita ne (Suositeltu korkeamman kaistanleveyden käyttäjille)'; - - @override - String get skip_non_music => 'Ohita ei-musiikki kohdat (SponsorBlock)'; - - @override - String get blacklist_description => 'Mustalistat kappaleet aja artistit'; - - @override - String get wait_for_download_to_finish => - 'Odota nykyisen latauksen lopetteluun'; - - @override - String get desktop => 'Työpöytä'; - - @override - String get close_behavior => 'Sulkemisen käyttäytyminen'; - - @override - String get close => 'Sulje'; - - @override - String get minimize_to_tray => 'Minimisoi tehtäväpalkkiin'; - - @override - String get show_tray_icon => 'Näytä järjestelmäkuvake'; - - @override - String get about => 'Tietoa'; - - @override - String get u_love_spotube => 'Tiedämme että rakastat Spotubea'; - - @override - String get check_for_updates => 'Tarkista päivitykset'; - - @override - String get about_spotube => 'Tietoa Spotube:sta'; - - @override - String get blacklist => 'Mustalista'; - - @override - String get please_sponsor => 'Sponsoroi/Lahjoita, kiitos'; - - @override - String get spotube_description => - 'Spotube, kevyt, cross-platform, vapaa-kaikille spotify clientti'; - - @override - String get version => 'Versio'; - - @override - String get build_number => 'Rakennusnumero'; - - @override - String get founder => 'Perustaja'; - - @override - String get repository => 'Arkisto'; - - @override - String get bug_issues => 'Bugit+Ongelmat'; - - @override - String get made_with => 'Tehty ❤️ Bangladeshista 🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Lisenssi'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Älä huoli, tunnuksiasi ei talleteta tai jaeta kenenkään kanssa'; - - @override - String get know_how_to_login => 'Etkö tiedä miten tehdä tämä?'; - - @override - String get follow_step_by_step_guide => 'Seuraa askel askeleelta opasta'; - - @override - String cookie_name_cookie(Object name) { - return '$name Keksi'; - } - - @override - String get fill_in_all_fields => 'Täytä kaikki kentät'; - - @override - String get submit => 'Lähetä'; - - @override - String get exit => 'Poistu'; - - @override - String get previous => 'Edellinen'; - - @override - String get next => 'Seuraava'; - - @override - String get done => 'Tehty'; - - @override - String get step_1 => 'Vaihe 1'; - - @override - String get first_go_to => 'Ensiksi, mene'; - - @override - String get something_went_wrong => 'Jotain meni pieleen'; - - @override - String get piped_instance => 'Johdettu palvelinesiintymä'; - - @override - String get piped_description => - 'Johdettu palvelinesiintymä Kappale täsmäyksiin'; - - @override - String get piped_warning => - 'Jotkut niistä eivät toimi hyvin, käytä siis omalla vastuullasi'; - - @override - String get invidious_instance => 'Invidious-palvelinesiintymä'; - - @override - String get invidious_description => - 'Invidious-palvelinesiintymä raitojen yhteensovittamiseen'; - - @override - String get invidious_warning => - 'Jotkin esiintymät eivät välttämättä toimi hyvin. Käytä omalla vastuullasi'; - - @override - String get generate => 'Luo'; - - @override - String track_exists(Object track) { - return 'Kappale $track on jo olemassa!'; - } - - @override - String get replace_downloaded_tracks => 'Korvaa kaikki ladatut kappaleet'; - - @override - String get skip_download_tracks => 'Ohita ladattujen laulujen lataaminen'; - - @override - String get do_you_want_to_replace => - 'Haluatko korvata olemassa olevan kappaleen??'; - - @override - String get replace => 'Korvaa'; - - @override - String get skip => 'Ohita'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Valitse enintään $count $type'; - } - - @override - String get select_genres => 'Valitse Genret'; - - @override - String get add_genres => 'Lisää Genrejä'; - - @override - String get country => 'Maa'; - - @override - String get number_of_tracks_generate => 'Numero tuotettavia kappaleita'; - - @override - String get acousticness => 'Akustisuus'; - - @override - String get danceability => 'Tanssittavuus'; - - @override - String get energy => 'Energia'; - - @override - String get instrumentalness => 'Instrumentaalisuus'; - - @override - String get liveness => 'Elävyyttä'; - - @override - String get loudness => 'Äänekkyys'; - - @override - String get speechiness => 'Puheisuus'; - - @override - String get valence => 'Valenssi'; - - @override - String get popularity => 'Suosio'; - - @override - String get key => 'Sävellaji'; - - @override - String get duration => 'Pituus (s)'; - - @override - String get tempo => 'Tempo (BPM)'; - - @override - String get mode => 'Tila'; - - @override - String get time_signature => 'Aikamerkki'; - - @override - String get short => 'Lyhyt'; - - @override - String get medium => 'Keskikokoinen'; - - @override - String get long => 'Pitkä'; - - @override - String get min => 'Minimi'; - - @override - String get max => 'Maximi'; - - @override - String get target => 'Kohde'; - - @override - String get moderate => 'Kohtalainen'; - - @override - String get deselect_all => 'Poista kaikki valinnat'; - - @override - String get select_all => 'Valitse kaikki'; - - @override - String get are_you_sure => 'Oletko varma?'; - - @override - String get generating_playlist => 'Luodaan mukautettua soittolistoa...'; - - @override - String selected_count_tracks(Object count) { - return 'Valittu $count kappaletta'; - } - - @override - String get download_warning => - 'Jos lataat kaikki laulut kerrällä olet selkeästi Piratoimassa ja aiheuttamassa vahinkoa musiikin luovaan yhteiskuntaan. Toivottavasti olet tietoinen tästä. Yritä aina kunnioittaa ja tukea Artistin kovaa työtä.'; - - @override - String get download_ip_ban_warning => - 'BTW, YouTube voi estää IP-Osoitteesi tavallista liiallisten latauspyyntöjen takia. IP-Osoitteen esto tarkoittaa sitä, ettet voi käyttää YouTubea (vaikka olisit kirjautunut) vähintään 2-3kk aikana kyseiseltä laitteelta. Spotube ei kanna yhtään vastuuta jos se tapahtuu.'; - - @override - String get by_clicking_accept_terms => - 'Painamalla \'hyväksy\' hyväksyt seuraaviin ehtoihin:'; - - @override - String get download_agreement_1 => - 'Tiedän että Piratoin musiikkia. Olen paha.'; - - @override - String get download_agreement_2 => - 'Tuen Artisteja silloin kun pystyn, ja teen tämän vain koska minulla ei ole rahaa ostaa heidän taidetta'; - - @override - String get download_agreement_3 => - 'Ymmärrän että minun YouTube voi estää IP-Osoitteeni ja en pidä Spotubea tai omistajiinsa/avustajia vastuullisena mistään omista teoistsani'; - - @override - String get decline => 'Hylkää'; - - @override - String get accept => 'Hyväksy'; - - @override - String get details => 'Yksityiskohdat'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Kanava'; - - @override - String get likes => 'Tykkäykset'; - - @override - String get dislikes => 'Epä-tykkäykset'; - - @override - String get views => 'Näyttökerrat'; - - @override - String get streamUrl => 'Suoratoiston URL'; - - @override - String get stop => 'Lopeta'; - - @override - String get sort_newest => 'Suodata uusimmista'; - - @override - String get sort_oldest => 'Suodata vanhimmista'; - - @override - String get sleep_timer => 'Uniajastin'; - - @override - String mins(Object minutes) { - return '$minutes Minuuttia'; - } - - @override - String hours(Object hours) { - return '$hours Tuntia'; - } - - @override - String hour(Object hours) { - return '$hours Tunti'; - } - - @override - String get custom_hours => 'Mukautetut tunnit'; - - @override - String get logs => 'Lokit'; - - @override - String get developers => 'Kehittäjät'; - - @override - String get not_logged_in => 'Et ole kirjautunut sisään.'; - - @override - String get search_mode => 'Hakutila'; - - @override - String get audio_source => 'Äänilähde'; - - @override - String get ok => 'Ok'; - - @override - String get failed_to_encrypt => 'Salaaminen epäonnistui'; - - @override - String get encryption_failed_warning => - 'Spotube käyttää salausta tallentaakseen tietosi, mutta epäonnistui, joten se palaa epäturvalliseen tallennukseen\nJos käytät Linuxia, varmista että sinulla on turvallisuuspalvelu (gnome-keyring, kde-wallet, keepassxc jne) asennettu'; - - @override - String get querying_info => 'Hankitaan tietoa...'; - - @override - String get piped_api_down => 'Johdettu palvelinesiintymä on alhaalla'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'Johdettu palvelinesiintymä $pipedInstance on alhaalla.\n\nVaihda joko ilmeytymä tia vahda \'API tyyppi\' YouTuben viralliseen API\n\nKäynnistä sovellus uudestaan vaihdon jälkeen'; - } - - @override - String get you_are_offline => 'Et ole yhdistetty verkkoon'; - - @override - String get connection_restored => 'Verkkoyhteys palautettu'; - - @override - String get use_system_title_bar => 'Käytä järjestelmäpalkkia'; - - @override - String get crunching_results => 'Paloitellaan tuloksia...'; - - @override - String get search_to_get_results => 'Hae saadakseen tuloksia'; - - @override - String get use_amoled_mode => 'Pilkkopimeä tumma teema'; - - @override - String get pitch_dark_theme => 'AMOLED Tila'; - - @override - String get normalize_audio => 'Normalisoi audio'; - - @override - String get change_cover => 'Vaihda koveri'; - - @override - String get add_cover => 'Lisää koveri'; - - @override - String get restore_defaults => 'Palauta oletukset'; - - @override - String get download_music_format => 'Musiikin latausmuoto'; - - @override - String get streaming_music_format => 'Musiikin suoratoistomuoto'; - - @override - String get download_music_quality => 'Musiikin latauslaatu'; - - @override - String get streaming_music_quality => 'Musiikin suoratoistolaadun'; - - @override - String get login_with_lastfm => 'Kirjaudu sisään Last.fm:llä'; - - @override - String get connect => 'Yhdistä'; - - @override - String get disconnect_lastfm => 'Katkaise Last.fm'; - - @override - String get disconnect => 'Katkaise'; - - @override - String get username => 'Käyttäjänimi'; - - @override - String get password => 'Salasana'; - - @override - String get login => 'Kirjaudu'; - - @override - String get login_with_your_lastfm => 'Kirjaudu Last.fm käyttäjälläsi'; - - @override - String get scrobble_to_lastfm => 'Scrobble Last.fm:ään'; - - @override - String get go_to_album => 'Mene albumiin'; - - @override - String get discord_rich_presence => 'Discord Rich Presence'; - - @override - String get browse_all => 'Selaa kaikki'; - - @override - String get genres => 'Genret'; - - @override - String get explore_genres => 'Seikkaile genrejä'; - - @override - String get friends => 'Kaverit'; - - @override - String get no_lyrics_available => - 'Anteeksi, emme löytäneet lyriikoita tälle laululle'; - - @override - String get start_a_radio => 'Aloita Radio'; - - @override - String get how_to_start_radio => 'Kuinka haluat aloittaa radion?'; - - @override - String get replace_queue_question => - 'Haluatko korvata nykyisen jonon vai lisätä siihen?'; - - @override - String get endless_playback => 'Loputon toisto'; - - @override - String get delete_playlist => 'Poista soittolista'; - - @override - String get delete_playlist_confirmation => - 'Oletko varma että haluat poistaa tämän soittolistan?'; - - @override - String get local_tracks => 'Paikalliset kappaleet'; - - @override - String get local_tab => 'Paikallinen'; - - @override - String get song_link => 'Laulun linkki'; - - @override - String get skip_this_nonsense => 'Ohita tämä hölynpöly'; - - @override - String get freedom_of_music => '“Musiikin vapaus”'; - - @override - String get freedom_of_music_palm => '“Musiikin vapaus käsissäsi”'; - - @override - String get get_started => 'Aloitetaan'; - - @override - String get youtube_source_description => 'Suositeltu ja toimii parhaiten.'; - - @override - String get piped_source_description => - 'Tuntuuko vapaalta? Sama kuin YouTube mutta paljon vapautta'; - - @override - String get jiosaavn_source_description => 'Paras Etelä-Aasian alueelle.'; - - @override - String get invidious_source_description => - 'Samankaltainen kuin Piped, mutta korkeammalla saatavuudella'; - - @override - String highest_quality(Object quality) { - return 'Korkein laatu: $quality'; - } - - @override - String get select_audio_source => 'Valitse äänilähde'; - - @override - String get endless_playback_description => - 'Lisää automaattisesti uusia lauluja\njonon perään'; - - @override - String get choose_your_region => 'Valitse alueesi'; - - @override - String get choose_your_region_description => - 'Tämä auttaa Spotube näyttämään sinulle oikeaa sisältöä\nsijaintiasi varten.'; - - @override - String get choose_your_language => 'Valitse kielesi'; - - @override - String get help_project_grow => 'Auta tätä projektia kasvamaan'; - - @override - String get help_project_grow_description => - 'Spotube projekti minkä lähdekoodi on julkisesti saatavilla. Voit autta tätä projektia kasvamaan muutoksilla, ilmoittamalla bugeista, tai ehdottamalla uusia ominaisuuksia.'; - - @override - String get contribute_on_github => 'Auta GitHub:ssa'; - - @override - String get donate_on_open_collective => 'Lahjoita avoimessa kollektiivissa'; - - @override - String get browse_anonymously => 'Selaa anonyyminä'; - - @override - String get enable_connect => 'Ota käyttöön yhdistäminen'; - - @override - String get enable_connect_description => 'Ohjaa Spotubea toiselta laitteelta'; - - @override - String get devices => 'Laitteet'; - - @override - String get select => 'Valitse'; - - @override - String connect_client_alert(Object client) { - return '$client ohjaa sinua'; - } - - @override - String get this_device => 'Tämä laite'; - - @override - String get remote => 'Etä'; - - @override - String get stats => 'Tilastot'; - - @override - String and_n_more(Object count) { - return 'ja $count lisää'; - } - - @override - String get recently_played => 'Äskettäin soitetut'; - - @override - String get browse_more => 'Selaa lisää'; - - @override - String get no_title => 'Ei otsikkoa'; - - @override - String get not_playing => 'Ei soi'; - - @override - String get epic_failure => 'Epäonnistuminen!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'Lisätty $tracks_length kappaletta jonoon'; - } - - @override - String get spotube_has_an_update => 'Spotubella on päivitys'; - - @override - String get download_now => 'Lataa nyt'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum on julkaistu'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version on julkaistu'; - } - - @override - String get read_the_latest => 'Lue viimeisimmät'; - - @override - String get release_notes => 'julkaisumuistiinpanot'; - - @override - String get pick_color_scheme => 'Valitse värimaailma'; - - @override - String get save => 'Tallenna'; - - @override - String get choose_the_device => 'Valitse laite:'; - - @override - String get multiple_device_connected => - 'Useita laitteita on kytketty.\nValitse laite, jossa haluat toiminnon suorittaa'; - - @override - String get nothing_found => 'Ei tuloksia'; - - @override - String get the_box_is_empty => 'Laatikko on tyhjä'; - - @override - String get top_artists => 'Suosituimmat artistit'; - - @override - String get top_albums => 'Suosituimmat albumit'; - - @override - String get this_week => 'Tällä viikolla'; - - @override - String get this_month => 'Tässä kuussa'; - - @override - String get last_6_months => 'Viimeiset 6 kuukautta'; - - @override - String get this_year => 'Tänä vuonna'; - - @override - String get last_2_years => 'Viimeiset 2 vuotta'; - - @override - String get all_time => 'Kaikki ajat'; - - @override - String powered_by_provider(Object providerName) { - return 'Tuottanut $providerName'; - } - - @override - String get email => 'Sähköposti'; - - @override - String get profile_followers => 'Seuraajat'; - - @override - String get birthday => 'Syntymäpäivä'; - - @override - String get subscription => 'Tilaus'; - - @override - String get not_born => 'Ei syntynyt'; - - @override - String get hacker => 'Hakkeri'; - - @override - String get profile => 'Profiili'; - - @override - String get no_name => 'Ei nimeä'; - - @override - String get edit => 'Muokkaa'; - - @override - String get user_profile => 'Käyttäjäprofiili'; - - @override - String count_plays(Object count) { - return '$count toistoa'; - } - - @override - String get streaming_fees_hypothetical => - 'Suoratoiston maksut (hypoteettinen)'; - - @override - String get minutes_listened => 'Kuunneltuja minuutteja'; - - @override - String get streamed_songs => 'Suoratoistettuja kappaleita'; - - @override - String count_streams(Object count) { - return '$count suoratoistoa'; - } - - @override - String get owned_by_you => 'Sinun omistama'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl kopioitu leikepöydälle'; - } - - @override - String get hipotetical_calculation => - '*Tämä on laskettu keskimääräisen musiikin suoratoistopalvelun 0,003–0,005 dollarin kappalekohtaisen maksun perusteella. Tämä on hypoteettinen laskelma, joka antaa käyttäjälle käsityksen siitä, kuinka paljon he olisivat maksaneet artisteille, jos he kuuntelisivat heidän kappaleitaan eri musiikin suoratoistopalveluissa.'; - - @override - String count_mins(Object minutes) { - return '$minutes min'; - } - - @override - String get summary_minutes => 'minuuttia'; - - @override - String get summary_listened_to_music => 'Kuunneltu musiikkia'; - - @override - String get summary_songs => 'kappaletta'; - - @override - String get summary_streamed_overall => 'Suoratoistettu yhteensä'; - - @override - String get summary_owed_to_artists => 'Maksettava artisteille\nTässä kuussa'; - - @override - String get summary_artists => 'artisti'; - - @override - String get summary_music_reached_you => 'Musiikki saavutti sinut'; - - @override - String get summary_full_albums => 'täydet albumit'; - - @override - String get summary_got_your_love => 'Sai rakkautesi'; - - @override - String get summary_playlists => 'soittolistat'; - - @override - String get summary_were_on_repeat => 'Olivat toistossa'; - - @override - String total_money(Object money) { - return 'Yhteensä $money'; - } - - @override - String get webview_not_found => 'Webview ei löydy'; - - @override - String get webview_not_found_description => - 'Laitteellasi ei ole asennettua Webview-ajonaikaa.\nJos se on asennettu, varmista, että se on environment PATH:ssa\n\nAsennuksen jälkeen käynnistä sovellus uudelleen'; - - @override - String get unsupported_platform => 'Ei tuettu alusta'; - - @override - String get cache_music => 'Musiikki välimuistissa'; - - @override - String get open => 'Avaa'; - - @override - String get cache_folder => 'Välimuistikansio'; - - @override - String get export => 'Vie'; - - @override - String get clear_cache => 'Tyhjennä välimuisti'; - - @override - String get clear_cache_confirmation => 'Haluatko tyhjentää välimuistin?'; - - @override - String get export_cache_files => 'Vie välimuistitiedostot'; - - @override - String found_n_files(Object count) { - return 'Löydettiin $count tiedostoa'; - } - - @override - String get export_cache_confirmation => 'Haluatko viedä nämä tiedostot'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return 'Vietiin $filesExported/$files tiedostoa'; - } - - @override - String get undo => 'Peruuta'; - - @override - String get download_all => 'Lataa kaikki'; - - @override - String get add_all_to_playlist => 'Lisää kaikki soittolistalle'; - - @override - String get add_all_to_queue => 'Lisää kaikki jonoon'; - - @override - String get play_all_next => 'Toista kaikki seuraavaksi'; - - @override - String get pause => 'Pysäytä'; - - @override - String get view_all => 'Näytä kaikki'; - - @override - String get no_tracks_added_yet => - 'Näyttää siltä, että et ole lisännyt vielä mitään kappaleita.'; - - @override - String get no_tracks => 'Näyttää siltä, että täällä ei ole kappaleita.'; - - @override - String get no_tracks_listened_yet => - 'Näyttää siltä, että et ole kuunnellut mitään vielä.'; - - @override - String get not_following_artists => 'Et seuraa yhtään artistia.'; - - @override - String get no_favorite_albums_yet => - 'Näyttää siltä, että et ole lisännyt yhtään albumia suosikkeihisi.'; - - @override - String get no_logs_found => 'Ei lokitietoja löydetty'; - - @override - String get youtube_engine => 'YouTube-moottori'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine ei ole asennettu'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine ei ole asennettu järjestelmääsi.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Varmista, että se on saatavilla PATH-muuttujassa tai\nasetetaan $engine suoritettavan tiedoston absoluuttinen polku alla.'; - } - - @override - String get youtube_engine_unix_issue_message => - 'macOS/Linux/unix-tyyppisissä käyttöjärjestelmissä polun asettaminen .zshrc/.bashrc/.bash_profile jne. ei toimi.\nSinun täytyy asettaa polku shellin asetustiedostoon.'; - - @override - String get download => 'Lataa'; - - @override - String get file_not_found => 'Tiedostoa ei löydy'; - - @override - String get custom => 'Mukautettu'; - - @override - String get add_custom_url => 'Lisää mukautettu URL'; - - @override - String get edit_port => 'Muokkaa porttia'; - - @override - String get port_helper_msg => - 'Oletusarvo on -1, mikä tarkoittaa satunnaista numeroa. Jos sinulla on palomuuri määritetty, tämän asettamista suositellaan.'; - - @override - String connect_request(Object client) { - return 'Salli $client yhdistää?'; - } - - @override - String get connection_request_denied => - 'Yhteys evätty. Käyttäjä eväsi pääsyn.'; - - @override - String get an_error_occurred => 'Tapahtui virhe'; - - @override - String get copy_to_clipboard => 'Kopioi leikepöydälle'; - - @override - String get view_logs => 'Näytä lokit'; - - @override - String get retry => 'Yritä uudelleen'; - - @override - String get no_default_metadata_provider_selected => - 'Et ole asettanut oletusmetatietojen tarjoajaa'; - - @override - String get manage_metadata_providers => 'Hallinnoi metatietojen tarjoajia'; - - @override - String get open_link_in_browser => 'Avaa linkki selaimessa?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Haluatko avata seuraavan linkin'; - - @override - String get unsafe_url_warning => - 'Linkkien avaaminen epäluotettavista lähteistä voi olla vaarallista. Ole varovainen!\nVoit myös kopioida linkin leikepöydälle.'; - - @override - String get copy_link => 'Kopioi linkki'; - - @override - String get building_your_timeline => - 'Rakennetaan aikajanaasi kuuntelujesi perusteella...'; - - @override - String get official => 'Virallinen'; - - @override - String author_name(Object author) { - return 'Tekijä: $author'; - } - - @override - String get third_party => 'Kolmannen osapuolen'; - - @override - String get plugin_requires_authentication => 'Lisäosa vaatii todentamisen'; - - @override - String get update_available => 'Päivitys saatavilla'; - - @override - String get supports_scrobbling => 'Tukee scrobblingia'; - - @override - String get plugin_scrobbling_info => - 'Tämä lisäosa scrobblaa musiikkisi luodakseen kuunteluhistoriasi.'; - - @override - String get default_metadata_source => 'Oletusarvoinen metatietolähde'; - - @override - String get set_default_metadata_source => 'Aseta oletusmetatietolähde'; - - @override - String get default_audio_source => 'Oletusarvoinen äänilähde'; - - @override - String get set_default_audio_source => 'Aseta oletusäänilähde'; - - @override - String get set_default => 'Aseta oletukseksi'; - - @override - String get support => 'Tuki'; - - @override - String get support_plugin_development => 'Tue lisäosan kehitystä'; - - @override - String can_access_name_api(Object name) { - return '- Voi käyttää **$name** APIa'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Haluatko asentaa tämän lisäosan?'; - - @override - String get third_party_plugin_warning => - 'Tämä lisäosa on kolmannen osapuolen arkistosta. Varmista, että luotat lähteeseen ennen asennusta.'; - - @override - String get author => 'Tekijä'; - - @override - String get this_plugin_can_do_following => 'Tämä lisäosa voi tehdä seuraavaa'; - - @override - String get install => 'Asenna'; - - @override - String get install_a_metadata_provider => 'Asenna metatietojen tarjoaja'; - - @override - String get no_tracks_playing => 'Ei kappaletta toistossa tällä hetkellä'; - - @override - String get synced_lyrics_not_available => - 'Synkronoidut sanoitukset eivät ole saatavilla tälle kappaleelle. Käytä sen sijaan'; - - @override - String get plain_lyrics => 'Pelkät sanoitukset'; - - @override - String get tab_instead => 'välilehteä.'; - - @override - String get disclaimer => 'Vastuuvapauslauseke'; - - @override - String get third_party_plugin_dmca_notice => - 'Spotube-tiimi ei ota mitään vastuuta (mukaan lukien oikeudellinen) mistään \"kolmannen osapuolen\" lisäosista.\nKäytä niitä omalla vastuullasi. Ilmoita kaikista virheistä/ongelmista lisäosan arkistoon.\n\nJos jokin \"kolmannen osapuolen\" lisäosa rikkoo jonkin palvelun/oikeushenkilön käyttöehtoja/DMCA:ta, pyydä \"kolmannen osapuolen\" lisäosan tekijää tai isännöintialustaa, esim. GitHubia/Codebergiä, ryhtymään toimiin. Yllä luetellut (\"kolmannen osapuolen\" merkityt) ovat kaikki julkisia/yhteisön ylläpitämiä lisäosia. Emme kuratoi niitä, joten emme voi ryhtyä niihin toimiin.\n\n'; - - @override - String get input_does_not_match_format => 'Syöte ei vastaa vaadittua muotoa'; - - @override - String get plugins => 'Laajennukset'; - - @override - String get paste_plugin_download_url => - 'Liitä lataus-URL-osoite tai GitHub/Codeberg-arkiston URL-osoite tai suora linkki .smplug-tiedostoon'; - - @override - String get download_and_install_plugin_from_url => - 'Lataa ja asenna lisäosa URL-osoitteesta'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Lisäosan lisääminen epäonnistui: $error'; - } - - @override - String get upload_plugin_from_file => 'Lataa lisäosa tiedostosta'; - - @override - String get installed => 'Asennettu'; - - @override - String get available_plugins => 'Saatavilla olevat lisäosat'; - - @override - String get configure_plugins => - 'Määritä omat metatietojen tarjoaja- ja äänilähdelaajennukset'; - - @override - String get audio_scrobblers => 'Äänen scrobblerit'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Lähde: '; - - @override - String get uncompressed => 'Pakkaamaton'; - - @override - String get dab_music_source_description => - 'Audiofiileille. Tarjoaa korkealaatuisia/häviöttömiä äänivirtoja. Tarkka ISRC-pohjainen kappaleiden tunnistus.'; -} diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart deleted file mode 100644 index 3637391b..00000000 --- a/lib/l10n/generated/app_localizations_fr.dart +++ /dev/null @@ -1,1584 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for French (`fr`). -class AppLocalizationsFr extends AppLocalizations { - AppLocalizationsFr([String locale = 'fr']) : super(locale); - - @override - String get guest => 'Invité'; - - @override - String get browse => 'Explorer'; - - @override - String get search => 'Rechercher'; - - @override - String get library => 'Bibliothèque'; - - @override - String get lyrics => 'Paroles'; - - @override - String get settings => 'Paramètres'; - - @override - String get genre_categories_filter => - 'Filtrer les catégories ou les genres...'; - - @override - String get genre => 'Genre'; - - @override - String get personalized => 'Personnalisé'; - - @override - String get featured => 'En vedette'; - - @override - String get new_releases => 'Nouvelles sorties'; - - @override - String get songs => 'Chansons'; - - @override - String playing_track(Object track) { - return 'Lecture de $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Cela effacera la file d\'attente actuelle. $track_length pistes seront supprimées\nVoulez-vous continuer?'; - } - - @override - String get load_more => 'Charger plus'; - - @override - String get playlists => 'Listes de lecture'; - - @override - String get artists => 'Artistes'; - - @override - String get albums => 'Albums'; - - @override - String get tracks => 'Pistes'; - - @override - String get downloads => 'Téléchargements'; - - @override - String get filter_playlists => 'Filtrer vos listes de lecture...'; - - @override - String get liked_tracks => 'Pistes aimées'; - - @override - String get liked_tracks_description => 'Toutes vos pistes aimées'; - - @override - String get playlist => 'Playlist'; - - @override - String get create_a_playlist => 'Créer une liste de lecture'; - - @override - String get update_playlist => 'Mettre à jour la playlist'; - - @override - String get create => 'Créer'; - - @override - String get cancel => 'Annuler'; - - @override - String get update => 'Mettre à jour'; - - @override - String get playlist_name => 'Nom de la liste de lecture'; - - @override - String get name_of_playlist => 'Nom de la liste de lecture'; - - @override - String get description => 'Description'; - - @override - String get public => 'Public'; - - @override - String get collaborative => 'Collaborative'; - - @override - String get search_local_tracks => 'Rechercher des pistes locales...'; - - @override - String get play => 'Lecture'; - - @override - String get delete => 'Supprimer'; - - @override - String get none => 'Aucun'; - - @override - String get sort_a_z => 'Trier par ordre alphabétique'; - - @override - String get sort_z_a => 'Trier par ordre alphabétique inverse'; - - @override - String get sort_artist => 'Trier par artiste'; - - @override - String get sort_album => 'Trier par album'; - - @override - String get sort_duration => 'Trier par durée'; - - @override - String get sort_tracks => 'Trier les pistes'; - - @override - String currently_downloading(Object tracks_length) { - return 'Téléchargement en cours ($tracks_length)'; - } - - @override - String get cancel_all => 'Tout annuler'; - - @override - String get filter_artist => 'Filtrer les artistes...'; - - @override - String followers(Object followers) { - return '$followers abonnés'; - } - - @override - String get add_artist_to_blacklist => 'Ajouter l\'artiste à la liste noire'; - - @override - String get top_tracks => 'Meilleures pistes'; - - @override - String get fans_also_like => 'Les fans aiment aussi'; - - @override - String get loading => 'Chargement...'; - - @override - String get artist => 'Artiste'; - - @override - String get blacklisted => 'Liste noire'; - - @override - String get following => 'Abonné'; - - @override - String get follow => 'S\'abonner'; - - @override - String get artist_url_copied => - 'URL de l\'artiste copiée dans le presse-papiers'; - - @override - String added_to_queue(Object tracks) { - return '$tracks pistes ajoutées à la file d\'attente'; - } - - @override - String get filter_albums => 'Filtrer les albums...'; - - @override - String get synced => 'Synchronisé'; - - @override - String get plain => 'Simple'; - - @override - String get shuffle => 'Lecture aléatoire'; - - @override - String get search_tracks => 'Rechercher des pistes...'; - - @override - String get released => 'Sorti'; - - @override - String error(Object error) { - return 'Erreur $error'; - } - - @override - String get title => 'Titre'; - - @override - String get time => 'Durée'; - - @override - String get more_actions => 'Plus d\'actions'; - - @override - String download_count(Object count) { - return 'Téléchargement ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Ajouter ($count) à la liste de lecture'; - } - - @override - String add_count_to_queue(Object count) { - return 'Ajouter ($count) à la file d\'attente'; - } - - @override - String play_count_next(Object count) { - return 'Lire ($count) ensuite'; - } - - @override - String get album => 'Album'; - - @override - String copied_to_clipboard(Object data) { - return '$data copié dans le presse-papiers'; - } - - @override - String add_to_following_playlists(Object track) { - return 'Ajouter $track aux listes de lecture suivantes'; - } - - @override - String get add => 'Ajouter'; - - @override - String added_track_to_queue(Object track) { - return '$track ajouté à la file d\'attente'; - } - - @override - String get add_to_queue => 'Ajouter à la file d\'attente'; - - @override - String track_will_play_next(Object track) { - return '$track sera joué ensuite'; - } - - @override - String get play_next => 'Lire ensuite'; - - @override - String removed_track_from_queue(Object track) { - return '$track retiré de la file d\'attente'; - } - - @override - String get remove_from_queue => 'Retirer de la file d\'attente'; - - @override - String get remove_from_favorites => 'Retirer des favoris'; - - @override - String get save_as_favorite => 'Enregistrer comme favori'; - - @override - String get add_to_playlist => 'Ajouter à la liste de lecture'; - - @override - String get remove_from_playlist => 'Retirer de la liste de lecture'; - - @override - String get add_to_blacklist => 'Ajouter à la liste noire'; - - @override - String get remove_from_blacklist => 'Retirer de la liste noire'; - - @override - String get share => 'Partager'; - - @override - String get mini_player => 'Lecteur mini'; - - @override - String get slide_to_seek => 'Faites glisser pour avancer ou reculer'; - - @override - String get shuffle_playlist => 'Lecture aléatoire de la liste de lecture'; - - @override - String get unshuffle_playlist => - 'Annuler la lecture aléatoire de la liste de lecture'; - - @override - String get previous_track => 'Piste précédente'; - - @override - String get next_track => 'Piste suivante'; - - @override - String get pause_playback => 'Mettre en pause la lecture'; - - @override - String get resume_playback => 'Reprendre la lecture'; - - @override - String get loop_track => 'Lecture en boucle de la piste'; - - @override - String get no_loop => 'Pas de boucle'; - - @override - String get repeat_playlist => 'Répéter la liste de lecture'; - - @override - String get queue => 'File d\'attente'; - - @override - String get alternative_track_sources => 'Sources alternatives de pistes'; - - @override - String get download_track => 'Télécharger la piste'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks pistes dans la file d\'attente'; - } - - @override - String get clear_all => 'Tout effacer'; - - @override - String get show_hide_ui_on_hover => - 'Afficher/Masquer l\'interface utilisateur au survol'; - - @override - String get always_on_top => 'Toujours au-dessus'; - - @override - String get exit_mini_player => 'Quitter le lecteur mini'; - - @override - String get download_location => 'Emplacement de téléchargement'; - - @override - String get local_library => 'Bibliothèque locale'; - - @override - String get add_library_location => 'Ajouter à la bibliothèque'; - - @override - String get remove_library_location => 'Retirer de la bibliothèque'; - - @override - String get account => 'Compte'; - - @override - String get logout => 'Se déconnecter'; - - @override - String get logout_of_this_account => 'Se déconnecter de ce compte'; - - @override - String get language_region => 'Langue et région'; - - @override - String get language => 'Langue'; - - @override - String get system_default => 'Paramètres par défaut du système'; - - @override - String get market_place_region => 'Région du marché'; - - @override - String get recommendation_country => 'Pays de recommandation'; - - @override - String get appearance => 'Apparence'; - - @override - String get layout_mode => 'Mode de mise en page'; - - @override - String get override_layout_settings => - 'Remplacer les paramètres de mise en page adaptative'; - - @override - String get adaptive => 'Adaptatif'; - - @override - String get compact => 'Compact'; - - @override - String get extended => 'Étendu'; - - @override - String get theme => 'Thème'; - - @override - String get dark => 'Sombre'; - - @override - String get light => 'Clair'; - - @override - String get system => 'Système'; - - @override - String get accent_color => 'Couleur d\'accentuation'; - - @override - String get sync_album_color => 'Synchroniser la couleur de l\'album'; - - @override - String get sync_album_color_description => - 'Utilise la couleur dominante de l\'art de l\'album comme couleur d\'accentuation'; - - @override - String get playback => 'Lecture'; - - @override - String get audio_quality => 'Qualité audio'; - - @override - String get high => 'Haute'; - - @override - String get low => 'Basse'; - - @override - String get pre_download_play => 'Pré-télécharger et lire'; - - @override - String get pre_download_play_description => - 'Au lieu de diffuser de l\'audio, téléchargez les octets et lisez-les à la place (recommandé pour les utilisateurs à bande passante élevée)'; - - @override - String get skip_non_music => - 'Ignorer les segments non musicaux (SponsorBlock)'; - - @override - String get blacklist_description => 'Pistes et artistes en liste noire'; - - @override - String get wait_for_download_to_finish => - 'Veuillez attendre la fin du téléchargement en cours'; - - @override - String get desktop => 'Bureau'; - - @override - String get close_behavior => 'Comportement de fermeture'; - - @override - String get close => 'Fermer'; - - @override - String get minimize_to_tray => 'Réduire dans la zone de notification'; - - @override - String get show_tray_icon => 'Afficher l\'icône de la zone de notification'; - - @override - String get about => 'À propos'; - - @override - String get u_love_spotube => 'Nous savons que vous aimez Spotube'; - - @override - String get check_for_updates => 'Vérifier les mises à jour'; - - @override - String get about_spotube => 'À propos de Spotube'; - - @override - String get blacklist => 'Liste noire'; - - @override - String get please_sponsor => 'S\'il vous plaît Sponsoriser/Donner'; - - @override - String get spotube_description => - 'Spotube, un client Spotify léger, multiplateforme et gratuit pour tous'; - - @override - String get version => 'Version'; - - @override - String get build_number => 'Numéro de version'; - - @override - String get founder => 'Fondateur'; - - @override - String get repository => 'Dépôt'; - - @override - String get bug_issues => 'Bugs + Problèmes'; - - @override - String get made_with => 'Fabriqué avec ❤️ au Bangladesh🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Licence'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Ne vous inquiétez pas, vos identifiants ne seront ni collectés ni partagés avec qui que ce soit'; - - @override - String get know_how_to_login => 'Vous ne savez pas comment faire?'; - - @override - String get follow_step_by_step_guide => 'Suivez le guide étape par étape'; - - @override - String cookie_name_cookie(Object name) { - return 'Cookie $name'; - } - - @override - String get fill_in_all_fields => 'Veuillez remplir tous les champs'; - - @override - String get submit => 'Soumettre'; - - @override - String get exit => 'Quitter'; - - @override - String get previous => 'Précédent'; - - @override - String get next => 'Suivant'; - - @override - String get done => 'Terminé'; - - @override - String get step_1 => 'Étape 1'; - - @override - String get first_go_to => 'Tout d\'abord, allez sur'; - - @override - String get something_went_wrong => 'Quelque chose s\'est mal passé'; - - @override - String get piped_instance => 'Instance pipée'; - - @override - String get piped_description => - 'L\'instance de serveur Piped à utiliser pour la correspondance des pistes'; - - @override - String get piped_warning => - 'Certaines d\'entre elles peuvent ne pas fonctionner correctement. Alors utilisez à vos risques et périls'; - - @override - String get invidious_instance => 'Instance de serveur Invidious'; - - @override - String get invidious_description => - 'L\'instance de serveur Invidious à utiliser pour la correspondance de pistes'; - - @override - String get invidious_warning => - 'Certaines instances pourraient ne pas bien fonctionner. À utiliser à vos risques et périls'; - - @override - String get generate => 'Générer'; - - @override - String track_exists(Object track) { - return 'La piste $track existe déjà'; - } - - @override - String get replace_downloaded_tracks => - 'Remplacer toutes les pistes téléchargées'; - - @override - String get skip_download_tracks => - 'Ignorer le téléchargement de toutes les pistes téléchargées'; - - @override - String get do_you_want_to_replace => - 'Voulez-vous remplacer la piste existante ?'; - - @override - String get replace => 'Remplacer'; - - @override - String get skip => 'Passer'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Sélectionnez jusqu\'à $count $type'; - } - - @override - String get select_genres => 'Sélectionner les genres'; - - @override - String get add_genres => 'Ajouter des genres'; - - @override - String get country => 'Pays'; - - @override - String get number_of_tracks_generate => 'Nombre de pistes à générer'; - - @override - String get acousticness => 'Acoustique'; - - @override - String get danceability => 'Dansabilité'; - - @override - String get energy => 'Énergie'; - - @override - String get instrumentalness => 'Instrumentalité'; - - @override - String get liveness => 'Interprétation en direct'; - - @override - String get loudness => 'Sonorité'; - - @override - String get speechiness => 'Parlé'; - - @override - String get valence => 'Valeur émotionnelle'; - - @override - String get popularity => 'Popularité'; - - @override - String get key => 'Clé'; - - @override - String get duration => 'Durée (s)'; - - @override - String get tempo => 'Tempo (BPM)'; - - @override - String get mode => 'Mode'; - - @override - String get time_signature => 'Signature rythmique'; - - @override - String get short => 'Court'; - - @override - String get medium => 'Moyen'; - - @override - String get long => 'Long'; - - @override - String get min => 'Min'; - - @override - String get max => 'Max'; - - @override - String get target => 'Cible'; - - @override - String get moderate => 'Modéré'; - - @override - String get deselect_all => 'Tout désélectionner'; - - @override - String get select_all => 'Tout sélectionner'; - - @override - String get are_you_sure => 'Êtes-vous sûr(e) ?'; - - @override - String get generating_playlist => - 'Génération de votre playlist personnalisée en cours...'; - - @override - String selected_count_tracks(Object count) { - return '$count pistes sélectionnées'; - } - - @override - String get download_warning => - 'Si vous téléchargez toutes les pistes en vrac, vous violez clairement les droits d\'auteur de la musique et vous causez des dommages à la société créative de la musique. J\'espère que vous en êtes conscient. Essayez toujours de respecter et de soutenir le travail acharné des artistes.'; - - @override - String get download_ip_ban_warning => - 'Au fait, votre adresse IP peut être bloquée sur YouTube en raison d\'une demande excessive de téléchargements par rapport à la normale. Le blocage de l\'IP signifie que vous ne pourrez pas utiliser YouTube (même si vous êtes connecté) pendant au moins 2 à 3 mois à partir de cet appareil IP. Et Spotube ne peut être tenu responsable si cela se produit.'; - - @override - String get by_clicking_accept_terms => - 'En cliquant sur \'accepter\', vous acceptez les conditions suivantes :'; - - @override - String get download_agreement_1 => - 'Je sais que je pirate de la musique. Je suis méchant(e).'; - - @override - String get download_agreement_2 => - 'Je soutiendrai l\'artiste autant que possible et je ne fais cela que parce que je n\'ai pas d\'argent pour acheter leur art.'; - - @override - String get download_agreement_3 => - 'Je suis parfaitement conscient(e) que mon adresse IP peut être bloquée sur YouTube et je ne tiens pas Spotube ni ses propriétaires/contributeurs responsables de tout accident causé par mon action actuelle.'; - - @override - String get decline => 'Refuser'; - - @override - String get accept => 'Accepter'; - - @override - String get details => 'Détails'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Chaîne'; - - @override - String get likes => 'J\'aime'; - - @override - String get dislikes => 'Je n\'aime pas'; - - @override - String get views => 'Vues'; - - @override - String get streamUrl => 'URL de diffusion'; - - @override - String get stop => 'Arrêter'; - - @override - String get sort_newest => 'Trier par les plus récents'; - - @override - String get sort_oldest => 'Trier par les plus anciens'; - - @override - String get sleep_timer => 'Minuteur de veille'; - - @override - String mins(Object minutes) { - return '$minutes minutes'; - } - - @override - String hours(Object hours) { - return '$hours heures'; - } - - @override - String hour(Object hours) { - return '$hours heure'; - } - - @override - String get custom_hours => 'Heures personnalisées'; - - @override - String get logs => 'Journaux'; - - @override - String get developers => 'Développeurs'; - - @override - String get not_logged_in => 'Vous n\'êtes pas connecté(e)'; - - @override - String get search_mode => 'Mode de recherche'; - - @override - String get audio_source => 'Source audio'; - - @override - String get ok => 'OK'; - - @override - String get failed_to_encrypt => 'Échec de la cryptage'; - - @override - String get encryption_failed_warning => - 'Spotube utilise le cryptage pour stocker vos données en toute sécurité. Mais cela a échoué. Il basculera donc vers un stockage non sécurisé\nSi vous utilisez Linux, assurez-vous d\'avoir installé des services secrets tels que gnome-keyring, kde-wallet et keepassxc'; - - @override - String get querying_info => 'Interrogation des info...'; - - @override - String get piped_api_down => 'L\'API Piped est hors service'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'L\'instance Piped $pipedInstance est actuellement indisponible\n\nChangez soit l\'instance, soit le \'Type d\'API\' pour utiliser l\'API officielle de YouTube\n\nN\'oubliez pas de redémarrer l\'application après la modification'; - } - - @override - String get you_are_offline => 'Vous êtes actuellement hors ligne'; - - @override - String get connection_restored => 'Votre connexion internet a été rétablie'; - - @override - String get use_system_title_bar => 'Utiliser la barre de titre système'; - - @override - String get crunching_results => 'Traitement des résultats...'; - - @override - String get search_to_get_results => 'Recherche pour obtenir des résultats'; - - @override - String get use_amoled_mode => 'Utiliser le mode AMOLED'; - - @override - String get pitch_dark_theme => 'Thème Dart noir intense'; - - @override - String get normalize_audio => 'Normaliser l\'audio'; - - @override - String get change_cover => 'Changer de couverture'; - - @override - String get add_cover => 'Ajouter une couverture'; - - @override - String get restore_defaults => 'Restaurer les valeurs par défaut'; - - @override - String get download_music_format => 'Format de téléchargement de musique'; - - @override - String get streaming_music_format => 'Format de streaming de musique'; - - @override - String get download_music_quality => 'Qualité de téléchargement de musique'; - - @override - String get streaming_music_quality => 'Qualité de streaming de musique'; - - @override - String get login_with_lastfm => 'Se connecter avec Last.fm'; - - @override - String get connect => 'Connecter'; - - @override - String get disconnect_lastfm => 'Déconnecter de Last.fm'; - - @override - String get disconnect => 'Déconnecter'; - - @override - String get username => 'Nom d\'utilisateur'; - - @override - String get password => 'Mot de passe'; - - @override - String get login => 'Se connecter'; - - @override - String get login_with_your_lastfm => 'Se connecter avec votre compte Last.fm'; - - @override - String get scrobble_to_lastfm => 'Scrobble à Last.fm'; - - @override - String get go_to_album => 'Aller à l\'album'; - - @override - String get discord_rich_presence => 'Présence riche de Discord'; - - @override - String get browse_all => 'Parcourir tout'; - - @override - String get genres => 'Genres'; - - @override - String get explore_genres => 'Explorer les genres'; - - @override - String get friends => 'Amis'; - - @override - String get no_lyrics_available => - 'Désolé, impossible de trouver les paroles de cette piste'; - - @override - String get start_a_radio => 'Démarrer une radio'; - - @override - String get how_to_start_radio => 'Comment voulez-vous démarrer la radio ?'; - - @override - String get replace_queue_question => - 'Voulez-vous remplacer la file d\'attente actuelle ou y ajouter ?'; - - @override - String get endless_playback => 'Lecture sans fin'; - - @override - String get delete_playlist => 'Supprimer la playlist'; - - @override - String get delete_playlist_confirmation => - 'Êtes-vous sûr de vouloir supprimer cette playlist ?'; - - @override - String get local_tracks => 'Titres locaux'; - - @override - String get local_tab => 'Local'; - - @override - String get song_link => 'Lien de la chanson'; - - @override - String get skip_this_nonsense => 'Passer cette absurdité'; - - @override - String get freedom_of_music => '“Liberté de la musique”'; - - @override - String get freedom_of_music_palm => - '“Liberté de la musique dans la paume de votre main”'; - - @override - String get get_started => 'Commençons'; - - @override - String get youtube_source_description => 'Recommandé et fonctionne mieux.'; - - @override - String get piped_source_description => - 'Vous vous sentez libre ? Comme YouTube mais beaucoup plus gratuit.'; - - @override - String get jiosaavn_source_description => - 'Le meilleur pour la région d\'Asie du Sud.'; - - @override - String get invidious_source_description => - 'Similaire à Piped mais avec une meilleure disponibilité'; - - @override - String highest_quality(Object quality) { - return 'Meilleure qualité : $quality'; - } - - @override - String get select_audio_source => 'Sélectionner la source audio'; - - @override - String get endless_playback_description => - 'Ajouter automatiquement de nouvelles chansons à la fin de la file d\'attente'; - - @override - String get choose_your_region => 'Choisissez votre région'; - - @override - String get choose_your_region_description => - 'Cela aidera Spotube à vous montrer le bon contenu pour votre emplacement.'; - - @override - String get choose_your_language => 'Choisissez votre langue'; - - @override - String get help_project_grow => 'Aidez ce projet à grandir'; - - @override - String get help_project_grow_description => - 'Spotube est un projet open-source. Vous pouvez aider ce projet à grandir en contribuant au projet, en signalant des bugs ou en suggérant de nouvelles fonctionnalités.'; - - @override - String get contribute_on_github => 'Contribuer sur GitHub'; - - @override - String get donate_on_open_collective => 'Faire un don sur Open Collective'; - - @override - String get browse_anonymously => 'Naviguer anonymement'; - - @override - String get enable_connect => 'Activer la connexion'; - - @override - String get enable_connect_description => - 'Contrôlez Spotube depuis d\'autres appareils'; - - @override - String get devices => 'Appareils'; - - @override - String get select => 'Sélectionner'; - - @override - String connect_client_alert(Object client) { - return 'Vous êtes contrôlé par $client'; - } - - @override - String get this_device => 'Cet appareil'; - - @override - String get remote => 'À distance'; - - @override - String get stats => 'Statistiques'; - - @override - String and_n_more(Object count) { - return 'et $count de plus'; - } - - @override - String get recently_played => 'Récemment joué'; - - @override - String get browse_more => 'Parcourir plus'; - - @override - String get no_title => 'Sans titre'; - - @override - String get not_playing => 'Non joué'; - - @override - String get epic_failure => 'Échec épique!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return '$tracks_length morceaux ajoutés à la file d\'attente'; - } - - @override - String get spotube_has_an_update => 'Spotube a une mise à jour'; - - @override - String get download_now => 'Télécharger maintenant'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum a été publié'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version a été publié'; - } - - @override - String get read_the_latest => 'Lisez les dernières '; - - @override - String get release_notes => 'notes de version'; - - @override - String get pick_color_scheme => 'Choisissez le schéma de couleurs'; - - @override - String get save => 'Sauvegarder'; - - @override - String get choose_the_device => 'Choisissez l\'appareil:'; - - @override - String get multiple_device_connected => - 'Plusieurs appareils sont connectés.\nChoisissez l\'appareil sur lequel vous souhaitez effectuer cette action'; - - @override - String get nothing_found => 'Rien trouvé'; - - @override - String get the_box_is_empty => 'La boîte est vide'; - - @override - String get top_artists => 'Meilleurs artistes'; - - @override - String get top_albums => 'Meilleurs albums'; - - @override - String get this_week => 'Cette semaine'; - - @override - String get this_month => 'Ce mois-ci'; - - @override - String get last_6_months => 'Les 6 derniers mois'; - - @override - String get this_year => 'Cette année'; - - @override - String get last_2_years => 'Les 2 dernières années'; - - @override - String get all_time => 'De tous les temps'; - - @override - String powered_by_provider(Object providerName) { - return 'Propulsé par $providerName'; - } - - @override - String get email => 'Email'; - - @override - String get profile_followers => 'Abonnés'; - - @override - String get birthday => 'Anniversaire'; - - @override - String get subscription => 'Abonnement'; - - @override - String get not_born => 'Non né'; - - @override - String get hacker => 'Hacker'; - - @override - String get profile => 'Profil'; - - @override - String get no_name => 'Sans nom'; - - @override - String get edit => 'Modifier'; - - @override - String get user_profile => 'Profil utilisateur'; - - @override - String count_plays(Object count) { - return '$count lectures'; - } - - @override - String get streaming_fees_hypothetical => - 'Frais de streaming (hypothétiques)'; - - @override - String get minutes_listened => 'Minutes écoutées'; - - @override - String get streamed_songs => 'Morceaux diffusés'; - - @override - String count_streams(Object count) { - return '$count streams'; - } - - @override - String get owned_by_you => 'Possédé par vous'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl copié dans le presse-papier'; - } - - @override - String get hipotetical_calculation => - '*Ce calcul est basé sur le paiement moyen par lecture des plateformes de streaming musical en ligne, de 0,003 \$ à 0,005 \$. Il s\'agit d\'un calcul hypothétique pour donner à l\'utilisateur un aperçu de ce qu\'il aurait payé aux artistes s\'il écoutait leur chanson sur différentes plateformes de streaming musical.'; - - @override - String count_mins(Object minutes) { - return '$minutes minutes'; - } - - @override - String get summary_minutes => 'minutes'; - - @override - String get summary_listened_to_music => 'A écouté de la musique'; - - @override - String get summary_songs => 'morceaux'; - - @override - String get summary_streamed_overall => 'Diffusé en général'; - - @override - String get summary_owed_to_artists => 'Dû aux artistes\nCe mois-ci'; - - @override - String get summary_artists => 'artistes'; - - @override - String get summary_music_reached_you => 'La musique vous a atteint'; - - @override - String get summary_full_albums => 'albums complets'; - - @override - String get summary_got_your_love => 'A obtenu votre amour'; - - @override - String get summary_playlists => 'playlists'; - - @override - String get summary_were_on_repeat => 'Était en répétition'; - - @override - String total_money(Object money) { - return 'Total $money'; - } - - @override - String get webview_not_found => 'Webview non trouvé'; - - @override - String get webview_not_found_description => - 'Aucun environnement d\'exécution Webview installé sur votre appareil.\nSi c\'est installé, assurez-vous qu\'il soit dans le environment PATH\n\nAprès l\'installation, redémarrez l\'application'; - - @override - String get unsupported_platform => 'Plateforme non prise en charge'; - - @override - String get cache_music => 'Mettre la musique en cache'; - - @override - String get open => 'Ouvrir'; - - @override - String get cache_folder => 'Dossier du cache'; - - @override - String get export => 'Exporter'; - - @override - String get clear_cache => 'Effacer le cache'; - - @override - String get clear_cache_confirmation => 'Voulez-vous effacer le cache ?'; - - @override - String get export_cache_files => 'Exporter les fichiers en cache'; - - @override - String found_n_files(Object count) { - return '$count fichiers trouvés'; - } - - @override - String get export_cache_confirmation => - 'Voulez-vous exporter ces fichiers vers'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return '$filesExported fichiers exportés sur $files'; - } - - @override - String get undo => 'Annuler'; - - @override - String get download_all => 'Télécharger tout'; - - @override - String get add_all_to_playlist => 'Ajouter tout à la playlist'; - - @override - String get add_all_to_queue => 'Ajouter tout à la file d\'attente'; - - @override - String get play_all_next => 'Lire tout suivant'; - - @override - String get pause => 'Pause'; - - @override - String get view_all => 'Voir tout'; - - @override - String get no_tracks_added_yet => - 'Il semble que vous n\'avez encore ajouté aucun morceau.'; - - @override - String get no_tracks => 'Il semble qu\'il n\'y ait pas de morceaux ici.'; - - @override - String get no_tracks_listened_yet => - 'Il semble que vous n\'avez encore rien écouté.'; - - @override - String get not_following_artists => 'Vous ne suivez aucun artiste.'; - - @override - String get no_favorite_albums_yet => - 'Il semble que vous n\'ayez encore ajouté aucun album à vos favoris.'; - - @override - String get no_logs_found => 'Aucun log trouvé'; - - @override - String get youtube_engine => 'Moteur YouTube'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine n\'est pas installé'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine n\'est pas installé sur votre système.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Assurez-vous qu\'il est disponible dans la variable PATH ou\nfixez le chemin absolu du fichier exécutable $engine ci-dessous.'; - } - - @override - String get youtube_engine_unix_issue_message => - 'Dans macOS/Linux/les systèmes d\'exploitation similaires à Unix, définir le chemin dans .zshrc/.bashrc/.bash_profile etc. ne fonctionnera pas.\nVous devez définir le chemin dans le fichier de configuration du shell.'; - - @override - String get download => 'Télécharger'; - - @override - String get file_not_found => 'Fichier non trouvé'; - - @override - String get custom => 'Personnalisé'; - - @override - String get add_custom_url => 'Ajouter une URL personnalisée'; - - @override - String get edit_port => 'Modifier le port'; - - @override - String get port_helper_msg => - 'La valeur par défaut est -1, ce qui indique un nombre aléatoire. Si vous avez configuré un pare-feu, il est recommandé de le définir.'; - - @override - String connect_request(Object client) { - return 'Autoriser $client à se connecter ?'; - } - - @override - String get connection_request_denied => - 'Connexion refusée. L\'utilisateur a refusé l\'accès.'; - - @override - String get an_error_occurred => 'Une erreur est survenue'; - - @override - String get copy_to_clipboard => 'Copier dans le presse-papiers'; - - @override - String get view_logs => 'Afficher les journaux'; - - @override - String get retry => 'Réessayer'; - - @override - String get no_default_metadata_provider_selected => - 'Vous n\'avez pas de fournisseur de métadonnées par défaut'; - - @override - String get manage_metadata_providers => - 'Gérer les fournisseurs de métadonnées'; - - @override - String get open_link_in_browser => 'Ouvrir le lien dans le navigateur ?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Voulez-vous ouvrir le lien suivant'; - - @override - String get unsafe_url_warning => - 'L\'ouverture de liens provenant de sources non fiables peut être dangereuse. Soyez prudent !\nVous pouvez également copier le lien dans votre presse-papiers.'; - - @override - String get copy_link => 'Copier le lien'; - - @override - String get building_your_timeline => - 'Construction de votre chronologie en fonction de vos écoutes...'; - - @override - String get official => 'Officiel'; - - @override - String author_name(Object author) { - return 'Auteur : $author'; - } - - @override - String get third_party => 'Tiers'; - - @override - String get plugin_requires_authentication => - 'Le plugin nécessite une authentification'; - - @override - String get update_available => 'Mise à jour disponible'; - - @override - String get supports_scrobbling => 'Supporte le scrobbling'; - - @override - String get plugin_scrobbling_info => - 'Ce plugin scrobble votre musique pour générer votre historique d\'écoute.'; - - @override - String get default_metadata_source => 'Source de métadonnées par défaut'; - - @override - String get set_default_metadata_source => - 'Définir la source de métadonnées par défaut'; - - @override - String get default_audio_source => 'Source audio par défaut'; - - @override - String get set_default_audio_source => 'Définir la source audio par défaut'; - - @override - String get set_default => 'Définir par défaut'; - - @override - String get support => 'Soutien'; - - @override - String get support_plugin_development => - 'Soutenir le développement de plugins'; - - @override - String can_access_name_api(Object name) { - return '- Peut accéder à l\'API **$name**'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Voulez-vous installer ce plugin ?'; - - @override - String get third_party_plugin_warning => - 'Ce plugin provient d\'un dépôt tiers. Assurez-vous de faire confiance à la source avant de l\'installer.'; - - @override - String get author => 'Auteur'; - - @override - String get this_plugin_can_do_following => 'Ce plugin peut faire ce qui suit'; - - @override - String get install => 'Installer'; - - @override - String get install_a_metadata_provider => - 'Installer un fournisseur de métadonnées'; - - @override - String get no_tracks_playing => - 'Aucune piste n\'est en cours de lecture actuellement'; - - @override - String get synced_lyrics_not_available => - 'Les paroles synchronisées ne sont pas disponibles pour cette chanson. Veuillez utiliser l\'onglet'; - - @override - String get plain_lyrics => 'Paroles simples'; - - @override - String get tab_instead => 'à la place.'; - - @override - String get disclaimer => 'Avertissement'; - - @override - String get third_party_plugin_dmca_notice => - 'L\'équipe de Spotube n\'assume aucune responsabilité (y compris juridique) pour les plugins \"tiers\".\nVeuillez les utiliser à vos propres risques. Pour tout bug/problème, veuillez le signaler au dépôt du plugin.\n\nSi un plugin \"tiers\" enfreint les conditions d\'utilisation/DMCA d\'un service/entité juridique, veuillez demander à l\'auteur du plugin \"tiers\" ou à la plateforme d\'hébergement (par exemple GitHub/Codeberg) de prendre des mesures. Les plugins listés ci-dessus (étiquetés \"tiers\") sont tous des plugins publics/maintenus par la communauté. Nous ne les gérons pas, nous ne pouvons donc prendre aucune mesure à leur sujet.\n\n'; - - @override - String get input_does_not_match_format => - 'L\'entrée ne correspond pas au format requis'; - - @override - String get plugins => 'Plugins'; - - @override - String get paste_plugin_download_url => - 'Collez l\'URL de téléchargement ou l\'URL du dépôt GitHub/Codeberg ou un lien direct vers le fichier .smplug'; - - @override - String get download_and_install_plugin_from_url => - 'Télécharger et installer le plugin à partir de l\'URL'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Échec de l\'ajout du plugin : $error'; - } - - @override - String get upload_plugin_from_file => - 'Télécharger le plugin à partir d\'un fichier'; - - @override - String get installed => 'Installé'; - - @override - String get available_plugins => 'Plugins disponibles'; - - @override - String get configure_plugins => - 'Configurez vos propres plugins de fournisseur de métadonnées et de source audio'; - - @override - String get audio_scrobblers => 'Scrobblers audio'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Source : '; - - @override - String get uncompressed => 'Non compressé'; - - @override - String get dab_music_source_description => - 'Pour les audiophiles. Fournit des flux audio de haute qualité/sans perte. Correspondance précise des pistes basée sur ISRC.'; -} diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart deleted file mode 100644 index 0434e8db..00000000 --- a/lib/l10n/generated/app_localizations_hi.dart +++ /dev/null @@ -1,1570 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Hindi (`hi`). -class AppLocalizationsHi extends AppLocalizations { - AppLocalizationsHi([String locale = 'hi']) : super(locale); - - @override - String get guest => 'अतिथि'; - - @override - String get browse => 'ब्राउज़ करें'; - - @override - String get search => 'खोजें'; - - @override - String get library => 'लाइब्रेरी'; - - @override - String get lyrics => 'गीतों के बोल'; - - @override - String get settings => 'सेटिंग्स'; - - @override - String get genre_categories_filter => 'श्रेणियों या जानरों को फिल्टर करें...'; - - @override - String get genre => 'जानर'; - - @override - String get personalized => 'व्यक्तिगत'; - - @override - String get featured => 'विशेष रुप से प्रदर्शित'; - - @override - String get new_releases => 'नई रिलीज़'; - - @override - String get songs => 'गाने'; - - @override - String playing_track(Object track) { - return '$track चल रहा है'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'यह मौजूदा कतार को साफ़ कर देगा। $track_length ट्रैक हटा दिए जाएंगे\nक्या आप जारी रखना चाहते हैं?'; - } - - @override - String get load_more => 'और लोड करें'; - - @override - String get playlists => 'प्लेलिस्ट'; - - @override - String get artists => 'कलाकार'; - - @override - String get albums => 'एल्बम'; - - @override - String get tracks => 'ट्रैक'; - - @override - String get downloads => 'डाउनलोड'; - - @override - String get filter_playlists => 'अपनी प्लेलिस्टों को फ़िल्टर करें...'; - - @override - String get liked_tracks => 'पसंदीदा ट्रैक'; - - @override - String get liked_tracks_description => 'आपके सभी पसंदीदा ट्रैक'; - - @override - String get playlist => 'प्लेलिस्ट'; - - @override - String get create_a_playlist => 'एक प्लेलिस्ट बनाएं'; - - @override - String get update_playlist => 'प्लेलिस्ट अपडेट करें'; - - @override - String get create => 'बनाएं'; - - @override - String get cancel => 'रद्द करें'; - - @override - String get update => 'अपडेट करें'; - - @override - String get playlist_name => 'प्लेलिस्ट का नाम'; - - @override - String get name_of_playlist => 'प्लेलिस्ट का नाम'; - - @override - String get description => 'विवरण'; - - @override - String get public => 'सार्वजनिक'; - - @override - String get collaborative => 'सहयोगी'; - - @override - String get search_local_tracks => 'स्थानीय ट्रैक खोजें...'; - - @override - String get play => 'चलाएँ'; - - @override - String get delete => 'हटाएँ'; - - @override - String get none => 'कोई नहीं'; - - @override - String get sort_a_z => 'A-Z सॉर्ट करें'; - - @override - String get sort_z_a => 'Z-A सॉर्ट करें'; - - @override - String get sort_artist => 'कलाकार के अनुसार सॉर्ट करें'; - - @override - String get sort_album => 'एल्बम के अनुसार सॉर्ट करें'; - - @override - String get sort_duration => 'समय के आधार पर क्रमबद्ध करें'; - - @override - String get sort_tracks => 'ट्रैक को सॉर्ट करें'; - - @override - String currently_downloading(Object tracks_length) { - return 'वर्तमान में डाउनलोड हो रहा है ($tracks_length)'; - } - - @override - String get cancel_all => 'सभी को रद्द करें'; - - @override - String get filter_artist => 'कलाकारों को फ़िल्टर करें...'; - - @override - String followers(Object followers) { - return '$followers फॉलोअर्स'; - } - - @override - String get add_artist_to_blacklist => 'काल सूची में कलाकार जोड़ें'; - - @override - String get top_tracks => 'शीर्ष ट्रैक'; - - @override - String get fans_also_like => 'फैंस भी पसंद करते हैं'; - - @override - String get loading => 'लोड हो रहा है...'; - - @override - String get artist => 'कलाकार'; - - @override - String get blacklisted => 'काल सूची में है'; - - @override - String get following => 'फॉलो करना'; - - @override - String get follow => 'फॉलो करें'; - - @override - String get artist_url_copied => 'कलाकार URL क्लिपबोर्ड पर कॉपी हुआ'; - - @override - String added_to_queue(Object tracks) { - return '$tracks ट्रैक कतार में जोड़े गए'; - } - - @override - String get filter_albums => 'एल्बमों को फ़िल्टर करें...'; - - @override - String get synced => 'सिंक किया गया'; - - @override - String get plain => 'सादा'; - - @override - String get shuffle => 'शफल'; - - @override - String get search_tracks => 'ट्रैक खोजें...'; - - @override - String get released => 'जारी हुआ'; - - @override - String error(Object error) { - return 'त्रुटि $error'; - } - - @override - String get title => 'शीर्षक'; - - @override - String get time => 'समय'; - - @override - String get more_actions => 'अधिक कार्रवाई'; - - @override - String download_count(Object count) { - return 'डाउनलोड ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return '($count) को प्लेलिस्ट में जोड़ें'; - } - - @override - String add_count_to_queue(Object count) { - return '($count) को कतार में जोड़ें'; - } - - @override - String play_count_next(Object count) { - return '($count) अगले में चलाएँ'; - } - - @override - String get album => 'एल्बम'; - - @override - String copied_to_clipboard(Object data) { - return '$data क्लिपबोर्ड पर कॉपी किया गया'; - } - - @override - String add_to_following_playlists(Object track) { - return '$track को निम्नलिखित प्लेलिस्ट में जोड़ें'; - } - - @override - String get add => 'जोड़ें'; - - @override - String added_track_to_queue(Object track) { - return '$track को कतार में जोड़ दिया गया'; - } - - @override - String get add_to_queue => 'कतार में जोड़ें'; - - @override - String track_will_play_next(Object track) { - return '$track अगले में चलेगा'; - } - - @override - String get play_next => 'अगले में चलाएँ'; - - @override - String removed_track_from_queue(Object track) { - return '$track को कतार से हटा दिया गया'; - } - - @override - String get remove_from_queue => 'कतार से हटाएँ'; - - @override - String get remove_from_favorites => 'पसंदीदा से हटाएँ'; - - @override - String get save_as_favorite => 'पसंदीदा के रूप में सहेजें'; - - @override - String get add_to_playlist => 'प्लेलिस्ट में जोड़ें'; - - @override - String get remove_from_playlist => 'प्लेलिस्ट से हटाएँ'; - - @override - String get add_to_blacklist => 'ब्लैकलिस्ट में जोड़ें'; - - @override - String get remove_from_blacklist => 'ब्लैकलिस्ट से हटाएँ'; - - @override - String get share => 'साझा करें'; - - @override - String get mini_player => 'मिनी प्लेयर'; - - @override - String get slide_to_seek => 'आगे या पीछे खोजने के लिए स्लाइड करें'; - - @override - String get shuffle_playlist => 'प्लेलिस्ट शफल करें'; - - @override - String get unshuffle_playlist => 'अनशफल प्लेलिस्ट'; - - @override - String get previous_track => 'पिछला ट्रैक'; - - @override - String get next_track => 'अगला ट्रैक'; - - @override - String get pause_playback => 'वापसी बंद करें'; - - @override - String get resume_playback => 'पुनः चलाना'; - - @override - String get loop_track => 'लूप ट्रैक'; - - @override - String get no_loop => 'कोई लूप नहीं'; - - @override - String get repeat_playlist => 'प्लेलिस्ट दोहराएं'; - - @override - String get queue => 'कतार'; - - @override - String get alternative_track_sources => 'वैकल्पिक ट्रैक स्रोत'; - - @override - String get download_track => 'ट्रैक डाउनलोड करें'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks ट्रैक कतार में हैं'; - } - - @override - String get clear_all => 'सभी हटाएं'; - - @override - String get show_hide_ui_on_hover => 'होवर पर यूआई दिखाएँ/छिपाएँ'; - - @override - String get always_on_top => 'हमेशा ऊपर हो'; - - @override - String get exit_mini_player => 'मिनी प्लेयर से बाहर निकलें'; - - @override - String get download_location => 'डाउनलोड स्थान'; - - @override - String get local_library => 'स्थानीय पुस्तकालय'; - - @override - String get add_library_location => 'पुस्तकालय में जोड़ें'; - - @override - String get remove_library_location => 'पुस्तकालय से हटाएं'; - - @override - String get account => 'खाता'; - - @override - String get logout => 'लॉगआउट'; - - @override - String get logout_of_this_account => 'इस खाते से लॉगआउट करें'; - - @override - String get language_region => 'भाषा और क्षेत्र'; - - @override - String get language => 'भाषा'; - - @override - String get system_default => 'सिस्टम डिफ़ॉल्ट'; - - @override - String get market_place_region => 'मार्केटप्लेस क्षेत्र'; - - @override - String get recommendation_country => 'सिफ़ारिश देने वाला देश'; - - @override - String get appearance => 'दिखने में'; - - @override - String get layout_mode => 'लेआउट मोड'; - - @override - String get override_layout_settings => - 'ओवरराइड रेस्पॉन्सिव लेआउट मोड सेटिंग्स'; - - @override - String get adaptive => 'अनुकूल'; - - @override - String get compact => 'कॉम्पैक्ट'; - - @override - String get extended => 'विस्तृत'; - - @override - String get theme => 'थीम'; - - @override - String get dark => 'डार्क'; - - @override - String get light => 'लाइट'; - - @override - String get system => 'सिस्टम'; - - @override - String get accent_color => 'अक्षरशैली का रंग'; - - @override - String get sync_album_color => 'एल्बम का रंग सिंक करें'; - - @override - String get sync_album_color_description => - 'एल्बम कला का प्रधान रंग एक्सेंट रंग के रूप में उपयोग किया जाता है'; - - @override - String get playback => 'प्लेबैक'; - - @override - String get audio_quality => 'ऑडियो क्वालिटी'; - - @override - String get high => 'उच्च'; - - @override - String get low => 'निम्न'; - - @override - String get pre_download_play => 'पूर्वावत डाउनलोड और प्ले करें'; - - @override - String get pre_download_play_description => - 'ऑडियो स्ट्रीमिंग की बजाय बाइट्स डाउनलोड करें और बजाय में प्ले करें (उच्च बैंडविड्थ उपयोगकर्ताओं के लिए सिफारिश किया जाता है)'; - - @override - String get skip_non_music => - 'गाने के अलावा सेगमेंट्स को छोड़ें (स्पॉन्सरब्लॉक)'; - - @override - String get blacklist_description => 'ब्लैकलिस्ट में शामिल ट्रैक और कलाकार'; - - @override - String get wait_for_download_to_finish => - 'वर्तमान डाउनलोड समाप्त होने तक कृपया प्रतीक्षा करें'; - - @override - String get desktop => 'डेस्कटॉप'; - - @override - String get close_behavior => 'बंद करने का व्यवहार'; - - @override - String get close => 'बंद करें'; - - @override - String get minimize_to_tray => 'ट्रे में कम करें'; - - @override - String get show_tray_icon => 'सिस्टम ट्रे आइकन दिखाएं'; - - @override - String get about => 'के बारे में'; - - @override - String get u_love_spotube => 'हम जानते हैं कि आप Spotube से प्यार करते हैं'; - - @override - String get check_for_updates => 'अपडेट के लिए जाँच करें'; - - @override - String get about_spotube => 'Spotube के बारे में'; - - @override - String get blacklist => 'ब्लैकलिस्ट'; - - @override - String get please_sponsor => 'कृपया स्पॉन्सर / डोनेट करें'; - - @override - String get spotube_description => - 'Spotube, एक हल्का, सभी प्लेटफॉर्मों पर चलने वाला, मुफ्त स्पॉटिफाई क्लाइंट'; - - @override - String get version => 'संस्करण'; - - @override - String get build_number => 'बिल्ड नंबर'; - - @override - String get founder => 'संस्थापक'; - - @override - String get repository => 'भण्डार'; - - @override - String get bug_issues => 'बग+मुद्दे'; - - @override - String get made_with => 'बांग्लादेश🇧🇩 में दिल से बनाया गया'; - - @override - String get kingkor_roy_tirtho => 'किंगकोर रॉय तिर्थो'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year किंगकोर रॉय तिर्थो'; - } - - @override - String get license => 'लाइसेंस'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'चिंता न करें, आपके क्रेडेंशियल किसी भी तरह से नहीं एकत्रित या साझा किए जाएंगे'; - - @override - String get know_how_to_login => 'इसे कैसे करें पता नहीं?'; - - @override - String get follow_step_by_step_guide => 'कदम से कदम गाइड के साथ चलें'; - - @override - String cookie_name_cookie(Object name) { - return '$name कुकी'; - } - - @override - String get fill_in_all_fields => 'कृपया सभी फ़ील्ड भरें'; - - @override - String get submit => 'सबमिट'; - - @override - String get exit => 'बाहर निकलें'; - - @override - String get previous => 'पिछला'; - - @override - String get next => 'अगला'; - - @override - String get done => 'किया हुआ'; - - @override - String get step_1 => '1 चरण'; - - @override - String get first_go_to => 'पहले, जाएं'; - - @override - String get something_went_wrong => 'कुछ गलत हो गया'; - - @override - String get piped_instance => 'पाइप्ड सर्वर'; - - @override - String get piped_description => 'पाइप किए गए सर्वर'; - - @override - String get piped_warning => - 'गानों का मिलान करने के लिए उपयोग किए जाते हैं, हो सकता है कि उनमें से कुछ के साथ ठीक से काम न करें इसलिए अपने जोखिम पर उपयोग करें'; - - @override - String get invidious_instance => 'इन्विडियस सर्वर इंस्टेंस'; - - @override - String get invidious_description => - 'ट्रैक मिलान के लिए इन्विडियस सर्वर इंस्टेंस'; - - @override - String get invidious_warning => - 'कुछ इंस्टेंस अच्छी तरह से काम नहीं कर सकते। अपने जोखिम पर उपयोग करें'; - - @override - String get generate => 'उत्पन्न करें'; - - @override - String track_exists(Object track) { - return 'ट्रैक $track पहले से मौजूद है'; - } - - @override - String get replace_downloaded_tracks => 'सभी डाउनलोड किए गए ट्रैक्स को बदलें'; - - @override - String get skip_download_tracks => 'सभी डाउनलोड किए गए ट्रैक्स को छोड़ें'; - - @override - String get do_you_want_to_replace => - 'क्या आप मौजूदा ट्रैक को बदलना चाहते हैं?'; - - @override - String get replace => 'बदलें'; - - @override - String get skip => 'छोड़ें'; - - @override - String select_up_to_count_type(Object count, Object type) { - return '$count $type तक चुनें'; - } - - @override - String get select_genres => 'जान्र चुनें'; - - @override - String get add_genres => 'जान्र जोड़ें'; - - @override - String get country => 'देश'; - - @override - String get number_of_tracks_generate => 'उत्पन्न करने के लिए ट्रैक की संख्या'; - - @override - String get acousticness => 'ध्वनिकता'; - - @override - String get danceability => 'नृत्यता'; - - @override - String get energy => 'ऊर्जा'; - - @override - String get instrumentalness => 'आलापिकता'; - - @override - String get liveness => 'जीवंतता'; - - @override - String get loudness => 'शोर'; - - @override - String get speechiness => 'बोलचालता'; - - @override - String get valence => 'मनोदयता'; - - @override - String get popularity => 'लोकप्रियता'; - - @override - String get key => 'कुंजी'; - - @override - String get duration => 'अवधि (सेकंड)'; - - @override - String get tempo => 'गति (BPM)'; - - @override - String get mode => 'मोड'; - - @override - String get time_signature => 'समय छाप'; - - @override - String get short => 'संक्षेप'; - - @override - String get medium => 'मध्यम'; - - @override - String get long => 'लंबा'; - - @override - String get min => 'न्यूनतम'; - - @override - String get max => 'अधिकतम'; - - @override - String get target => 'लक्ष्य'; - - @override - String get moderate => 'मध्यम'; - - @override - String get deselect_all => 'सभी को अचयनित करें'; - - @override - String get select_all => 'सभी को चुनें'; - - @override - String get are_you_sure => 'क्या आपको यकीन है?'; - - @override - String get generating_playlist => 'आपकी कस्टम प्लेलिस्ट बनाई जा रही है...'; - - @override - String selected_count_tracks(Object count) { - return '$count ट्रैक्स चयनित हैं'; - } - - @override - String get download_warning => - 'यदि आप सभी ट्रैक्स को बल्क में डाउनलोड करते हैं, तो आप स्पष्ट रूप से संगीत की अवैध नकली बना रहे हैं और संगीत के रचनात्मक समाज को क्षति पहुंचा रहे हैं। मुझे आशा है कि आप इसके बारे में जागरूक हैं। हमेशा कोशिश करें कि कलाकार के मेहनत का सम्मान और समर्थन करें।'; - - @override - String get download_ip_ban_warning => - 'बाहरी डाउनलोड अनुरोधों के कारण आपका आईपी YouTube पर अधिक से अधिक ब्लॉक हो सकता है। आईपी ब्लॉक का अर्थ है कि आप उसी आईपी उपकरण से कम से कम 2-3 महीनों तक YouTube का उपयोग नहीं कर सकेंगे (यदि आप लॉग इन हैं तो भी)। और स्पोट्यूब किसी भी जिम्मेदारी को नहीं उठाता है अगर ऐसा कभी होता है।'; - - @override - String get by_clicking_accept_terms => - '\'स्वीकार\' पर क्लिक करके आप निम्नलिखित शर्तों से सहमत होते हैं:'; - - @override - String get download_agreement_1 => - 'मुझे पता है कि मैं संगीत की अवैध नकली बना रहा हूं। मैं बुरा हूं'; - - @override - String get download_agreement_2 => - 'मैं कलाकार का समर्थन करूंगा जहां भी मुझे संभव हो और मैं केवल इसल िए ऐसा कर रहा हूं क्योंकि मेरे पास उनकी कला खरीदने के लिए पैसे नहीं हैं।'; - - @override - String get download_agreement_3 => - 'मैं पूरी तरह से जागरूक हूं कि मेरा आईपी YouTube पर ब्लॉक हो सकता है और मैं स्पोट्यूब या उसके मालिकों / सहयोगियों को किसी भी दुर्घटना के लिए जिम्मेदार नहीं मानता।'; - - @override - String get decline => 'इनकार करें'; - - @override - String get accept => 'स्वीकार करें'; - - @override - String get details => 'विवरण'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'चैनल'; - - @override - String get likes => 'पसंद'; - - @override - String get dislikes => 'अप्रिय'; - - @override - String get views => 'दृश्य'; - - @override - String get streamUrl => 'स्ट्रीम URL'; - - @override - String get stop => 'रोकें'; - - @override - String get sort_newest => 'नवीनतम जोड़े गए के अनुसार क्रमबद्ध करें'; - - @override - String get sort_oldest => 'सबसे पुराने जोड़े गए के अनुसार क्रमबद्ध करें'; - - @override - String get sleep_timer => 'स्लीप टाइमर'; - - @override - String mins(Object minutes) { - return '$minutes मिनट'; - } - - @override - String hours(Object hours) { - return '$hours घंटे'; - } - - @override - String hour(Object hours) { - return '$hours घंटा'; - } - - @override - String get custom_hours => 'कस्टम घंटे'; - - @override - String get logs => 'लॉग'; - - @override - String get developers => 'डेवलपर्स'; - - @override - String get not_logged_in => 'आप लॉग इन नहीं हैं'; - - @override - String get search_mode => 'खोज मोड'; - - @override - String get audio_source => 'ऑडियो स्रोत'; - - @override - String get ok => 'ठीक है'; - - @override - String get failed_to_encrypt => 'एन्क्रिप्ट करने में विफल रहा'; - - @override - String get encryption_failed_warning => - 'Spotube आपके डेटा को सुरक्षित रूप से स्टोर करने के लिए एन्क्रिप्शन का उपयोग करता है। लेकिन इसमें विफल रहा। इसलिए, यह असुरक्षित स्टोरेज पर फॉलबैक करेगा\nयदि आप Linux का उपयोग कर रहे हैं, तो कृपया सुनिश्चित करें कि आपके पास gnome-keyring, kde-wallet, keepassxc आदि जैसी कोई सीक्रेट-सर्विस इंस्टॉल की गई है'; - - @override - String get querying_info => 'जानकारी प्राप्त करना'; - - @override - String get piped_api_down => 'पाइप्ड एपीआई डाउन है'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'पाइप्ड इंस्टेंस $pipedInstance वर्तमान में डाउन है\n\nइंस्टेंस बदलें या \'एपीआई प्रकार\' को आधिकृत YouTube एपीआई में बदलें\n\nपरिवर्तन के बाद ऐप को फिर से चालने की सुनिश्चित करें'; - } - - @override - String get you_are_offline => 'आप वर्तमान में ऑफ़लाइन हैं'; - - @override - String get connection_restored => 'आपका इंटरनेट कनेक्शन बहाल हो गया है'; - - @override - String get use_system_title_bar => 'सिस्टम शीर्षक पट्टी का उपयोग करें'; - - @override - String get crunching_results => 'परिणाम को प्रसंस्कृत किया जा रहा है...'; - - @override - String get search_to_get_results => 'परिणाम प्राप्त करने के लिए खोजें'; - - @override - String get use_amoled_mode => 'AMOLED मोड का उपयोग करें'; - - @override - String get pitch_dark_theme => 'पिच ब्लैक डार्ट थीम'; - - @override - String get normalize_audio => 'ऑडियो को सामान्य करें'; - - @override - String get change_cover => 'कवर बदलें'; - - @override - String get add_cover => 'कवर जोड़ें'; - - @override - String get restore_defaults => 'डिफ़ॉल्ट सेटिंग्स को बहाल करें'; - - @override - String get download_music_format => 'संगीत डाउनलोड प्रारूप'; - - @override - String get streaming_music_format => 'संगीत स्ट्रीमिंग प्रारूप'; - - @override - String get download_music_quality => 'संगीत डाउनलोड गुणवत्ता'; - - @override - String get streaming_music_quality => 'संगीत स्ट्रीमिंग गुणवत्ता'; - - @override - String get login_with_lastfm => 'Last.fm से लॉगिन करें'; - - @override - String get connect => 'कनेक्ट करें'; - - @override - String get disconnect_lastfm => 'Last.fm से डिस्कनेक्ट करें'; - - @override - String get disconnect => 'डिस्कनेक्ट करें'; - - @override - String get username => 'उपयोगकर्ता नाम'; - - @override - String get password => 'पासवर्ड'; - - @override - String get login => 'लॉग इन करें'; - - @override - String get login_with_your_lastfm => 'अपने Last.fm अकाउंट से लॉगिन करें'; - - @override - String get scrobble_to_lastfm => 'Last.fm पर स्क्रॉबल करें'; - - @override - String get go_to_album => 'एल्बम पर जाएं'; - - @override - String get discord_rich_presence => 'डिस्कॉर्ड रिच प्रेजेंस'; - - @override - String get browse_all => 'सभी को ब्राउज़ करें'; - - @override - String get genres => 'शैलियाँ'; - - @override - String get explore_genres => 'शैलियों का अन्वेषण करें'; - - @override - String get friends => 'दोस्त'; - - @override - String get no_lyrics_available => - 'क्षमा करें, इस ट्रैक के लिए गाने नहीं मिल सके'; - - @override - String get start_a_radio => 'रेडियो शुरू करें'; - - @override - String get how_to_start_radio => 'रेडियो कैसे शुरू करना चाहते हैं?'; - - @override - String get replace_queue_question => - 'क्या आप वर्तमान कतार को बदलना चाहते हैं या इसे जोड़ना चाहते हैं?'; - - @override - String get endless_playback => 'अंतहीन प्लेबैक'; - - @override - String get delete_playlist => 'प्लेलिस्ट हटाएं'; - - @override - String get delete_playlist_confirmation => - 'क्या आप वाकई इस प्लेलिस्ट को हटाना चाहते हैं?'; - - @override - String get local_tracks => 'स्थानीय ट्रैक्स'; - - @override - String get local_tab => 'स्थानीय'; - - @override - String get song_link => 'गाने का लिंक'; - - @override - String get skip_this_nonsense => 'इस माया को छोड़ें'; - - @override - String get freedom_of_music => '“संगीत की स्वतंत्रता”'; - - @override - String get freedom_of_music_palm => '“हाथ में संगीत की स्वतंत्रता”'; - - @override - String get get_started => 'आइए शुरू करें'; - - @override - String get youtube_source_description => - 'सिफारिश किया गया और सबसे अच्छा काम करता है।'; - - @override - String get piped_source_description => - 'मुफ्त महसूस कर रहे हैं? YouTube के समान लेकिन काफी अधिक मुफ्त।'; - - @override - String get jiosaavn_source_description => - 'दक्षिण एशियाई क्षेत्र के लिए सर्वोत्तम।'; - - @override - String get invidious_source_description => - 'पाइप्ड के समान, लेकिन अधिक उपलब्धता के साथ'; - - @override - String highest_quality(Object quality) { - return 'सर्वोत्तम गुणवत्ता: $quality'; - } - - @override - String get select_audio_source => 'ऑडियो स्रोत चुनें'; - - @override - String get endless_playback_description => - 'क्रमबद्ध कतार के अंत में नए गाने स्वचालित रूप से जोड़ें'; - - @override - String get choose_your_region => 'अपना क्षेत्र चुनें'; - - @override - String get choose_your_region_description => - 'यह Spotube को आपके स्थान के लिए सही सामग्री दिखाने में मदद करेगा।'; - - @override - String get choose_your_language => 'अपनी भाषा चुनें'; - - @override - String get help_project_grow => 'इस परियोजना को बढ़ावा दें'; - - @override - String get help_project_grow_description => - 'Spotube एक ओपन सोर्स परियोजना है। आप इस परियोजना को योगदान देकर, बग रिपोर्ट करके या नई विशेषताओं का सुझाव देकर इस परियोजना को बढ़ा सकते हैं।'; - - @override - String get contribute_on_github => 'GitHub पर योगदान करें'; - - @override - String get donate_on_open_collective => 'ओपन कलेक्टिव पर दान करें'; - - @override - String get browse_anonymously => 'बिना नाम के ब्राउज़ करें'; - - @override - String get enable_connect => 'कनेक्ट सक्षम करें'; - - @override - String get enable_connect_description => - 'अन्य उपकरणों से Spotube को नियंत्रित करें'; - - @override - String get devices => 'उपकरण'; - - @override - String get select => 'चयन करें'; - - @override - String connect_client_alert(Object client) { - return 'आप $client द्वारा नियंत्रित हो रहे हैं'; - } - - @override - String get this_device => 'यह उपकरण'; - - @override - String get remote => 'रिमोट'; - - @override - String get stats => 'आंकड़े'; - - @override - String and_n_more(Object count) { - return 'और $count और'; - } - - @override - String get recently_played => 'हाल ही में खेले गए'; - - @override - String get browse_more => 'अधिक ब्राउज़ करें'; - - @override - String get no_title => 'कोई शीर्षक नहीं'; - - @override - String get not_playing => 'नहीं चल रहा'; - - @override - String get epic_failure => 'महान असफलता!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return '$tracks_length ट्रैक्स कतार में जोड़े गए'; - } - - @override - String get spotube_has_an_update => 'Spotube में एक अपडेट है'; - - @override - String get download_now => 'अभी डाउनलोड करें'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum जारी किया गया है'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version जारी किया गया है'; - } - - @override - String get read_the_latest => 'नवीनतम पढ़ें'; - - @override - String get release_notes => 'रिलीज़ नोट्स'; - - @override - String get pick_color_scheme => 'रंग योजना चुनें'; - - @override - String get save => 'सहेजें'; - - @override - String get choose_the_device => 'उपकरण चुनें:'; - - @override - String get multiple_device_connected => - 'कई उपकरण जुड़े हुए हैं।\nउस उपकरण को चुनें जिस पर आप यह क्रिया करना चाहते हैं'; - - @override - String get nothing_found => 'कुछ भी नहीं मिला'; - - @override - String get the_box_is_empty => 'बॉक्स खाली है'; - - @override - String get top_artists => 'शीर्ष कलाकार'; - - @override - String get top_albums => 'शीर्ष एल्बम'; - - @override - String get this_week => 'इस हफ्ते'; - - @override - String get this_month => 'इस महीने'; - - @override - String get last_6_months => 'पिछले 6 महीने'; - - @override - String get this_year => 'इस साल'; - - @override - String get last_2_years => 'पिछले 2 साल'; - - @override - String get all_time => 'सभी समय'; - - @override - String powered_by_provider(Object providerName) { - return '$providerName द्वारा संचालित'; - } - - @override - String get email => 'ईमेल'; - - @override - String get profile_followers => 'अनुयायी'; - - @override - String get birthday => 'जन्मदिन'; - - @override - String get subscription => 'सदस्यता'; - - @override - String get not_born => 'अभी पैदा नहीं हुआ'; - - @override - String get hacker => 'हैकर'; - - @override - String get profile => 'प्रोफ़ाइल'; - - @override - String get no_name => 'कोई नाम नहीं'; - - @override - String get edit => 'संपादित करें'; - - @override - String get user_profile => 'उपयोगकर्ता प्रोफ़ाइल'; - - @override - String count_plays(Object count) { - return '$count प्ले'; - } - - @override - String get streaming_fees_hypothetical => - '*Spotify की प्रति स्ट्रीम भुगतान के आधार पर\n\$0.003 से \$0.005 तक गणना की गई है। यह एक काल्पनिक\nगणना है जो उपयोगकर्ता को यह जानकारी देती है कि वे कितना भुगतान\nकरते यदि वे Spotify पर गाने सुनते।'; - - @override - String get minutes_listened => 'सुनिएका मिनेटहरू'; - - @override - String get streamed_songs => 'स्ट्रीम गरिएका गीतहरू'; - - @override - String count_streams(Object count) { - return '$count स्ट्रिम'; - } - - @override - String get owned_by_you => 'तपाईंले स्वामित्व गरेको'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl क्लिपबोर्डमा कपी गरियो'; - } - - @override - String get hipotetical_calculation => - '*यह औसत ऑनलाइन संगीत स्ट्रीमिंग प्लेटफ़ॉर्म के प्रति स्ट्रीम भुगतान (\$0.003 से \$0.005) के आधार पर गणना की गई है। यह एक काल्पनिक गणना है जो उपयोगकर्ता को यह जानकारी देने के लिए है कि यदि वे विभिन्न संगीत स्ट्रीमिंग प्लेटफ़ॉर्म पर अपने गाने सुनते हैं तो उन्होंने कलाकारों को कितना भुगतान किया होगा।'; - - @override - String count_mins(Object minutes) { - return '$minutes मिनट'; - } - - @override - String get summary_minutes => 'मिनट'; - - @override - String get summary_listened_to_music => 'सुनी गई संगीत'; - - @override - String get summary_songs => 'गाने'; - - @override - String get summary_streamed_overall => 'कुल स्ट्रीम'; - - @override - String get summary_owed_to_artists => 'कलाकारों को देनदार\nइस महीने'; - - @override - String get summary_artists => 'कलाकार'; - - @override - String get summary_music_reached_you => 'संगीत आपके पास पहुंच गया'; - - @override - String get summary_full_albums => 'पूरा एल्बम'; - - @override - String get summary_got_your_love => 'आपका प्यार मिला'; - - @override - String get summary_playlists => 'प्लेलिस्ट'; - - @override - String get summary_were_on_repeat => 'दोहराया गया'; - - @override - String total_money(Object money) { - return 'कुल $money'; - } - - @override - String get webview_not_found => 'वेबव्यू नहीं मिला'; - - @override - String get webview_not_found_description => - 'आपके डिवाइस पर वेबव्यू रनटाइम इंस्टॉल नहीं है।\nअगर इंस्टॉल है, तो सुनिश्चित करें कि यह environment PATH में है\n\nइंस्टॉल करने के बाद, ऐप को पुनः शुरू करें'; - - @override - String get unsupported_platform => 'असमर्थित प्लेटफार्म'; - - @override - String get cache_music => 'संगीत को कैश करें'; - - @override - String get open => 'खोलें'; - - @override - String get cache_folder => 'कैश फ़ोल्डर'; - - @override - String get export => 'निर्यात करें'; - - @override - String get clear_cache => 'कैश साफ़ करें'; - - @override - String get clear_cache_confirmation => 'क्या आप कैश साफ़ करना चाहते हैं?'; - - @override - String get export_cache_files => 'कैश फ़ाइलें निर्यात करें'; - - @override - String found_n_files(Object count) { - return '$count फ़ाइलें मिलीं'; - } - - @override - String get export_cache_confirmation => - 'क्या आप इन फ़ाइलों को निर्यात करना चाहते हैं'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return '$filesExported फ़ाइलें निर्यात की गईं $files में से'; - } - - @override - String get undo => 'पूर्ववत करें'; - - @override - String get download_all => 'सभी डाउनलोड करें'; - - @override - String get add_all_to_playlist => 'सभी को प्लेलिस्ट में जोड़ें'; - - @override - String get add_all_to_queue => 'सभी को कतार में जोड़ें'; - - @override - String get play_all_next => 'सभी को अगले खेलने के लिए'; - - @override - String get pause => 'रोकें'; - - @override - String get view_all => 'सभी देखें'; - - @override - String get no_tracks_added_yet => - 'लगता है आपने अभी तक कोई ट्रैक नहीं जोड़ा है।'; - - @override - String get no_tracks => 'लगता है यहाँ कोई ट्रैक नहीं है।'; - - @override - String get no_tracks_listened_yet => 'लगता है आपने अभी तक कुछ नहीं सुना है।'; - - @override - String get not_following_artists => - 'आप किसी भी कलाकार को फॉलो नहीं कर रहे हैं।'; - - @override - String get no_favorite_albums_yet => - 'लगता है आपने अभी तक कोई एल्बम अपनी पसंदीदा सूची में नहीं जोड़ा है।'; - - @override - String get no_logs_found => 'कोई लॉग नहीं मिला'; - - @override - String get youtube_engine => 'YouTube इंजन'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine स्थापित नहीं है'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine आपके सिस्टम में स्थापित नहीं है।'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'यह सुनिश्चित करें कि यह PATH वेरिएबल में उपलब्ध हो या\nनीचे $engine निष्पादन योग्य फ़ाइल का पूर्ण पथ सेट करें।'; - } - - @override - String get youtube_engine_unix_issue_message => - 'macOS/Linux/यूनिक्स जैसे OS में, .zshrc/.bashrc/.bash_profile आदि में पथ सेट करना काम नहीं करेगा।\nआपको पथ को शेल कॉन्फ़िगरेशन फ़ाइल में सेट करना होगा।'; - - @override - String get download => 'डाउनलोड करें'; - - @override - String get file_not_found => 'फाइल नहीं मिली'; - - @override - String get custom => 'कस्टम'; - - @override - String get add_custom_url => 'कस्टम URL जोड़ें'; - - @override - String get edit_port => 'पोर्ट संपादित करें'; - - @override - String get port_helper_msg => - 'डिफ़ॉल्ट -1 है जो यादृच्छिक संख्या को दर्शाता है। यदि आपने फ़ायरवॉल कॉन्फ़िगर किया है, तो इसे सेट करना अनुशंसित है।'; - - @override - String connect_request(Object client) { - return '$client को कनेक्ट करने की अनुमति दें?'; - } - - @override - String get connection_request_denied => - 'कनेक्शन अस्वीकृत। उपयोगकर्ता ने पहुंच अस्वीकृत कर दी।'; - - @override - String get an_error_occurred => 'एक त्रुटि हुई'; - - @override - String get copy_to_clipboard => 'क्लिपबोर्ड पर कॉपी करें'; - - @override - String get view_logs => 'लॉग देखें'; - - @override - String get retry => 'पुनः प्रयास करें'; - - @override - String get no_default_metadata_provider_selected => - 'आपने कोई डिफ़ॉल्ट मेटाडेटा प्रदाता सेट नहीं किया है'; - - @override - String get manage_metadata_providers => 'मेटाडेटा प्रदाताओं को प्रबंधित करें'; - - @override - String get open_link_in_browser => 'ब्राउज़र में लिंक खोलें?'; - - @override - String get do_you_want_to_open_the_following_link => - 'क्या आप निम्नलिखित लिंक खोलना चाहते हैं'; - - @override - String get unsafe_url_warning => - 'अविश्वसनीय स्रोतों से लिंक खोलना असुरक्षित हो सकता है। सावधान रहें!\nआप लिंक को अपने क्लिपबोर्ड पर भी कॉपी कर सकते हैं।'; - - @override - String get copy_link => 'लिंक कॉपी करें'; - - @override - String get building_your_timeline => - 'आपकी सुनने की आदतों के आधार पर आपकी टाइमलाइन बनाई जा रही है...'; - - @override - String get official => 'आधिकारिक'; - - @override - String author_name(Object author) { - return 'लेखक: $author'; - } - - @override - String get third_party => 'तृतीय-पक्ष'; - - @override - String get plugin_requires_authentication => - 'प्लगइन को प्रमाणीकरण की आवश्यकता है'; - - @override - String get update_available => 'अपडेट उपलब्ध है'; - - @override - String get supports_scrobbling => 'स्क्रॉबलिंग का समर्थन करता है'; - - @override - String get plugin_scrobbling_info => - 'यह प्लगइन आपके सुनने के इतिहास को उत्पन्न करने के लिए आपके संगीत को स्क्रॉबल करता है।'; - - @override - String get default_metadata_source => 'डिफ़ॉल्ट मेटाडेटा स्रोत'; - - @override - String get set_default_metadata_source => 'डिफ़ॉल्ट मेटाडेटा स्रोत सेट करें'; - - @override - String get default_audio_source => 'डिफ़ॉल्ट ऑडियो स्रोत'; - - @override - String get set_default_audio_source => 'डिफ़ॉल्ट ऑडियो स्रोत सेट करें'; - - @override - String get set_default => 'डिफ़ॉल्ट सेट करें'; - - @override - String get support => 'समर्थन'; - - @override - String get support_plugin_development => 'प्लगइन विकास का समर्थन करें'; - - @override - String can_access_name_api(Object name) { - return '- **$name** API तक पहुंच सकता है'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'क्या आप इस प्लगइन को स्थापित करना चाहते हैं?'; - - @override - String get third_party_plugin_warning => - 'यह प्लगइन एक तृतीय-पक्ष रिपॉजिटरी से है। कृपया सुनिश्चित करें कि आप इसे स्थापित करने से पहले स्रोत पर भरोसा करते हैं।'; - - @override - String get author => 'लेखक'; - - @override - String get this_plugin_can_do_following => 'यह प्लगइन निम्नलिखित कर सकता है'; - - @override - String get install => 'स्थापित करें'; - - @override - String get install_a_metadata_provider => 'एक मेटाडेटा प्रदाता स्थापित करें'; - - @override - String get no_tracks_playing => 'वर्तमान में कोई ट्रैक नहीं चल रहा है'; - - @override - String get synced_lyrics_not_available => - 'इस गाने के लिए सिंक्रनाइज़ किए गए बोल उपलब्ध नहीं हैं। कृपया'; - - @override - String get plain_lyrics => 'सादे बोल'; - - @override - String get tab_instead => 'टैब का उपयोग करें।'; - - @override - String get disclaimer => 'अस्वीकरण'; - - @override - String get third_party_plugin_dmca_notice => - 'स्पॉट्यूब टीम किसी भी \"तृतीय-पक्ष\" प्लगइन के लिए कोई जिम्मेदारी (कानूनी सहित) नहीं लेती है।\nकृपया उन्हें अपने जोखिम पर उपयोग करें। किसी भी बग/समस्या के लिए, कृपया उन्हें प्लगइन रिपॉजिटरी को रिपोर्ट करें।\n\nयदि कोई \"तृतीय-पक्ष\" प्लगइन किसी सेवा/कानूनी इकाई के ToS/DMCA को तोड़ रहा है, तो कृपया \"तृतीय-पक्ष\" प्लगइन लेखक या होस्टिंग प्लेटफ़ॉर्म जैसे GitHub/Codeberg से कार्रवाई करने के लिए कहें। ऊपर सूचीबद्ध (\"तृतीय-पक्ष\" लेबल वाले) सभी सार्वजनिक/समुदाय-द्वारा-रखरखाव किए गए प्लगइन हैं। हम उन्हें क्यूरेट नहीं कर रहे हैं, इसलिए हम उन पर कोई कार्रवाई नहीं कर सकते हैं।\n\n'; - - @override - String get input_does_not_match_format => - 'इनपुट आवश्यक प्रारूप से मेल नहीं खाता है'; - - @override - String get plugins => 'प्लगइन्स'; - - @override - String get paste_plugin_download_url => - 'डाउनलोड यूआरएल या गिटहब/कोडबर्ग रेपो यूआरएल या .smplug फ़ाइल का सीधा लिंक पेस्ट करें'; - - @override - String get download_and_install_plugin_from_url => - 'यूआरएल से प्लगइन डाउनलोड और स्थापित करें'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'प्लगइन जोड़ने में विफल: $error'; - } - - @override - String get upload_plugin_from_file => 'फ़ाइल से प्लगइन अपलोड करें'; - - @override - String get installed => 'स्थापित'; - - @override - String get available_plugins => 'उपलब्ध प्लगइन'; - - @override - String get configure_plugins => - 'अपने स्वयं के मेटाडेटा प्रदाता और ऑडियो स्रोत प्लगइन्स कॉन्फ़िगर करें'; - - @override - String get audio_scrobblers => 'ऑडियो स्क्रॉबलर्स'; - - @override - String get scrobbling => 'स्क्रॉबलिंग'; - - @override - String get source => 'स्रोत: '; - - @override - String get uncompressed => 'असंपीड़ित'; - - @override - String get dab_music_source_description => - 'ऑडियोफाइलों के लिए। उच्च-गुणवत्ता/बिना हानि वाले ऑडियो स्ट्रीम प्रदान करता है। सटीक ISRC आधारित ट्रैक मिलान।'; -} diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart deleted file mode 100644 index ce250425..00000000 --- a/lib/l10n/generated/app_localizations_id.dart +++ /dev/null @@ -1,1572 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Indonesian (`id`). -class AppLocalizationsId extends AppLocalizations { - AppLocalizationsId([String locale = 'id']) : super(locale); - - @override - String get guest => 'Tamu'; - - @override - String get browse => 'Jelajahi'; - - @override - String get search => 'Cari'; - - @override - String get library => 'Pustaka'; - - @override - String get lyrics => 'Lirik'; - - @override - String get settings => 'Pengaturan'; - - @override - String get genre_categories_filter => 'Urutkan kategori atau genre...'; - - @override - String get genre => 'Genre'; - - @override - String get personalized => 'Dipersonalisasi'; - - @override - String get featured => 'Unggulan'; - - @override - String get new_releases => 'Rilis Terbaru'; - - @override - String get songs => 'Lagu'; - - @override - String playing_track(Object track) { - return 'Memutar $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Ini akan menghapus antrian saat ini This will clear the current queue. $track_length trek akan dihapus\nAnda ingin melanjutkan?'; - } - - @override - String get load_more => 'Lebih Banyak'; - - @override - String get playlists => 'Daftar Putar'; - - @override - String get artists => 'Artis'; - - @override - String get albums => 'Album'; - - @override - String get tracks => 'Trek'; - - @override - String get downloads => 'Unduhan'; - - @override - String get filter_playlists => 'Urutkan daftar putar Anda...'; - - @override - String get liked_tracks => 'Lagu Yang Disukai'; - - @override - String get liked_tracks_description => 'Semua lagu yang Anda sukai'; - - @override - String get playlist => 'Playlist'; - - @override - String get create_a_playlist => 'Buat daftar putar'; - - @override - String get update_playlist => 'Ubah daftar putar'; - - @override - String get create => 'Buat'; - - @override - String get cancel => 'Batal'; - - @override - String get update => 'Ubah'; - - @override - String get playlist_name => 'Nama Daftar Putar'; - - @override - String get name_of_playlist => 'Nama daftar putar'; - - @override - String get description => 'Deskripsi'; - - @override - String get public => 'Publik'; - - @override - String get collaborative => 'Kolaboratif'; - - @override - String get search_local_tracks => 'Cari trek lokal...'; - - @override - String get play => 'Putar'; - - @override - String get delete => 'Hapus'; - - @override - String get none => 'Tidak Ada'; - - @override - String get sort_a_z => 'Urutkan berdasarkan A-Z'; - - @override - String get sort_z_a => 'Urutkan berdasarkan Z-A'; - - @override - String get sort_artist => 'Urutkan berdasarkan Artis'; - - @override - String get sort_album => 'Urutkan berdasarkan Album'; - - @override - String get sort_duration => 'Urutkan berdasarkan Durasi'; - - @override - String get sort_tracks => 'Urutkan trek'; - - @override - String currently_downloading(Object tracks_length) { - return 'Sedang Mengunduh ($tracks_length)'; - } - - @override - String get cancel_all => 'Batalkan Semua'; - - @override - String get filter_artist => 'Urutkan artis...'; - - @override - String followers(Object followers) { - return '$followers Pengikut'; - } - - @override - String get add_artist_to_blacklist => 'Tambah artis ke daftar hitam'; - - @override - String get top_tracks => 'Lagu Teratas'; - - @override - String get fans_also_like => 'Penggemar juga menyukainya'; - - @override - String get loading => 'Memuat...'; - - @override - String get artist => 'Artis'; - - @override - String get blacklisted => 'Masuk Daftar Hitam'; - - @override - String get following => 'Mengikuti'; - - @override - String get follow => 'Ikuti'; - - @override - String get artist_url_copied => 'URL artis telah disalin'; - - @override - String added_to_queue(Object tracks) { - return 'Menambah trek $tracks ke antrean'; - } - - @override - String get filter_albums => 'Urutkan album...'; - - @override - String get synced => 'Disinkronkan'; - - @override - String get plain => 'Normal'; - - @override - String get shuffle => 'Acak'; - - @override - String get search_tracks => 'Cari trek...'; - - @override - String get released => 'Dirilis'; - - @override - String error(Object error) { - return 'Kesalahan $error'; - } - - @override - String get title => 'Judul'; - - @override - String get time => 'Waktu'; - - @override - String get more_actions => 'Tindakan Lainnya'; - - @override - String download_count(Object count) { - return 'Unduhan ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Menambah ($count) ke Daftar Putar'; - } - - @override - String add_count_to_queue(Object count) { - return 'Menambah ($count) ke Antrian'; - } - - @override - String play_count_next(Object count) { - return 'Mainkan ($count) selanjutnya'; - } - - @override - String get album => 'Album'; - - @override - String copied_to_clipboard(Object data) { - return '$data telah disalin'; - } - - @override - String add_to_following_playlists(Object track) { - return 'Menambah $track ke Daftar Putar berikut'; - } - - @override - String get add => 'Tambah'; - - @override - String added_track_to_queue(Object track) { - return 'Menambah $track ke antrian'; - } - - @override - String get add_to_queue => 'Tambah ke antrian'; - - @override - String track_will_play_next(Object track) { - return '$track akan diputar berikutnya'; - } - - @override - String get play_next => 'Mainkan selanjutnya'; - - @override - String removed_track_from_queue(Object track) { - return 'Menghapus $track dari antrian'; - } - - @override - String get remove_from_queue => 'Hapus dari antrian'; - - @override - String get remove_from_favorites => 'Hapus dari favorit'; - - @override - String get save_as_favorite => 'Simpan sebagai favorit'; - - @override - String get add_to_playlist => 'Tambah ke daftar putar'; - - @override - String get remove_from_playlist => 'Hapus dari daftar putar'; - - @override - String get add_to_blacklist => 'Tambah ke daftar hitam'; - - @override - String get remove_from_blacklist => 'Hapus dari daftar hitam'; - - @override - String get share => 'Bagikan'; - - @override - String get mini_player => 'Pemutar Mini'; - - @override - String get slide_to_seek => 'Geser untuk maju atau mundur'; - - @override - String get shuffle_playlist => 'Acak daftar putar'; - - @override - String get unshuffle_playlist => 'Batalkan pengacakan daftar putar'; - - @override - String get previous_track => 'Lagu sebelumnya'; - - @override - String get next_track => 'Lagu berikutnya'; - - @override - String get pause_playback => 'Jeda Pemutaran'; - - @override - String get resume_playback => 'Lanjutkan Pemutaran'; - - @override - String get loop_track => 'Ulangi Pemutaran'; - - @override - String get no_loop => 'No loop'; - - @override - String get repeat_playlist => 'Ulangi daftar putar'; - - @override - String get queue => 'Antrian'; - - @override - String get alternative_track_sources => 'Sumber trek alternatif'; - - @override - String get download_track => 'Unduh lagu'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks trek dalam antrian'; - } - - @override - String get clear_all => 'Bersihkan semua'; - - @override - String get show_hide_ui_on_hover => - 'Tampil/Sembunyikan UI saat mengarahkan kursor'; - - @override - String get always_on_top => 'Selalu di atas'; - - @override - String get exit_mini_player => 'Keluar Pemutar Mini'; - - @override - String get download_location => 'Lokasi unduhan'; - - @override - String get local_library => 'Perpustakaan lokal'; - - @override - String get add_library_location => 'Tambahkan ke perpustakaan'; - - @override - String get remove_library_location => 'Hapus dari perpustakaan'; - - @override - String get account => 'Akun'; - - @override - String get logout => 'Keluar'; - - @override - String get logout_of_this_account => 'Keluar dari akun'; - - @override - String get language_region => 'Bahasa & Wilayah'; - - @override - String get language => 'Bahasa'; - - @override - String get system_default => 'Bawaan Sistem'; - - @override - String get market_place_region => 'Wilayah Pasar'; - - @override - String get recommendation_country => 'Negara Rekomendasi'; - - @override - String get appearance => 'Tampilan'; - - @override - String get layout_mode => 'Mode Tata Letak'; - - @override - String get override_layout_settings => - 'Ganti pengaturan mode tata letak responsif'; - - @override - String get adaptive => 'Adaptif'; - - @override - String get compact => 'Ringkas'; - - @override - String get extended => 'Diperluas'; - - @override - String get theme => 'Tema'; - - @override - String get dark => 'Gelap'; - - @override - String get light => 'Terang'; - - @override - String get system => 'Sistem'; - - @override - String get accent_color => 'Warna Aksen'; - - @override - String get sync_album_color => 'Sinkronkan warna album'; - - @override - String get sync_album_color_description => - 'Menggunakan warna dominan sampul album sebagai warna aksen'; - - @override - String get playback => 'Pemutaran'; - - @override - String get audio_quality => 'Kualitas Suara'; - - @override - String get high => 'Tinggi'; - - @override - String get low => 'Rendah'; - - @override - String get pre_download_play => 'Unduh dan putar'; - - @override - String get pre_download_play_description => - 'Daripada streaming audio, unduh byte dan mainkan (Direkomendasikan untuk pengguna bandwidth yang lebih tinggi)'; - - @override - String get skip_non_music => 'Lewati segmen non-musik (SponsorBlock)'; - - @override - String get blacklist_description => 'Lagu dan artis di daftar hitam'; - - @override - String get wait_for_download_to_finish => - 'Tunggu hingga unduhan saat ini selesai'; - - @override - String get desktop => 'Desktop'; - - @override - String get close_behavior => 'Tutup Perilaku'; - - @override - String get close => 'Tutup'; - - @override - String get minimize_to_tray => 'Perkecil ke tray'; - - @override - String get show_tray_icon => 'Tampilkan tray ikon sistem'; - - @override - String get about => 'Tentang'; - - @override - String get u_love_spotube => 'Kami tahu Anda menyukai Spotube'; - - @override - String get check_for_updates => 'Periksa pembaruan'; - - @override - String get about_spotube => 'Tentang Spotube'; - - @override - String get blacklist => 'Daftar Hitam'; - - @override - String get please_sponsor => 'Silakan Sponsor/Menyumbang'; - - @override - String get spotube_description => - 'Spotube, klien Spotify yang ringan, lintas platform, dan gratis untuk semua'; - - @override - String get version => 'Versi'; - - @override - String get build_number => 'Nomor Pembuatan'; - - @override - String get founder => 'Pendiri'; - - @override - String get repository => 'Repositori'; - - @override - String get bug_issues => 'Bug+Masalah'; - - @override - String get made_with => 'Dibuat dengan ❤️ di Bangladesh🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Lisensi'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Jangan khawatir, kredensial Anda tidak akan dikumpulkan atau dibagikan kepada siapa pun'; - - @override - String get know_how_to_login => 'Tidak tahu bagaimana melakukan ini?'; - - @override - String get follow_step_by_step_guide => 'Ikuti panduan Langkah demi Langkah'; - - @override - String cookie_name_cookie(Object name) { - return '$name Cookie'; - } - - @override - String get fill_in_all_fields => 'Silakan isi semua kolom'; - - @override - String get submit => 'Kirim'; - - @override - String get exit => 'Keluar'; - - @override - String get previous => 'Sebelumnya'; - - @override - String get next => 'Berikutnya'; - - @override - String get done => 'Selesai'; - - @override - String get step_1 => 'Langkah 1'; - - @override - String get first_go_to => 'Pertama, Pergi ke'; - - @override - String get something_went_wrong => 'Terjadi kesalahan'; - - @override - String get piped_instance => 'Piped Server Instance'; - - @override - String get piped_description => - 'The Piped server instance untuk digunakan sebagai pencocokan trek'; - - @override - String get piped_warning => - 'Beberapa di antaranya mungkin tidak berfungsi dengan baik. Jadi gunakan dengan risiko Anda sendiri'; - - @override - String get invidious_instance => 'Invidious Server Instance'; - - @override - String get invidious_description => - 'The Invidious server instance to use for track matching'; - - @override - String get invidious_warning => - 'Some of them might not work well. So use at your own risk'; - - @override - String get generate => 'Generate'; - - @override - String track_exists(Object track) { - return 'Lagu $track sudah ada'; - } - - @override - String get replace_downloaded_tracks => 'Ganti semua trek yang diunduh'; - - @override - String get skip_download_tracks => - 'Lewati pengunduhan semua trek yang diunduh'; - - @override - String get do_you_want_to_replace => - 'Apakah Anda ingin mengganti track yang ada?'; - - @override - String get replace => 'Ganti'; - - @override - String get skip => 'Lewati'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Pilih hingga $count $type'; - } - - @override - String get select_genres => 'Pilih Genre'; - - @override - String get add_genres => 'Tambah Genre'; - - @override - String get country => 'Negara'; - - @override - String get number_of_tracks_generate => 'Jumlah trek yang akan dihasilkan'; - - @override - String get acousticness => 'Akustik'; - - @override - String get danceability => 'Menari'; - - @override - String get energy => 'Energi'; - - @override - String get instrumentalness => 'Instrumentalitas'; - - @override - String get liveness => 'Kehidupan'; - - @override - String get loudness => 'Kekerasan'; - - @override - String get speechiness => 'Berbicara'; - - @override - String get valence => 'Valensi'; - - @override - String get popularity => 'Popularitas'; - - @override - String get key => 'Kunci'; - - @override - String get duration => 'Durasi (s)'; - - @override - String get tempo => 'Tempo (BPM)'; - - @override - String get mode => 'Mode'; - - @override - String get time_signature => 'Tanda Tangan Waktu'; - - @override - String get short => 'Pendek'; - - @override - String get medium => 'Sedang'; - - @override - String get long => 'Panjang'; - - @override - String get min => 'Minimal'; - - @override - String get max => 'Maksimal'; - - @override - String get target => 'Target'; - - @override - String get moderate => 'Sedang'; - - @override - String get deselect_all => 'Batalkan Semua'; - - @override - String get select_all => 'Pilih Semua'; - - @override - String get are_you_sure => 'Anda yakin?'; - - @override - String get generating_playlist => 'Menghasilkan daftar putar khusus Anda...'; - - @override - String selected_count_tracks(Object count) { - return '$count lagu yang dipilih'; - } - - @override - String get download_warning => - 'Jika Anda mengunduh semua Lagu secara massal, Anda jelas membajak Musik & menyebabkan kerusakan pada masyarakat kreatif Musik. Saya harap Anda menyadari hal ini. Selalu berusaha menghormati & mendukung kerja keras Artis'; - - @override - String get download_ip_ban_warning => - 'BTW, IP Anda bisa diblokir di YouTube karena permintaan unduhan yang berlebihan dari biasanya. Blokir IP berarti Anda tidak dapat menggunakan YouTube (meskipun Anda masuk) setidaknya selama 2-3 bulan dari perangkat IP tersebut. Dan Spotube tidak bertanggung jawab jika hal ini terjadi'; - - @override - String get by_clicking_accept_terms => - 'Dengan mengklik \'terima\' Anda menyetujui ketentuan berikut:'; - - @override - String get download_agreement_1 => - 'Saya tahu saya membajak Musik. Saya buruk'; - - @override - String get download_agreement_2 => - 'Saya akan mendukung Artis di mana pun saya bisa dan saya melakukan ini hanya karena saya tidak punya uang untuk membeli karya seni mereka'; - - @override - String get download_agreement_3 => - 'Saya sepenuhnya menyadari bahwa IP saya dapat diblokir di YouTube & saya tidak menganggap Spotube atau pemilik/kontributornya bertanggung jawab atas kecelakaan apa pun yang disebabkan oleh tindakan saya saat ini'; - - @override - String get decline => 'Menolak'; - - @override - String get accept => 'Setuju'; - - @override - String get details => 'Detail'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Channel'; - - @override - String get likes => 'Suka'; - - @override - String get dislikes => 'Tidak Suka'; - - @override - String get views => 'Dilihat'; - - @override - String get streamUrl => 'URL Stream'; - - @override - String get stop => 'Berhenti'; - - @override - String get sort_newest => 'Urutkan yang baru ditambah'; - - @override - String get sort_oldest => 'Urutkan yang paling lama ditambah'; - - @override - String get sleep_timer => 'Pengatur Waktu Tidur'; - - @override - String mins(Object minutes) { - return '$minutes Menit'; - } - - @override - String hours(Object hours) { - return '$hours Jam'; - } - - @override - String hour(Object hours) { - return '$hours Jam'; - } - - @override - String get custom_hours => 'Jam Kostum'; - - @override - String get logs => 'Log'; - - @override - String get developers => 'Pengembang'; - - @override - String get not_logged_in => 'Anda belum masuk'; - - @override - String get search_mode => 'Mode Pencarian'; - - @override - String get audio_source => 'Sumber Suara'; - - @override - String get ok => 'OK'; - - @override - String get failed_to_encrypt => 'Gagal mengenkripsi'; - - @override - String get encryption_failed_warning => - 'Spotube menggunakan enkripsi untuk menyimpan data Anda dengan aman. Namun gagal melakukannya. Jadi itu akan kembali ke penyimpanan yang tidak aman\nJika Anda menggunakan linux, pastikan Anda telah menginstal layanan rahasia (gnome-keyring, kde-wallet, keepassxc, dll)'; - - @override - String get querying_info => 'Mencari informasi...'; - - @override - String get piped_api_down => 'Piped API tidak aktif'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'Piped Instance $pipedInstance saat ini tidak aktif\n\nUbah instance atau ubah \'jenis API\' menjadi API YouTube resmi\n\nPastikan untuk memulai ulang aplikasi setelah perubahan'; - } - - @override - String get you_are_offline => 'Anda sedang offline'; - - @override - String get connection_restored => 'Koneksi internet Anda telah pulih'; - - @override - String get use_system_title_bar => 'Gunakan bilah judul sistem'; - - @override - String get crunching_results => 'Mengolah hasil...'; - - @override - String get search_to_get_results => 'Cari untuk mendapatkan hasil'; - - @override - String get use_amoled_mode => 'Tema gelap gulita'; - - @override - String get pitch_dark_theme => 'Mode AMOLED'; - - @override - String get normalize_audio => 'Normalisasi audio'; - - @override - String get change_cover => 'Ganti sampul'; - - @override - String get add_cover => 'Tambah sampul'; - - @override - String get restore_defaults => 'Kembalikan semula'; - - @override - String get download_music_format => 'Format unduh musik'; - - @override - String get streaming_music_format => 'Format streaming musik'; - - @override - String get download_music_quality => 'Kualitas unduh musik'; - - @override - String get streaming_music_quality => 'Kualitas streaming musik'; - - @override - String get login_with_lastfm => 'Masuk dengan Last.fm'; - - @override - String get connect => 'Hubungkan'; - - @override - String get disconnect_lastfm => 'Memutuskan Last.fm'; - - @override - String get disconnect => 'Memutuskan'; - - @override - String get username => 'Username'; - - @override - String get password => 'Password'; - - @override - String get login => 'Masuk'; - - @override - String get login_with_your_lastfm => 'Masuk dengan Last.fm Anda'; - - @override - String get scrobble_to_lastfm => 'Scrobble ke Last.fm'; - - @override - String get go_to_album => 'Pergi ke Album'; - - @override - String get discord_rich_presence => 'Discord Rich Presence'; - - @override - String get browse_all => 'Lihat Semua'; - - @override - String get genres => 'Genre'; - - @override - String get explore_genres => 'Jelajahi Genre'; - - @override - String get friends => 'Daftar Teman'; - - @override - String get no_lyrics_available => - 'Maaf, tidak dapat menemukan lirik untuk lagu ini'; - - @override - String get start_a_radio => 'Putar Radio'; - - @override - String get how_to_start_radio => 'Bagaimana Anda ingin memutar radio?'; - - @override - String get replace_queue_question => - 'Apakah Anda ingin mengganti antrean saat ini atau menambahkannya?'; - - @override - String get endless_playback => 'Pemutaran Tanpa Akhir'; - - @override - String get delete_playlist => 'Hapus Daftar Putar'; - - @override - String get delete_playlist_confirmation => - 'Anda yakin ingin menghapus daftar putar ini?'; - - @override - String get local_tracks => 'Trek Lokal'; - - @override - String get local_tab => 'Lokal'; - - @override - String get song_link => 'Tautan Lagu'; - - @override - String get skip_this_nonsense => 'Lewati omong kosong ini'; - - @override - String get freedom_of_music => '“Kebebasan Musik”'; - - @override - String get freedom_of_music_palm => - '“Kebebasan Musik di telapak tangan Anda”'; - - @override - String get get_started => 'Mari kita mulai'; - - @override - String get youtube_source_description => - 'Direkomendasikan dan berfungsi paling baik.'; - - @override - String get piped_source_description => - 'Merasa bebas? Sama seperti YouTube tetapi banyak yang gratis.'; - - @override - String get jiosaavn_source_description => - 'Terbaik untuk wilayah Asia Selatan.'; - - @override - String get invidious_source_description => - 'Similar to Piped but with higher availability.'; - - @override - String highest_quality(Object quality) { - return 'Kualitas Terbaik: $quality'; - } - - @override - String get select_audio_source => 'Pilih Sumber Suara'; - - @override - String get endless_playback_description => - 'Tambahkan lagu baru secara otomatis\nke akhir antrean'; - - @override - String get choose_your_region => 'Pilih wilayah Anda'; - - @override - String get choose_your_region_description => - 'Ini akan membantu Spotube menampilkan konten yang tepat\nuntuk lokasi Anda.'; - - @override - String get choose_your_language => 'Pilih bahasa Anda'; - - @override - String get help_project_grow => 'Bantu proyek ini berkembang'; - - @override - String get help_project_grow_description => - 'Spotube adalah proyek sumber terbuka. Anda dapat membantu proyek ini berkembang dengan berkontribusi pada proyek, melaporkan bug, atau menyarankan fitur baru.'; - - @override - String get contribute_on_github => 'Berkontribusi di GitHub'; - - @override - String get donate_on_open_collective => 'Donasi di Open Collective'; - - @override - String get browse_anonymously => 'Jelajahi Secara Anonim'; - - @override - String get enable_connect => 'Aktifkan Hubungkan'; - - @override - String get enable_connect_description => - 'Kontrol Spotube dari perangkat lain'; - - @override - String get devices => 'Perangkat'; - - @override - String get select => 'Pilih'; - - @override - String connect_client_alert(Object client) { - return 'Anda dikendalikan oleh $client'; - } - - @override - String get this_device => 'Perangkat Ini'; - - @override - String get remote => 'Remot'; - - @override - String get stats => 'Statistik'; - - @override - String and_n_more(Object count) { - return 'dan $count lainnya'; - } - - @override - String get recently_played => 'Baru saja diputar'; - - @override - String get browse_more => 'Telusuri lebih banyak'; - - @override - String get no_title => 'Tanpa judul'; - - @override - String get not_playing => 'Tidak diputar'; - - @override - String get epic_failure => 'Kegagalan epik!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'Menambahkan $tracks_length trek ke antrean'; - } - - @override - String get spotube_has_an_update => 'Spotube memiliki pembaruan'; - - @override - String get download_now => 'Unduh sekarang'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum telah dirilis'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version telah dirilis'; - } - - @override - String get read_the_latest => 'Baca yang terbaru '; - - @override - String get release_notes => 'catatan rilis'; - - @override - String get pick_color_scheme => 'Pilih skema warna'; - - @override - String get save => 'Simpan'; - - @override - String get choose_the_device => 'Pilih perangkat:'; - - @override - String get multiple_device_connected => - 'Beberapa perangkat terhubung.\nPilih perangkat tempat Anda ingin melakukan tindakan ini'; - - @override - String get nothing_found => 'Tidak ditemukan apa pun'; - - @override - String get the_box_is_empty => 'Kotak kosong'; - - @override - String get top_artists => 'Artis Teratas'; - - @override - String get top_albums => 'Album Teratas'; - - @override - String get this_week => 'Minggu ini'; - - @override - String get this_month => 'Bulan ini'; - - @override - String get last_6_months => '6 bulan terakhir'; - - @override - String get this_year => 'Tahun ini'; - - @override - String get last_2_years => '2 tahun terakhir'; - - @override - String get all_time => 'Sepanjang waktu'; - - @override - String powered_by_provider(Object providerName) { - return 'Didukung oleh $providerName'; - } - - @override - String get email => 'Email'; - - @override - String get profile_followers => 'Pengikut'; - - @override - String get birthday => 'Ulang Tahun'; - - @override - String get subscription => 'Langganan'; - - @override - String get not_born => 'Belum lahir'; - - @override - String get hacker => 'Hacker'; - - @override - String get profile => 'Profil'; - - @override - String get no_name => 'Tanpa nama'; - - @override - String get edit => 'Edit'; - - @override - String get user_profile => 'Profil pengguna'; - - @override - String count_plays(Object count) { - return '$count pemutaran'; - } - - @override - String get streaming_fees_hypothetical => 'Biaya streaming (hipotetis)'; - - @override - String get minutes_listened => 'Menit didengarkan'; - - @override - String get streamed_songs => 'Lagu yang disiarkan'; - - @override - String count_streams(Object count) { - return '$count streams'; - } - - @override - String get owned_by_you => 'Dimiliki oleh Anda'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl disalin ke clipboard'; - } - - @override - String get hipotetical_calculation => - '*Ini dihitung berdasarkan pembayaran rata-rata per streaming dari platform streaming musik online sebesar \$0,003 hingga \$0,005. Ini adalah perhitungan hipotetis untuk memberikan wawasan kepada pengguna tentang seberapa banyak yang akan mereka bayarkan kepada artis jika mereka mendengarkan lagu mereka di platform streaming musik yang berbeda.'; - - @override - String count_mins(Object minutes) { - return '$minutes menit'; - } - - @override - String get summary_minutes => 'menit'; - - @override - String get summary_listened_to_music => 'Mendengarkan musik'; - - @override - String get summary_songs => 'lagu'; - - @override - String get summary_streamed_overall => 'Disiarkan secara keseluruhan'; - - @override - String get summary_owed_to_artists => 'Terhutang kepada artis\nBulan ini'; - - @override - String get summary_artists => 'artis'; - - @override - String get summary_music_reached_you => 'Musik mencapai Anda'; - - @override - String get summary_full_albums => 'album lengkap'; - - @override - String get summary_got_your_love => 'Mendapatkan cinta Anda'; - - @override - String get summary_playlists => 'daftar putar'; - - @override - String get summary_were_on_repeat => 'Sedang diulang'; - - @override - String total_money(Object money) { - return 'Total $money'; - } - - @override - String get webview_not_found => 'Webview tidak ditemukan'; - - @override - String get webview_not_found_description => - 'Tidak ada runtime Webview yang diinstal di perangkat Anda.\nJika sudah diinstal, pastikan itu ada di environment PATH\n\nSetelah diinstal, restart aplikasi'; - - @override - String get unsupported_platform => 'Platform tidak didukung'; - - @override - String get cache_music => 'Cache music'; - - @override - String get open => 'Open'; - - @override - String get cache_folder => 'Cache folder'; - - @override - String get export => 'Export'; - - @override - String get clear_cache => 'Clear cache'; - - @override - String get clear_cache_confirmation => 'Do you want to clear the cache?'; - - @override - String get export_cache_files => 'Export Cached Files'; - - @override - String found_n_files(Object count) { - return 'Found $count files'; - } - - @override - String get export_cache_confirmation => - 'Do you want to export these files to'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return 'Exported $filesExported out of $files files'; - } - - @override - String get undo => 'Undo'; - - @override - String get download_all => 'Download all'; - - @override - String get add_all_to_playlist => 'Add all to playlist'; - - @override - String get add_all_to_queue => 'Add all to queue'; - - @override - String get play_all_next => 'Play all next'; - - @override - String get pause => 'Pause'; - - @override - String get view_all => 'View all'; - - @override - String get no_tracks_added_yet => - 'Looks like you haven\'t added any tracks yet'; - - @override - String get no_tracks => 'Looks like there are no tracks here'; - - @override - String get no_tracks_listened_yet => - 'Looks like you haven\'t listened to anything yet'; - - @override - String get not_following_artists => 'You\'re not following any artists'; - - @override - String get no_favorite_albums_yet => - 'Looks like you haven\'t added any albums to your favorites yet'; - - @override - String get no_logs_found => 'No logs found'; - - @override - String get youtube_engine => 'YouTube Engine'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine is not installed'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine is not installed in your system.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Make sure it\'s available in the PATH variable or\nset the absolute path to the $engine executable below'; - } - - @override - String get youtube_engine_unix_issue_message => - 'In macOS/Linux/unix like OS\'s, setting path on .zshrc/.bashrc/.bash_profile etc. won\'t work.\nYou need to set the path in the shell configuration file'; - - @override - String get download => 'Download'; - - @override - String get file_not_found => 'File not found'; - - @override - String get custom => 'Custom'; - - @override - String get add_custom_url => 'Add custom URL'; - - @override - String get edit_port => 'Edit port'; - - @override - String get port_helper_msg => - 'Default adalah -1 yang menunjukkan angka acak. Jika Anda telah mengonfigurasi firewall, disarankan untuk mengatur ini.'; - - @override - String connect_request(Object client) { - return 'Izinkan $client untuk terhubung?'; - } - - @override - String get connection_request_denied => - 'Koneksi ditolak. Pengguna menolak akses.'; - - @override - String get an_error_occurred => 'Terjadi kesalahan'; - - @override - String get copy_to_clipboard => 'Salin ke papan klip'; - - @override - String get view_logs => 'Lihat log'; - - @override - String get retry => 'Coba lagi'; - - @override - String get no_default_metadata_provider_selected => - 'Anda belum mengatur penyedia metadata default'; - - @override - String get manage_metadata_providers => 'Kelola penyedia metadata'; - - @override - String get open_link_in_browser => 'Buka Tautan di Peramban?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Apakah Anda ingin membuka tautan berikut'; - - @override - String get unsafe_url_warning => - 'Tidak aman untuk membuka tautan dari sumber yang tidak tepercaya. Berhati-hatilah!\nAnda juga dapat menyalin tautan ke papan klip Anda.'; - - @override - String get copy_link => 'Salin Tautan'; - - @override - String get building_your_timeline => - 'Membangun garis waktu Anda berdasarkan riwayat mendengarkan Anda...'; - - @override - String get official => 'Resmi'; - - @override - String author_name(Object author) { - return 'Penulis: $author'; - } - - @override - String get third_party => 'Pihak ketiga'; - - @override - String get plugin_requires_authentication => 'Plugin memerlukan otentikasi'; - - @override - String get update_available => 'Pembaruan tersedia'; - - @override - String get supports_scrobbling => 'Mendukung scrobbling'; - - @override - String get plugin_scrobbling_info => - 'Plugin ini scrobble musik Anda untuk menghasilkan riwayat mendengarkan Anda.'; - - @override - String get default_metadata_source => 'Sumber metadata default'; - - @override - String get set_default_metadata_source => 'Atur sumber metadata default'; - - @override - String get default_audio_source => 'Sumber audio default'; - - @override - String get set_default_audio_source => 'Atur sumber audio default'; - - @override - String get set_default => 'Atur sebagai bawaan'; - - @override - String get support => 'Dukungan'; - - @override - String get support_plugin_development => 'Dukung pengembangan plugin'; - - @override - String can_access_name_api(Object name) { - return '- Dapat mengakses API **$name**'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Apakah Anda ingin menginstal plugin ini?'; - - @override - String get third_party_plugin_warning => - 'Plugin ini berasal dari repositori pihak ketiga. Pastikan Anda memercayai sumbernya sebelum menginstal.'; - - @override - String get author => 'Penulis'; - - @override - String get this_plugin_can_do_following => - 'Plugin ini dapat melakukan hal berikut'; - - @override - String get install => 'Instal'; - - @override - String get install_a_metadata_provider => 'Instal Penyedia Metadata'; - - @override - String get no_tracks_playing => 'Tidak ada Lagu yang sedang diputar saat ini'; - - @override - String get synced_lyrics_not_available => - 'Lirik tersinkronisasi tidak tersedia untuk lagu ini. Silakan gunakan tab'; - - @override - String get plain_lyrics => 'Lirik Polos'; - - @override - String get tab_instead => 'sebagai gantinya.'; - - @override - String get disclaimer => 'Penafian'; - - @override - String get third_party_plugin_dmca_notice => - 'Tim Spotube tidak bertanggung jawab (termasuk hukum) atas plugin \"Pihak ketiga\" mana pun.\nSilakan gunakan dengan risiko Anda sendiri. Untuk bug/masalah apa pun, silakan laporkan ke repositori plugin.\n\nJika ada plugin \"Pihak ketiga\" yang melanggar ToS/DMCA dari layanan/entitas hukum mana pun, silakan minta penulis plugin \"Pihak ketiga\" atau platform hosting, mis. GitHub/Codeberg, untuk mengambil tindakan. Yang tercantum di atas (berlabel \"Pihak ketiga\") adalah semua plugin publik/yang dikelola oleh komunitas. Kami tidak mengkurasi mereka, jadi kami tidak dapat mengambil tindakan apa pun terhadap mereka.\n\n'; - - @override - String get input_does_not_match_format => - 'Masukan tidak cocok dengan format yang diperlukan'; - - @override - String get plugins => 'Plugin'; - - @override - String get paste_plugin_download_url => - 'Tempel url unduhan atau url repo GitHub/Codeberg atau tautan langsung ke file .smplug'; - - @override - String get download_and_install_plugin_from_url => - 'Unduh dan instal plugin dari url'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Gagal menambahkan plugin: $error'; - } - - @override - String get upload_plugin_from_file => 'Unggah plugin dari file'; - - @override - String get installed => 'Terinstal'; - - @override - String get available_plugins => 'Plugin yang tersedia'; - - @override - String get configure_plugins => - 'Konfigurasi plugin penyedia metadata dan sumber audio Anda sendiri'; - - @override - String get audio_scrobblers => 'Scrobblers Audio'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Sumber: '; - - @override - String get uncompressed => 'Tidak terkompresi'; - - @override - String get dab_music_source_description => - 'Untuk audiophile. Menyediakan aliran audio berkualitas tinggi/tanpa kehilangan. Pencocokkan trek yang akurat berdasarkan ISRC.'; -} diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart deleted file mode 100644 index f2dfa5ed..00000000 --- a/lib/l10n/generated/app_localizations_it.dart +++ /dev/null @@ -1,1572 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Italian (`it`). -class AppLocalizationsIt extends AppLocalizations { - AppLocalizationsIt([String locale = 'it']) : super(locale); - - @override - String get guest => 'Ospite'; - - @override - String get browse => 'Sfoglia'; - - @override - String get search => 'Cerca'; - - @override - String get library => 'Libreria'; - - @override - String get lyrics => 'Testi'; - - @override - String get settings => 'Impostazioni'; - - @override - String get genre_categories_filter => 'Filtra categorie e generi...'; - - @override - String get genre => 'Genere'; - - @override - String get personalized => 'Personalizzato'; - - @override - String get featured => 'In evidenza'; - - @override - String get new_releases => 'Novità'; - - @override - String get songs => 'Canzoni'; - - @override - String playing_track(Object track) { - return 'Riproduzione $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Questo cancellerà la coda corrente. $track_length tracce saranno rimosse\nVuoi continuare?'; - } - - @override - String get load_more => 'Carica altro'; - - @override - String get playlists => 'Playlist'; - - @override - String get artists => 'Artisti'; - - @override - String get albums => 'Album'; - - @override - String get tracks => 'Tracce'; - - @override - String get downloads => 'Downloads'; - - @override - String get filter_playlists => 'Filtra le tue playlist...'; - - @override - String get liked_tracks => 'Tracce piaciute'; - - @override - String get liked_tracks_description => 'Tutte le tracce piaciute'; - - @override - String get playlist => 'Playlist'; - - @override - String get create_a_playlist => 'Crea una playlist'; - - @override - String get update_playlist => 'Aggiorna playlist'; - - @override - String get create => 'Crea'; - - @override - String get cancel => 'Annulla'; - - @override - String get update => 'Aggiorna'; - - @override - String get playlist_name => 'Nome Playlist'; - - @override - String get name_of_playlist => 'Nome della playlist'; - - @override - String get description => 'Descrizione'; - - @override - String get public => 'Pubblico'; - - @override - String get collaborative => 'Collaborativo'; - - @override - String get search_local_tracks => 'Cerca tracce locali...'; - - @override - String get play => 'Riproduci'; - - @override - String get delete => 'Cancella'; - - @override - String get none => 'Nessuno'; - - @override - String get sort_a_z => 'Ordina dalla A-Z'; - - @override - String get sort_z_a => 'Ordina dalla Z-A'; - - @override - String get sort_artist => 'Ordina per Artista'; - - @override - String get sort_album => 'Ordina per Album'; - - @override - String get sort_duration => 'Ordina per Durata'; - - @override - String get sort_tracks => 'Ordina tracce'; - - @override - String currently_downloading(Object tracks_length) { - return 'Attualmente in Download ($tracks_length)'; - } - - @override - String get cancel_all => 'Annulla Tutto'; - - @override - String get filter_artist => 'Filtra artisti...'; - - @override - String followers(Object followers) { - return '$followers Seguaci'; - } - - @override - String get add_artist_to_blacklist => 'Aggiungi artista alla lista nera'; - - @override - String get top_tracks => 'Tracce Top'; - - @override - String get fans_also_like => 'Ai fan piace anche'; - - @override - String get loading => 'Caricamento...'; - - @override - String get artist => 'Artista'; - - @override - String get blacklisted => 'In lista nera'; - - @override - String get following => 'Seguendo'; - - @override - String get follow => 'Segui'; - - @override - String get artist_url_copied => 'URL artista copiato negli appunti'; - - @override - String added_to_queue(Object tracks) { - return 'Aggiunto $tracks tracce alla coda'; - } - - @override - String get filter_albums => 'Filtra album...'; - - @override - String get synced => 'Sincronizzato'; - - @override - String get plain => 'Semplice'; - - @override - String get shuffle => 'Casuale'; - - @override - String get search_tracks => 'Cerca tracce...'; - - @override - String get released => 'Rilasciato'; - - @override - String error(Object error) { - return 'Errore $error'; - } - - @override - String get title => 'Titolo'; - - @override - String get time => 'Durata'; - - @override - String get more_actions => 'Più azioni'; - - @override - String download_count(Object count) { - return 'Scaricato ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Aggiungi ($count) alla playlist'; - } - - @override - String add_count_to_queue(Object count) { - return 'Aggiungi ($count) alla Coda'; - } - - @override - String play_count_next(Object count) { - return 'Riproduci ($count) prossime'; - } - - @override - String get album => 'Album'; - - @override - String copied_to_clipboard(Object data) { - return 'Copiato $data negli appunti'; - } - - @override - String add_to_following_playlists(Object track) { - return 'Aggiungi $track nelle seguenti Playlist'; - } - - @override - String get add => 'Aggiungi'; - - @override - String added_track_to_queue(Object track) { - return 'Aggiunto $track alla coda'; - } - - @override - String get add_to_queue => 'Aggiungi alla coda'; - - @override - String track_will_play_next(Object track) { - return 'in seguito sarà riprodotta $track'; - } - - @override - String get play_next => 'Riproduci prossimo'; - - @override - String removed_track_from_queue(Object track) { - return 'Rimosso $track dalla coda'; - } - - @override - String get remove_from_queue => 'Rimuovi dalla coda'; - - @override - String get remove_from_favorites => 'Rimuovi dai preferiti'; - - @override - String get save_as_favorite => 'Salva come preferito'; - - @override - String get add_to_playlist => 'Aggiungi alla playlist'; - - @override - String get remove_from_playlist => 'Rimuovi dalla playlist'; - - @override - String get add_to_blacklist => 'Aggiungi alla blacklist'; - - @override - String get remove_from_blacklist => 'Rimuovi dalla blacklist'; - - @override - String get share => 'Condividi'; - - @override - String get mini_player => 'Mini Riproduttore'; - - @override - String get slide_to_seek => 'Scorri per cercare avanti o indietro'; - - @override - String get shuffle_playlist => 'Playlist casuale'; - - @override - String get unshuffle_playlist => 'Ordina playlist'; - - @override - String get previous_track => 'Traccia precedente'; - - @override - String get next_track => 'Traccia successiva'; - - @override - String get pause_playback => 'Pausa Playback'; - - @override - String get resume_playback => 'Riprendi Playback'; - - @override - String get loop_track => 'Cicla traccia'; - - @override - String get no_loop => 'Nessun ciclo'; - - @override - String get repeat_playlist => 'Ripeti playlist'; - - @override - String get queue => 'Coda'; - - @override - String get alternative_track_sources => 'Sorgenti traccia alternative'; - - @override - String get download_track => 'Scarica traccia'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks tracce in coda'; - } - - @override - String get clear_all => 'Cancella tutto'; - - @override - String get show_hide_ui_on_hover => 'Mostra/Nascondi UI al passaggio'; - - @override - String get always_on_top => 'Sempre in cima'; - - @override - String get exit_mini_player => 'Esci da Mini player'; - - @override - String get download_location => 'Cartella di scarico'; - - @override - String get local_library => 'Biblioteca locale'; - - @override - String get add_library_location => 'Aggiungi alla biblioteca'; - - @override - String get remove_library_location => 'Rimuovi dalla biblioteca'; - - @override - String get account => 'Account'; - - @override - String get logout => 'Esci'; - - @override - String get logout_of_this_account => 'Esci da questo account'; - - @override - String get language_region => 'Lingua & Regione'; - - @override - String get language => 'Lingua'; - - @override - String get system_default => 'Default sistema'; - - @override - String get market_place_region => 'Regione del mercato'; - - @override - String get recommendation_country => 'Paese Raccomandato'; - - @override - String get appearance => 'Aspetto'; - - @override - String get layout_mode => 'Modalità Layout'; - - @override - String get override_layout_settings => - 'Sovrascrivi le impostazioni del layout responsivo'; - - @override - String get adaptive => 'Adattiva'; - - @override - String get compact => 'Compatta'; - - @override - String get extended => 'Estesa'; - - @override - String get theme => 'Tema'; - - @override - String get dark => 'Scuro'; - - @override - String get light => 'Chiaro'; - - @override - String get system => 'Sistema'; - - @override - String get accent_color => 'Colore accento'; - - @override - String get sync_album_color => 'Syncronizza colore album'; - - @override - String get sync_album_color_description => - 'Usa il colore dominante della copertina dell\'album come colore accento'; - - @override - String get playback => 'Riproduzione'; - - @override - String get audio_quality => 'Qualità Audio'; - - @override - String get high => 'Alta'; - - @override - String get low => 'Bassa'; - - @override - String get pre_download_play => 'Pre-scarica e riproduci'; - - @override - String get pre_download_play_description => - 'Anzi che effettuare lo stream dell\'audio, scarica invece i byte e li riproduce (raccomandato per gli utenti con banda più alta)'; - - @override - String get skip_non_music => 'Salta i segmenti non di musica (SponsorBlock)'; - - @override - String get blacklist_description => 'Tracce e artisti in blacklist'; - - @override - String get wait_for_download_to_finish => - 'Prego attendere che lo scaricamento corrente finisca'; - - @override - String get desktop => 'Desktop'; - - @override - String get close_behavior => 'Comportamento Chiusura'; - - @override - String get close => 'Chiudi'; - - @override - String get minimize_to_tray => 'Minimizza in tray'; - - @override - String get show_tray_icon => 'Mostra icona in tray di sistema'; - - @override - String get about => 'A proposito di'; - - @override - String get u_love_spotube => 'Sappiamo che ami Spotube'; - - @override - String get check_for_updates => 'Controlla aggiornamenti'; - - @override - String get about_spotube => 'A proposito di Spotube'; - - @override - String get blacklist => 'Blacklist'; - - @override - String get please_sponsor => 'Per favore sponsorizza/dona'; - - @override - String get spotube_description => - 'Spotube, un client spotify gratis per tutti, multipiattaforma e leggero'; - - @override - String get version => 'Versione'; - - @override - String get build_number => 'Numero Build'; - - @override - String get founder => 'Fondatore'; - - @override - String get repository => 'Repository'; - - @override - String get bug_issues => 'Bug+Problemi'; - - @override - String get made_with => 'Fatto con ❤️ in Bangladesh🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Licenza'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Non ti preoccupare, le tue credenziali non saranno inviate o condivise con nessuno'; - - @override - String get know_how_to_login => 'Non sai come farlo?'; - - @override - String get follow_step_by_step_guide => 'Segui la guida passo-passo'; - - @override - String cookie_name_cookie(Object name) { - return 'Cookie $name'; - } - - @override - String get fill_in_all_fields => 'Inserire tutti i campi'; - - @override - String get submit => 'Invia'; - - @override - String get exit => 'Esci'; - - @override - String get previous => 'Precedente'; - - @override - String get next => 'Prossimo'; - - @override - String get done => 'Finito'; - - @override - String get step_1 => 'Passo 1'; - - @override - String get first_go_to => 'Prim, vai a'; - - @override - String get something_went_wrong => 'Qualcosa è andato storto'; - - @override - String get piped_instance => 'Istanza Server Piped'; - - @override - String get piped_description => - 'L\'istanza server Piped da usare per il match della tracccia'; - - @override - String get piped_warning => - 'Alcune di queste non funzioneranno benen. Usa quindi a tuo rischio'; - - @override - String get invidious_instance => 'Istanza del server Invidious'; - - @override - String get invidious_description => - 'L\'istanza del server Invidious da utilizzare per il matching delle tracce'; - - @override - String get invidious_warning => - 'Alcuni potrebbero non funzionare bene. Usali a tuo rischio'; - - @override - String get generate => 'Genera'; - - @override - String track_exists(Object track) { - return 'La traccia $track esiste già'; - } - - @override - String get replace_downloaded_tracks => - 'Sostituisci tutte le tracce scaricate'; - - @override - String get skip_download_tracks => - 'Salta lo scaricamento di tutte le tracce scaricate'; - - @override - String get do_you_want_to_replace => - 'Vuoi sovrascrivere la traccia esistente??'; - - @override - String get replace => 'Sovrascrivi'; - - @override - String get skip => 'Salta'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Seleziona fino a $count $type'; - } - - @override - String get select_genres => 'Seleziona Generi'; - - @override - String get add_genres => 'Aggiungi Generi'; - - @override - String get country => 'Paese'; - - @override - String get number_of_tracks_generate => 'Nnumero di tracce da generare'; - - @override - String get acousticness => 'Acustica'; - - @override - String get danceability => 'Ballabilità'; - - @override - String get energy => 'Energia'; - - @override - String get instrumentalness => 'Strumentalità'; - - @override - String get liveness => 'Vitalità'; - - @override - String get loudness => 'Sonorità'; - - @override - String get speechiness => 'Loquacità'; - - @override - String get valence => 'Valenza'; - - @override - String get popularity => 'Popolarità'; - - @override - String get key => 'Chiave'; - - @override - String get duration => 'Durata (s)'; - - @override - String get tempo => 'Tempo (BPM)'; - - @override - String get mode => 'Modo'; - - @override - String get time_signature => 'Indicazione di tempo'; - - @override - String get short => 'Corta'; - - @override - String get medium => 'Media'; - - @override - String get long => 'Lunga'; - - @override - String get min => 'Min'; - - @override - String get max => 'Max'; - - @override - String get target => 'Obiettivo'; - - @override - String get moderate => 'Moderato'; - - @override - String get deselect_all => 'Deseleziona Tutto'; - - @override - String get select_all => 'Seleziona Tutto'; - - @override - String get are_you_sure => 'Sei certo?'; - - @override - String get generating_playlist => 'Generazione delle tue playlist custom...'; - - @override - String selected_count_tracks(Object count) { - return '$count tracce selezionate'; - } - - @override - String get download_warning => - 'Se scarichi tutte le Tracce in massa stai chiaramente piratando Musica e causando un danno alla società creativa della Musica. Spero che tu sia cosciente di questo. Cerca di rispettare e supportare sempre il duro lavoro degli Artisti'; - - @override - String get download_ip_ban_warning => - 'A proposito, il tuo IP può essere bloccato da YouTube per il numero di richieste di download eccessive rispetto la norma. Il blocco IP significa che non puoi usare YoutTube (anche hai effettuato l\'accesso) per almeno 2-3 mesi dal dispositivo con questo IP. Spotube non ha responsabilità se questo dovesse accadere'; - - @override - String get by_clicking_accept_terms => - 'Cliccando su \'accetta\' concordi con i seguenti termini:'; - - @override - String get download_agreement_1 => - 'So che sto piratando Musica. Sono cattivo'; - - @override - String get download_agreement_2 => - 'Supporterò l\'Artista come potrò e sto facendo questo solo perchè non ho denaro per acquistare il suo prodotto dell\'ingegno'; - - @override - String get download_agreement_3 => - 'Sono completamente cosciente che il mio IP può essere bloccato da YouTube & non riterrò responsabili Spotube o i suoi autori/contributori per ogni inconveniente causato dalla mia azione corrente'; - - @override - String get decline => 'Declino'; - - @override - String get accept => 'Accetto'; - - @override - String get details => 'Dettagli'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Canale'; - - @override - String get likes => 'Mi Piace'; - - @override - String get dislikes => 'Non Mi Piace'; - - @override - String get views => 'Viste'; - - @override - String get streamUrl => 'URL dello streaming'; - - @override - String get stop => 'Stop'; - - @override - String get sort_newest => 'Ordina per nuovi aggiunti'; - - @override - String get sort_oldest => 'Ordina per aggiunta più vecchia'; - - @override - String get sleep_timer => 'Timer Dormire'; - - @override - String mins(Object minutes) { - return '$minutes Minuti'; - } - - @override - String hours(Object hours) { - return '$hours Ore'; - } - - @override - String hour(Object hours) { - return '$hours Ora'; - } - - @override - String get custom_hours => 'Orari Personalizzati'; - - @override - String get logs => 'Log'; - - @override - String get developers => 'Sviluppatori'; - - @override - String get not_logged_in => 'Non hai effettuato l\'accesso'; - - @override - String get search_mode => 'Modalità Ricerca'; - - @override - String get audio_source => 'Fonte audio'; - - @override - String get ok => 'Ok'; - - @override - String get failed_to_encrypt => 'Criptazione fallita'; - - @override - String get encryption_failed_warning => - 'Spotube usa la criptazione per memorizzare in modo sicuro i dati. Ma ha fallito a farlo. Passerà quindi in ripiego alla memorizzazione non siscura\nSe stai usando Linux assicurati di avere un servizio di segretezza installato (gnome-keyring, kde-wallet, keepassxc etc)'; - - @override - String get querying_info => 'Richiesta informazioni...'; - - @override - String get piped_api_down => 'Le Piped API non funzionano'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'L\'istanza di Piped $pipedInstance è correntemente offline\n\nCambia istanza o cambia \'Tipo API\' alle API ufficiali YouTube\n\nAssicurati di riavviare l\'app dopo il cambio'; - } - - @override - String get you_are_offline => 'Sei correntemente offline'; - - @override - String get connection_restored => 'Connessione ad internet ripristinata'; - - @override - String get use_system_title_bar => 'Usa la barra del titolo di sistema'; - - @override - String get crunching_results => 'Elaborazione risultati...'; - - @override - String get search_to_get_results => 'Cerca per ottenere risultati'; - - @override - String get use_amoled_mode => 'Usa modalità AMOLED'; - - @override - String get pitch_dark_theme => 'Tema nero profondo'; - - @override - String get normalize_audio => 'Normalizza audio'; - - @override - String get change_cover => 'Cambia copertina'; - - @override - String get add_cover => 'Aggiungi copertina'; - - @override - String get restore_defaults => 'Ripristina default'; - - @override - String get download_music_format => 'Formato download musica'; - - @override - String get streaming_music_format => 'Formato streaming musica'; - - @override - String get download_music_quality => 'Qualità download musica'; - - @override - String get streaming_music_quality => 'Qualità streaming musica'; - - @override - String get login_with_lastfm => 'Accesso a Last.fm'; - - @override - String get connect => 'Connetti'; - - @override - String get disconnect_lastfm => 'Disconnetti Last.fm'; - - @override - String get disconnect => 'Disconnetti'; - - @override - String get username => 'Nome utente'; - - @override - String get password => 'Password'; - - @override - String get login => 'Accesso'; - - @override - String get login_with_your_lastfm => 'Accedi con il tuo account Last.fm'; - - @override - String get scrobble_to_lastfm => 'Invia a Last.fm'; - - @override - String get go_to_album => 'Vai all\'album'; - - @override - String get discord_rich_presence => 'Presenza ricca di Discord'; - - @override - String get browse_all => 'Esplora tutto'; - - @override - String get genres => 'Generi'; - - @override - String get explore_genres => 'Esplora generi'; - - @override - String get friends => 'Amici'; - - @override - String get no_lyrics_available => - 'Spiacente, impossibile trovare il testo di questa traccia'; - - @override - String get start_a_radio => 'Avvia una Radio'; - - @override - String get how_to_start_radio => 'Come vuoi avviare la radio?'; - - @override - String get replace_queue_question => - 'Vuoi sostituire la coda attuale o aggiungerla?'; - - @override - String get endless_playback => 'Riproduzione Infinita'; - - @override - String get delete_playlist => 'Elimina Playlist'; - - @override - String get delete_playlist_confirmation => - 'Sei sicuro di voler eliminare questa playlist?'; - - @override - String get local_tracks => 'Tracce Locali'; - - @override - String get local_tab => 'Locale'; - - @override - String get song_link => 'Link della Canzone'; - - @override - String get skip_this_nonsense => 'Salta questa sciocchezza'; - - @override - String get freedom_of_music => '“Libertà della Musica”'; - - @override - String get freedom_of_music_palm => - '“Libertà della Musica nel palmo della tua mano”'; - - @override - String get get_started => 'Cominciamo'; - - @override - String get youtube_source_description => 'Consigliato e funziona meglio.'; - - @override - String get piped_source_description => - 'Ti senti libero? Come YouTube ma molto più gratuito.'; - - @override - String get jiosaavn_source_description => - 'Il migliore per la regione dell\'Asia meridionale.'; - - @override - String get invidious_source_description => - 'Simile a Piped ma con maggiore disponibilità.'; - - @override - String highest_quality(Object quality) { - return 'Massima Qualità: $quality'; - } - - @override - String get select_audio_source => 'Seleziona Sorgente Audio'; - - @override - String get endless_playback_description => - 'Aggiungi automaticamente nuove canzoni alla fine della coda'; - - @override - String get choose_your_region => 'Scegli la tua regione'; - - @override - String get choose_your_region_description => - 'Questo aiuterà Spotube a mostrarti il contenuto giusto per la tua posizione.'; - - @override - String get choose_your_language => 'Scegli la tua lingua'; - - @override - String get help_project_grow => 'Aiuta questo progetto a crescere'; - - @override - String get help_project_grow_description => - 'Spotube è un progetto open-source. Puoi aiutare questo progetto a crescere contribuendo al progetto, segnalando bug o suggerendo nuove funzionalità.'; - - @override - String get contribute_on_github => 'Contribuisci su GitHub'; - - @override - String get donate_on_open_collective => 'Dona su Open Collective'; - - @override - String get browse_anonymously => 'Naviga in modo anonimo'; - - @override - String get enable_connect => 'Abilita connessione'; - - @override - String get enable_connect_description => - 'Controlla Spotube da altri dispositivi'; - - @override - String get devices => 'Dispositivi'; - - @override - String get select => 'Seleziona'; - - @override - String connect_client_alert(Object client) { - return 'Stai venendo controllato da $client'; - } - - @override - String get this_device => 'Questo dispositivo'; - - @override - String get remote => 'Remoto'; - - @override - String get stats => 'Statistiche'; - - @override - String and_n_more(Object count) { - return 'e $count in più'; - } - - @override - String get recently_played => 'Riprodotti di recente'; - - @override - String get browse_more => 'Esplora di più'; - - @override - String get no_title => 'Nessun titolo'; - - @override - String get not_playing => 'Non in riproduzione'; - - @override - String get epic_failure => 'Fallimento epico!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'Aggiunti $tracks_length brani alla coda'; - } - - @override - String get spotube_has_an_update => 'Spotube ha un aggiornamento'; - - @override - String get download_now => 'Scarica ora'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum è stato rilasciato'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version è stato rilasciato'; - } - - @override - String get read_the_latest => 'Leggi l\'ultimo '; - - @override - String get release_notes => 'note di rilascio'; - - @override - String get pick_color_scheme => 'Scegli uno schema di colori'; - - @override - String get save => 'Salva'; - - @override - String get choose_the_device => 'Scegli il dispositivo:'; - - @override - String get multiple_device_connected => - 'Sono collegati più dispositivi.\nScegli il dispositivo su cui vuoi che venga eseguita questa azione'; - - @override - String get nothing_found => 'Nessun risultato'; - - @override - String get the_box_is_empty => 'La scatola è vuota'; - - @override - String get top_artists => 'Artisti Top'; - - @override - String get top_albums => 'Album Top'; - - @override - String get this_week => 'Questa settimana'; - - @override - String get this_month => 'Questo mese'; - - @override - String get last_6_months => 'Ultimi 6 mesi'; - - @override - String get this_year => 'Quest\'anno'; - - @override - String get last_2_years => 'Ultimi 2 anni'; - - @override - String get all_time => 'Di tutti i tempi'; - - @override - String powered_by_provider(Object providerName) { - return 'Sostenuto da $providerName'; - } - - @override - String get email => 'Email'; - - @override - String get profile_followers => 'Follower'; - - @override - String get birthday => 'Compleanno'; - - @override - String get subscription => 'Abbonamento'; - - @override - String get not_born => 'Non nato'; - - @override - String get hacker => 'Hacker'; - - @override - String get profile => 'Profilo'; - - @override - String get no_name => 'Nessun nome'; - - @override - String get edit => 'Modifica'; - - @override - String get user_profile => 'Profilo utente'; - - @override - String count_plays(Object count) { - return '$count riproduzioni'; - } - - @override - String get streaming_fees_hypothetical => 'Spese di streaming (ipotetico)'; - - @override - String get minutes_listened => 'Minuti ascoltati'; - - @override - String get streamed_songs => 'Brani in streaming'; - - @override - String count_streams(Object count) { - return '$count streaming'; - } - - @override - String get owned_by_you => 'Di tua proprietà'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return 'Copiato $shareUrl negli appunti'; - } - - @override - String get hipotetical_calculation => - '*Questo è calcolato in base al pagamento medio per stream delle piattaforme di streaming musicale online, che va da \$0.003 a \$0.005. Si tratta di un calcolo ipotetico per dare all\'utente un\'idea di quanto avrebbe pagato agli artisti se avesse ascoltato la loro canzone su diverse piattaforme di streaming musicale.'; - - @override - String count_mins(Object minutes) { - return '$minutes min'; - } - - @override - String get summary_minutes => 'minuti'; - - @override - String get summary_listened_to_music => 'Musica ascoltata'; - - @override - String get summary_songs => 'brani'; - - @override - String get summary_streamed_overall => 'Streaming complessivo'; - - @override - String get summary_owed_to_artists => 'Dovuto agli artisti\nquesto mese'; - - @override - String get summary_artists => 'dell\'artista'; - - @override - String get summary_music_reached_you => 'La musica ti ha raggiunto'; - - @override - String get summary_full_albums => 'album completi'; - - @override - String get summary_got_your_love => 'Ha ricevuto il tuo amore'; - - @override - String get summary_playlists => 'playlist'; - - @override - String get summary_were_on_repeat => 'Erano in ripetizione'; - - @override - String total_money(Object money) { - return 'Totale $money'; - } - - @override - String get webview_not_found => 'Webview non trovato'; - - @override - String get webview_not_found_description => - 'Nessun runtime Webview installato nel tuo dispositivo.\nSe è installato, assicurati che sia nel environment PATH\n\nDopo l\'installazione, riavvia l\'app'; - - @override - String get unsupported_platform => 'Piattaforma non supportata'; - - @override - String get cache_music => 'Cache musica'; - - @override - String get open => 'Apri'; - - @override - String get cache_folder => 'Cartella cache'; - - @override - String get export => 'Esporta'; - - @override - String get clear_cache => 'Cancella cache'; - - @override - String get clear_cache_confirmation => 'Vuoi cancellare la cache?'; - - @override - String get export_cache_files => 'Esporta file nella cache'; - - @override - String found_n_files(Object count) { - return 'Trovati $count file'; - } - - @override - String get export_cache_confirmation => 'Vuoi esportare questi file su'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return 'Esportati $filesExported su $files file'; - } - - @override - String get undo => 'Annulla'; - - @override - String get download_all => 'Scarica tutto'; - - @override - String get add_all_to_playlist => 'Aggiungi tutto alla playlist'; - - @override - String get add_all_to_queue => 'Aggiungi tutto alla coda'; - - @override - String get play_all_next => 'Riproduci tutto dopo'; - - @override - String get pause => 'Pausa'; - - @override - String get view_all => 'Vedi tutto'; - - @override - String get no_tracks_added_yet => - 'Sembra che non hai ancora aggiunto nessun brano'; - - @override - String get no_tracks => 'Sembra che non ci siano brani qui'; - - @override - String get no_tracks_listened_yet => - 'Sembra che non hai ascoltato nulla ancora'; - - @override - String get not_following_artists => 'Non stai seguendo alcun artista'; - - @override - String get no_favorite_albums_yet => - 'Sembra che non hai ancora aggiunto album ai tuoi preferiti'; - - @override - String get no_logs_found => 'Nessun registro trovato'; - - @override - String get youtube_engine => 'Motore YouTube'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine non è installato'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine non è installato nel tuo sistema.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Assicurati che sia disponibile nella variabile PATH o\nimposta il percorso assoluto all\'eseguibile $engine qui sotto'; - } - - @override - String get youtube_engine_unix_issue_message => - 'In macOS/Linux/os simili a unix, impostare il percorso su .zshrc/.bashrc/.bash_profile ecc. non funzionerà.\nDevi impostare il percorso nel file di configurazione della shell'; - - @override - String get download => 'Scarica'; - - @override - String get file_not_found => 'File non trovato'; - - @override - String get custom => 'Personalizzato'; - - @override - String get add_custom_url => 'Aggiungi URL personalizzato'; - - @override - String get edit_port => 'Modifica porta'; - - @override - String get port_helper_msg => - 'Il valore predefinito è -1, che indica un numero casuale. Se hai configurato un firewall, si consiglia di impostarlo.'; - - @override - String connect_request(Object client) { - return 'Consentire a $client di connettersi?'; - } - - @override - String get connection_request_denied => - 'Connessione negata. L\'utente ha negato l\'accesso.'; - - @override - String get an_error_occurred => 'Si è verificato un errore'; - - @override - String get copy_to_clipboard => 'Copia negli appunti'; - - @override - String get view_logs => 'Visualizza log'; - - @override - String get retry => 'Riprova'; - - @override - String get no_default_metadata_provider_selected => - 'Non hai impostato alcun provider di metadati predefinito'; - - @override - String get manage_metadata_providers => 'Gestisci provider di metadati'; - - @override - String get open_link_in_browser => 'Aprire il link nel browser?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Vuoi aprire il seguente link'; - - @override - String get unsafe_url_warning => - 'Potrebbe essere pericoloso aprire link da fonti non attendibili. Sii cauto!\nPuoi anche copiare il link negli appunti.'; - - @override - String get copy_link => 'Copia link'; - - @override - String get building_your_timeline => - 'Creazione della tua cronologia in base ai tuoi ascolti...'; - - @override - String get official => 'Ufficiale'; - - @override - String author_name(Object author) { - return 'Autore: $author'; - } - - @override - String get third_party => 'Terze parti'; - - @override - String get plugin_requires_authentication => - 'Il plugin richiede l\'autenticazione'; - - @override - String get update_available => 'Aggiornamento disponibile'; - - @override - String get supports_scrobbling => 'Supporta lo scrobbling'; - - @override - String get plugin_scrobbling_info => - 'Questo plugin scrobbla la tua musica per generare la tua cronologia di ascolti.'; - - @override - String get default_metadata_source => 'Fonte metadati predefinita'; - - @override - String get set_default_metadata_source => - 'Imposta fonte metadati predefinita'; - - @override - String get default_audio_source => 'Fonte audio predefinita'; - - @override - String get set_default_audio_source => 'Imposta fonte audio predefinita'; - - @override - String get set_default => 'Imposta come predefinito'; - - @override - String get support => 'Supporto'; - - @override - String get support_plugin_development => 'Sostieni lo sviluppo del plugin'; - - @override - String can_access_name_api(Object name) { - return '- Può accedere all\'API **$name**'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Vuoi installare questo plugin?'; - - @override - String get third_party_plugin_warning => - 'Questo plugin proviene da un repository di terze parti. Assicurati di fidarti della fonte prima di installarlo.'; - - @override - String get author => 'Autore'; - - @override - String get this_plugin_can_do_following => - 'Questo plugin può fare quanto segue'; - - @override - String get install => 'Installa'; - - @override - String get install_a_metadata_provider => 'Installa un provider di metadati'; - - @override - String get no_tracks_playing => 'Nessun brano in riproduzione al momento'; - - @override - String get synced_lyrics_not_available => - 'Testi sincronizzati non disponibili per questa canzone. Si prega di utilizzare la scheda'; - - @override - String get plain_lyrics => 'Testi semplici'; - - @override - String get tab_instead => 'invece.'; - - @override - String get disclaimer => 'Disclaimer'; - - @override - String get third_party_plugin_dmca_notice => - 'Il team di Spotube non si assume alcuna responsabilità (anche legale) per i plugin di \"terze parti\".\nUsali a tuo rischio e pericolo. Per eventuali bug/problemi, segnalali al repository del plugin.\n\nSe un plugin di \"terze parti\" sta violando i ToS/DMCA di un servizio/entità legale, per favore chiedi all\'autore del plugin \"terzo\" o alla piattaforma di hosting, ad esempio GitHub/Codeberg, di agire. Quelli elencati sopra (etichettati come \"terze parti\") sono tutti plugin pubblici/mantenuti dalla comunità. Non li curiamo, quindi non possiamo intraprendere alcuna azione su di essi.\n\n'; - - @override - String get input_does_not_match_format => - 'L\'input non corrisponde al formato richiesto'; - - @override - String get plugins => 'Plugin'; - - @override - String get paste_plugin_download_url => - 'Incolla l\'URL di download o l\'URL del repository GitHub/Codeberg o il link diretto al file .smplug'; - - @override - String get download_and_install_plugin_from_url => - 'Scarica e installa il plugin da URL'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Impossibile aggiungere il plugin: $error'; - } - - @override - String get upload_plugin_from_file => 'Carica plugin da file'; - - @override - String get installed => 'Installato'; - - @override - String get available_plugins => 'Plugin disponibili'; - - @override - String get configure_plugins => - 'Configura i tuoi plugin per fornitore metadati e fonte audio'; - - @override - String get audio_scrobblers => 'Scrobbler audio'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Fonte: '; - - @override - String get uncompressed => 'Non compresso'; - - @override - String get dab_music_source_description => - 'Per audiophile. Fornisce flussi audio di alta qualità/senza perdita. Abbinamento traccia accurato basato su ISRC.'; -} diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart deleted file mode 100644 index 2505f68a..00000000 --- a/lib/l10n/generated/app_localizations_ja.dart +++ /dev/null @@ -1,1534 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Japanese (`ja`). -class AppLocalizationsJa extends AppLocalizations { - AppLocalizationsJa([String locale = 'ja']) : super(locale); - - @override - String get guest => 'ゲスト'; - - @override - String get browse => '閲覧'; - - @override - String get search => '検索'; - - @override - String get library => 'ライブラリ'; - - @override - String get lyrics => '歌詞'; - - @override - String get settings => '設定'; - - @override - String get genre_categories_filter => 'カテゴリーやジャンルを絞り込み...'; - - @override - String get genre => 'ジャンル'; - - @override - String get personalized => 'あなたにおすすめ'; - - @override - String get featured => '注目'; - - @override - String get new_releases => '新着'; - - @override - String get songs => '曲'; - - @override - String playing_track(Object track) { - return '$track を再生'; - } - - @override - String queue_clear_alert(Object track_length) { - return '現在のキューを消去します。$track_length 曲を消去します。\n続行しますか?'; - } - - @override - String get load_more => 'もっと読み込む'; - - @override - String get playlists => '再生リスト'; - - @override - String get artists => 'アーティスト'; - - @override - String get albums => 'アルバム'; - - @override - String get tracks => '曲'; - - @override - String get downloads => 'ダウンロード'; - - @override - String get filter_playlists => 'あなたの再生リストを絞り込み...'; - - @override - String get liked_tracks => 'いいねした曲'; - - @override - String get liked_tracks_description => 'いいねしたすべての曲'; - - @override - String get playlist => '再生リスト'; - - @override - String get create_a_playlist => '再生リストの作成'; - - @override - String get update_playlist => '再生リストを更新'; - - @override - String get create => '作成'; - - @override - String get cancel => 'キャンセル'; - - @override - String get update => '更新'; - - @override - String get playlist_name => '再生リスト名'; - - @override - String get name_of_playlist => '再生リストの名前'; - - @override - String get description => '説明'; - - @override - String get public => '公開'; - - @override - String get collaborative => 'コラボ'; - - @override - String get search_local_tracks => '端末内の曲を検索...'; - - @override - String get play => '再生'; - - @override - String get delete => '削除'; - - @override - String get none => 'なし'; - - @override - String get sort_a_z => 'A-Z 順に並び替え'; - - @override - String get sort_z_a => 'Z-A 順に並び替え'; - - @override - String get sort_artist => 'アーティスト順に並び替え'; - - @override - String get sort_album => 'アルバム順に並び替え'; - - @override - String get sort_duration => '長さ順に並べ替え'; - - @override - String get sort_tracks => '曲の並び替え'; - - @override - String currently_downloading(Object tracks_length) { - return 'ダウンロード中 ($tracks_length) 曲'; - } - - @override - String get cancel_all => 'すべてキャンセル'; - - @override - String get filter_artist => 'アーティストを絞り込み...'; - - @override - String followers(Object followers) { - return '$followers フォロワー'; - } - - @override - String get add_artist_to_blacklist => 'このアーティストをブラックリストに追加'; - - @override - String get top_tracks => '人気の曲'; - - @override - String get fans_also_like => 'ファンの間で人気'; - - @override - String get loading => '読み込み中...'; - - @override - String get artist => 'アーティスト'; - - @override - String get blacklisted => 'ブラックリスト'; - - @override - String get following => 'フォロー中'; - - @override - String get follow => 'フォローする'; - - @override - String get artist_url_copied => 'アーティストの URL をクリップボードにコピーしました'; - - @override - String added_to_queue(Object tracks) { - return '$tracks をキューに追加しました'; - } - - @override - String get filter_albums => 'アルバムを絞り込み...'; - - @override - String get synced => '同期する'; - - @override - String get plain => 'そのまま'; - - @override - String get shuffle => 'シャッフル'; - - @override - String get search_tracks => '曲を検索...'; - - @override - String get released => 'リリース日'; - - @override - String error(Object error) { - return 'エラー $error'; - } - - @override - String get title => 'タイトル'; - - @override - String get time => '長さ'; - - @override - String get more_actions => 'ほかの操作'; - - @override - String download_count(Object count) { - return 'ダウンロード ($count) 曲'; - } - - @override - String add_count_to_playlist(Object count) { - return '再生リストに ($count) 曲を追加'; - } - - @override - String add_count_to_queue(Object count) { - return 'キューに ($count) 曲を追加'; - } - - @override - String play_count_next(Object count) { - return '次に ($count) 曲を再生'; - } - - @override - String get album => 'アルバム'; - - @override - String copied_to_clipboard(Object data) { - return '$data をクリップボードにコピーしました'; - } - - @override - String add_to_following_playlists(Object track) { - return '$track をこの再生リストに追加'; - } - - @override - String get add => '追加'; - - @override - String added_track_to_queue(Object track) { - return 'キューに $track を追加しました'; - } - - @override - String get add_to_queue => 'キューに追加'; - - @override - String track_will_play_next(Object track) { - return '$track を次に再生'; - } - - @override - String get play_next => '次に再生'; - - @override - String removed_track_from_queue(Object track) { - return 'キューから $track を除去しました'; - } - - @override - String get remove_from_queue => 'キューから除去'; - - @override - String get remove_from_favorites => 'お気に入りから除去'; - - @override - String get save_as_favorite => 'お気に入りに保存'; - - @override - String get add_to_playlist => '再生リストに追加'; - - @override - String get remove_from_playlist => '再生リストから除去'; - - @override - String get add_to_blacklist => 'ブラックリストに追加'; - - @override - String get remove_from_blacklist => 'ブラックリストから除去'; - - @override - String get share => '共有'; - - @override - String get mini_player => 'ミニプレイヤー'; - - @override - String get slide_to_seek => '前後にスライドしてシーク'; - - @override - String get shuffle_playlist => '再生リストをシャッフル'; - - @override - String get unshuffle_playlist => '再生リストのシャッフル解除'; - - @override - String get previous_track => '前の曲'; - - @override - String get next_track => '次の曲'; - - @override - String get pause_playback => '再生を停止'; - - @override - String get resume_playback => '再生を再開'; - - @override - String get loop_track => '曲をループ'; - - @override - String get no_loop => 'ループなし'; - - @override - String get repeat_playlist => '再生リストをリピート'; - - @override - String get queue => '再生キュー'; - - @override - String get alternative_track_sources => 'この曲の別の音源を選ぶ'; - - @override - String get download_track => '曲のダウンロード'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks曲の再生キュー'; - } - - @override - String get clear_all => 'すべて消去l'; - - @override - String get show_hide_ui_on_hover => 'マウスを乗せてUIを表示/隠す'; - - @override - String get always_on_top => '常に手前に表示'; - - @override - String get exit_mini_player => 'ミニプレイヤーを終了'; - - @override - String get download_location => 'ダウンロード先'; - - @override - String get local_library => '端末内ライブラリ'; - - @override - String get add_library_location => 'ライブラリに追加'; - - @override - String get remove_library_location => 'ライブラリから削除'; - - @override - String get account => 'アカウント'; - - @override - String get logout => 'ログアウト'; - - @override - String get logout_of_this_account => 'このアカウントからログアウト'; - - @override - String get language_region => '言語 & 地域'; - - @override - String get language => '言語'; - - @override - String get system_default => 'システムの既定値'; - - @override - String get market_place_region => '音楽市場の地域'; - - @override - String get recommendation_country => 'おすすめの国'; - - @override - String get appearance => '外観'; - - @override - String get layout_mode => 'レイアウトの種類'; - - @override - String get override_layout_settings => 'レスポンシブなレイアウトの種類の設定を上書きする'; - - @override - String get adaptive => '適応的'; - - @override - String get compact => 'コンパクト'; - - @override - String get extended => '幅広'; - - @override - String get theme => 'テーマ'; - - @override - String get dark => 'ダーク'; - - @override - String get light => 'ライト'; - - @override - String get system => 'システムに従う'; - - @override - String get accent_color => 'アクセントカラー'; - - @override - String get sync_album_color => 'アルバムの色に合わせる'; - - @override - String get sync_album_color_description => 'アルバムアートの主張色をアクセントカラーとして使用'; - - @override - String get playback => '再生'; - - @override - String get audio_quality => '音声品質'; - - @override - String get high => '高'; - - @override - String get low => '低'; - - @override - String get pre_download_play => '事前ダウンロードと再生'; - - @override - String get pre_download_play_description => - '音声をストリーミングする代わりに、データをバイト単位でダウンロードして再生 (回線速度が早いユーザーにおすすめ)'; - - @override - String get skip_non_music => '音楽でない部分をスキップ (SponsorBlock)'; - - @override - String get blacklist_description => '曲とアーティストのブラックリスト'; - - @override - String get wait_for_download_to_finish => '現在のダウンロードが完了するまでお待ちください'; - - @override - String get desktop => 'デスクトップ'; - - @override - String get close_behavior => '閉じた時の動作'; - - @override - String get close => '閉じる'; - - @override - String get minimize_to_tray => 'トレイに最小化'; - - @override - String get show_tray_icon => 'システムトレイにアイコンを表示'; - - @override - String get about => 'このアプリについて'; - - @override - String get u_love_spotube => 'Spotube が好きだと知っていますよ'; - - @override - String get check_for_updates => 'アップデートの確認'; - - @override - String get about_spotube => 'Spotube について'; - - @override - String get blacklist => 'ブラックリスト'; - - @override - String get please_sponsor => '出資/寄付もお待ちします'; - - @override - String get spotube_description => - 'Spotube は、軽量でクロスプラットフォームな、すべて無料の spotify クライアント'; - - @override - String get version => 'バージョン'; - - @override - String get build_number => 'ビルド番号'; - - @override - String get founder => '創始者'; - - @override - String get repository => 'リポジトリ'; - - @override - String get bug_issues => 'バグや問題'; - - @override - String get made_with => '❤️ を込めてバングラディシュ🇧🇩で開発'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'ライセンス'; - - @override - String get credentials_will_not_be_shared_disclaimer => - '心配ありません。個人情報を収集したり、共有されることはありません'; - - @override - String get know_how_to_login => 'やり方が分からないですか?'; - - @override - String get follow_step_by_step_guide => 'やり方の説明を見る'; - - @override - String cookie_name_cookie(Object name) { - return '$name Cookies'; - } - - @override - String get fill_in_all_fields => 'すべての欄に入力してください'; - - @override - String get submit => '送信'; - - @override - String get exit => '終了'; - - @override - String get previous => '前へ'; - - @override - String get next => '次へ'; - - @override - String get done => '完了'; - - @override - String get step_1 => 'ステップ 1'; - - @override - String get first_go_to => '最初にここを開き'; - - @override - String get something_went_wrong => '何か誤りがあります'; - - @override - String get piped_instance => 'Piped サーバーのインスタンス'; - - @override - String get piped_description => '曲の一致に使う Piped サーバーのインスタンス'; - - @override - String get piped_warning => 'それらの一部ではうまく動作しないこともあります。自己責任で使用してください'; - - @override - String get invidious_instance => 'Invidiousサーバーインスタンス'; - - @override - String get invidious_description => '曲の一致に使用するInvidiousサーバーインスタンス'; - - @override - String get invidious_warning => '一部はうまく機能しない可能性があります。自己責任で使用してください'; - - @override - String get generate => '生成'; - - @override - String track_exists(Object track) { - return '曲 $track は既に存在します'; - } - - @override - String get replace_downloaded_tracks => 'すべてのダウンロード済みの曲を置換'; - - @override - String get skip_download_tracks => 'すべてのダウンロード済みの曲をスキップ'; - - @override - String get do_you_want_to_replace => '既存の曲と置換しますか?'; - - @override - String get replace => '置換する'; - - @override - String get skip => 'スキップ'; - - @override - String select_up_to_count_type(Object count, Object type) { - return '$typeを最大$count 個まで選択'; - } - - @override - String get select_genres => 'ジャンルを選択'; - - @override - String get add_genres => 'ジャンルを追加'; - - @override - String get country => '国'; - - @override - String get number_of_tracks_generate => '生成する曲数'; - - @override - String get acousticness => 'アコースティック感'; - - @override - String get danceability => 'ダンス感'; - - @override - String get energy => 'エネルギー'; - - @override - String get instrumentalness => 'インストゥルメンタル'; - - @override - String get liveness => 'ライブ感'; - - @override - String get loudness => 'ラウドネス'; - - @override - String get speechiness => '会話感'; - - @override - String get valence => '多幸性'; - - @override - String get popularity => '人気度'; - - @override - String get key => 'キー'; - - @override - String get duration => '長さ (秒)'; - - @override - String get tempo => 'テンポ (BPM)'; - - @override - String get mode => '長調'; - - @override - String get time_signature => '拍子記号'; - - @override - String get short => '短'; - - @override - String get medium => '中'; - - @override - String get long => '長'; - - @override - String get min => '最小'; - - @override - String get max => '最大'; - - @override - String get target => '目標'; - - @override - String get moderate => '中'; - - @override - String get deselect_all => 'すべて選択解除'; - - @override - String get select_all => 'すべて選択'; - - @override - String get are_you_sure => 'よろしいですか?'; - - @override - String get generating_playlist => 'カスタムの再生リストを生成中...'; - - @override - String selected_count_tracks(Object count) { - return '$count 曲が選ばれました'; - } - - @override - String get download_warning => - '全曲の一括ダウンロードは明らかに音楽への海賊行為であり、音楽を生み出す共同体に損害を与えるでしょう。気づいてほしい。アーティストの多大な努力に敬意を払い、支援するようにしてください'; - - @override - String get download_ip_ban_warning => - 'また、通常よりも過剰なダウンロード要求があれば、YouTubeはあなたのIPをブロックします。つまりそのIPの端末からは、少なくとも2-3か月の間、(ログインしても)YouTubeを利用できなくなりす。そうなっても Spotube は一切の責任を負いません'; - - @override - String get by_clicking_accept_terms => '「同意する」のクリックにより、以下への同意となります:'; - - @override - String get download_agreement_1 => 'ええ、音楽への海賊行為だ。私はよくない'; - - @override - String get download_agreement_2 => '芸術作品を買うお金がないのでそうするしかないが、アーティストをできる限り支援する'; - - @override - String get download_agreement_3 => - '私のIPがYouTubeにブロックされることがあると完全に把握した。私のこの行動により起きたどんな事故も、Spotube やその所有者/貢献者に責任はありません。'; - - @override - String get decline => '同意しない'; - - @override - String get accept => '同意する'; - - @override - String get details => '詳細'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'チャンネル'; - - @override - String get likes => '高評価'; - - @override - String get dislikes => '低評価'; - - @override - String get views => '視聴回数'; - - @override - String get streamUrl => '動画の URL'; - - @override - String get stop => '中止'; - - @override - String get sort_newest => '追加日の新しい順に並び替え'; - - @override - String get sort_oldest => '追加日の古い順に並び替え'; - - @override - String get sleep_timer => 'スリープタイマー'; - - @override - String mins(Object minutes) { - return '$minutes 分'; - } - - @override - String hours(Object hours) { - return '$hours 時間'; - } - - @override - String hour(Object hours) { - return '$hours 時間'; - } - - @override - String get custom_hours => '時間を指定'; - - @override - String get logs => 'ログ'; - - @override - String get developers => '開発'; - - @override - String get not_logged_in => 'ログインしていません'; - - @override - String get search_mode => '検索モード'; - - @override - String get audio_source => '音声の提供元'; - - @override - String get ok => 'OK'; - - @override - String get failed_to_encrypt => '暗号化に失敗しました'; - - @override - String get encryption_failed_warning => - 'SpoTubeはデータを安全に保存するために暗号化を用いますが、暗号化に失敗しました。このため、安全でない保存領域への保存に切り替えます\nOSがLinuxなら、gnome-keyring、kde-wallet、keepassxcなどの管理ツールがインストールされていることを確認してください'; - - @override - String get querying_info => '情報を取得中...'; - - @override - String get piped_api_down => 'Piped APIがダウンしています'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'Pipedインスタンス $pipedInstance は現在ダウンしています\n\nインスタンスを変更するか、「APIの種類」を公式のYouTube APIに変更してください\n\n変更後にアプリを再起動してください'; - } - - @override - String get you_are_offline => '現在、オフラインです'; - - @override - String get connection_restored => 'インターネット接続が復旧しました'; - - @override - String get use_system_title_bar => 'システムのタイトルバーを使う'; - - @override - String get crunching_results => '結果を処理中...'; - - @override - String get search_to_get_results => '結果を取得するために検索'; - - @override - String get use_amoled_mode => 'AMOLEDモードを使用'; - - @override - String get pitch_dark_theme => 'ピッチブラック ダークテーマ'; - - @override - String get normalize_audio => '音声を正規化'; - - @override - String get change_cover => 'カバーを変更'; - - @override - String get add_cover => 'カバーを追加'; - - @override - String get restore_defaults => '設定を初期化'; - - @override - String get download_music_format => '音楽ダウンロード形式'; - - @override - String get streaming_music_format => '音楽ストリーミング形式'; - - @override - String get download_music_quality => '音楽ダウンロード品質'; - - @override - String get streaming_music_quality => '音楽ストリーミング品質'; - - @override - String get login_with_lastfm => 'Last.fmでログイン'; - - @override - String get connect => '接続'; - - @override - String get disconnect_lastfm => 'Last.fmから切断'; - - @override - String get disconnect => '切断'; - - @override - String get username => 'ユーザー名'; - - @override - String get password => 'パスワード'; - - @override - String get login => 'ログイン'; - - @override - String get login_with_your_lastfm => 'Last.fmアカウントでログイン'; - - @override - String get scrobble_to_lastfm => 'Last.fmにスクロブルする'; - - @override - String get go_to_album => 'アルバムに移動'; - - @override - String get discord_rich_presence => 'Discord リッチプレゼンス'; - - @override - String get browse_all => 'すべてを閲覧'; - - @override - String get genres => 'ジャンル'; - - @override - String get explore_genres => 'ジャンルを探索'; - - @override - String get friends => '友達'; - - @override - String get no_lyrics_available => 'すみません、この曲の歌詞が見つかりません'; - - @override - String get start_a_radio => 'ラジオを開始'; - - @override - String get how_to_start_radio => 'ラジオをどのように開始しますか?'; - - @override - String get replace_queue_question => '現在のキューを置き換えるか、追加しますか?'; - - @override - String get endless_playback => 'エンドレス再生'; - - @override - String get delete_playlist => '再生リストを削除'; - - @override - String get delete_playlist_confirmation => 'この再生リストを削除しますか?'; - - @override - String get local_tracks => '端末内の曲'; - - @override - String get local_tab => '端末内'; - - @override - String get song_link => '曲のリンク'; - - @override - String get skip_this_nonsense => 'こんなことはスキップ'; - - @override - String get freedom_of_music => '“音楽の自由”'; - - @override - String get freedom_of_music_palm => '“音楽の自由を思いのままに”'; - - @override - String get get_started => 'さあ始めましょう'; - - @override - String get youtube_source_description => '推奨され、最適に機能します。'; - - @override - String get piped_source_description => '自由を感じる?YouTubeと同じだけど、はるかに自由です。'; - - @override - String get jiosaavn_source_description => '南アジア地域では最適です。'; - - @override - String get invidious_source_description => 'Pipedに似ていますが、より利用性があります。'; - - @override - String highest_quality(Object quality) { - return '最高品質:$quality'; - } - - @override - String get select_audio_source => '音声の提供元を選択'; - - @override - String get endless_playback_description => 'キューの最後に新しい曲を自動で追加'; - - @override - String get choose_your_region => '地域を選択'; - - @override - String get choose_your_region_description => 'Spotubeがあなたの地域に適したコンテンツを表示します。'; - - @override - String get choose_your_language => '言語を選択してください'; - - @override - String get help_project_grow => 'プロジェクトの成長を支援する'; - - @override - String get help_project_grow_description => - 'SpoTubeはオープンソースプロジェクトです。貢献したり、バグ報告したり、新機能を提案することで、プロジェクトの成長に貢献できます。'; - - @override - String get contribute_on_github => 'GitHubで貢献'; - - @override - String get donate_on_open_collective => 'Open Collectiveで寄付'; - - @override - String get browse_anonymously => '匿名で閲覧する'; - - @override - String get enable_connect => '接続する'; - - @override - String get enable_connect_description => '他の端末からSpotubeを制御する'; - - @override - String get devices => '機器'; - - @override - String get select => '選択'; - - @override - String connect_client_alert(Object client) { - return '$client から操作されています'; - } - - @override - String get this_device => 'この端末'; - - @override - String get remote => 'リモート'; - - @override - String get stats => '統計'; - - @override - String and_n_more(Object count) { - return 'さらに $count 項目'; - } - - @override - String get recently_played => '最近聴いた曲'; - - @override - String get browse_more => 'もっと表示'; - - @override - String get no_title => 'タイトルなし'; - - @override - String get not_playing => '再生なし'; - - @override - String get epic_failure => '壮大なエラー!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return '$tracks_length 曲をキューに追加しました'; - } - - @override - String get spotube_has_an_update => 'Spotube の最新版あり'; - - @override - String get download_now => '今すぐダウンロード'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum がリリースされました'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version がリリースされました'; - } - - @override - String get read_the_latest => '最新の '; - - @override - String get release_notes => '更新情報を読む'; - - @override - String get pick_color_scheme => 'カラーテーマを選択'; - - @override - String get save => '保存'; - - @override - String get choose_the_device => '端末を選択:'; - - @override - String get multiple_device_connected => '複数の端末が接続されています。\nこの操作を実行する端末を選択'; - - @override - String get nothing_found => '何も見つかりませんでした'; - - @override - String get the_box_is_empty => 'ボックスは空です'; - - @override - String get top_artists => 'トップアーティスト'; - - @override - String get top_albums => 'トップアルバム'; - - @override - String get this_week => '今週'; - - @override - String get this_month => '今月'; - - @override - String get last_6_months => '過去6か月'; - - @override - String get this_year => '今年'; - - @override - String get last_2_years => '過去2年間'; - - @override - String get all_time => '全期間'; - - @override - String powered_by_provider(Object providerName) { - return '$providerName 提供'; - } - - @override - String get email => 'メール'; - - @override - String get profile_followers => 'フォロワー'; - - @override - String get birthday => '誕生日'; - - @override - String get subscription => '登録'; - - @override - String get not_born => '未出生'; - - @override - String get hacker => 'ハッカー'; - - @override - String get profile => 'プロフィール'; - - @override - String get no_name => '名前なし'; - - @override - String get edit => '編集'; - - @override - String get user_profile => 'ユーザープロフィール'; - - @override - String count_plays(Object count) { - return '$count 回再生'; - } - - @override - String get streaming_fees_hypothetical => 'ストリーミング料金 (概算)'; - - @override - String get minutes_listened => '視聴時間'; - - @override - String get streamed_songs => 'ストリーミングされた曲'; - - @override - String count_streams(Object count) { - return '$count 回のストリーム'; - } - - @override - String get owned_by_you => 'あなたが所有'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl をクリップボードにコピーしました'; - } - - @override - String get hipotetical_calculation => - '*これは、オンライン音楽ストリーミングプラットフォームの1ストリームあたりの平均支払い額である\$0.003〜\$0.005に基づいて計算されています。これは、ユーザーが異なる音楽ストリーミングプラットフォームで曲を聴いた場合に、アーティストにどれだけ支払ったかを把握するための仮説的な計算です。'; - - @override - String count_mins(Object minutes) { - return '$minutes 分'; - } - - @override - String get summary_minutes => '分'; - - @override - String get summary_listened_to_music => '音楽を聴いた'; - - @override - String get summary_songs => '曲'; - - @override - String get summary_streamed_overall => 'まるごと聴いた'; - - @override - String get summary_owed_to_artists => '今月アーティストに払う\nべき額'; - - @override - String get summary_artists => 'アーティスト'; - - @override - String get summary_music_reached_you => 'の音楽が届いた'; - - @override - String get summary_full_albums => 'フルアルバム'; - - @override - String get summary_got_your_love => 'があなたの愛を受け取った'; - - @override - String get summary_playlists => '再生リスト'; - - @override - String get summary_were_on_repeat => 'をリピートしました'; - - @override - String total_money(Object money) { - return '計 $money'; - } - - @override - String get webview_not_found => 'Webviewが見つかりません'; - - @override - String get webview_not_found_description => - '端末にWebviewランタイムがインストールされていません。\nインストールされている場合は、環境変数のパスにあるか確認してください\n\nインストール後、アプリを再起動してください'; - - @override - String get unsupported_platform => '未対応のプラットフォーム'; - - @override - String get cache_music => '音楽をキャッシュ'; - - @override - String get open => '開く'; - - @override - String get cache_folder => 'キャッシュフォルダー'; - - @override - String get export => 'エクスポート'; - - @override - String get clear_cache => 'キャッシュをクリア'; - - @override - String get clear_cache_confirmation => 'キャッシュをクリアしますか?'; - - @override - String get export_cache_files => 'キャッシュされたファイルをエクスポート'; - - @override - String found_n_files(Object count) { - return '$countファイルが見つかりました'; - } - - @override - String get export_cache_confirmation => 'これらのファイルをエクスポートしますか'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return '$filesExported / $filesファイルがエクスポートされました'; - } - - @override - String get undo => '元に戻す'; - - @override - String get download_all => 'すべてダウンロード'; - - @override - String get add_all_to_playlist => 'すべて再生リストに追加'; - - @override - String get add_all_to_queue => 'すべてキューに追加'; - - @override - String get play_all_next => 'すべてを次に再生'; - - @override - String get pause => '一時停止'; - - @override - String get view_all => 'すべて表示'; - - @override - String get no_tracks_added_yet => 'まだ曲を追加していないようです'; - - @override - String get no_tracks => 'ここには曲がないようです'; - - @override - String get no_tracks_listened_yet => 'まだ何も聞いていないようです'; - - @override - String get not_following_artists => 'アーティストをフォローしていません'; - - @override - String get no_favorite_albums_yet => 'まだお気に入りのアルバムを追加していないようです'; - - @override - String get no_logs_found => 'ログなし'; - - @override - String get youtube_engine => 'YouTubeエンジン'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engineはインストールされていません'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engineはシステムにインストールされていません。'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'PATH変数に設定されていることを確認するか\n$engine実行ファイルの絶対パスを下記に設定してください'; - } - - @override - String get youtube_engine_unix_issue_message => - 'macOS/Linux/Unix系OSでは、.zshrc/.bashrc/.bash_profileなどでパスを設定しても動作しません。\nシェルの設定ファイルにパスを設定する必要があります'; - - @override - String get download => 'ダウンロード'; - - @override - String get file_not_found => 'ファイルが見つかりません'; - - @override - String get custom => '独自'; - - @override - String get add_custom_url => '独自にURLを追加'; - - @override - String get edit_port => 'ポートを編集'; - - @override - String get port_helper_msg => - '初期設定は-1で、ランダムな番号を示します。ファイアウォールを設定している場合に設定することを推奨します。'; - - @override - String connect_request(Object client) { - return '$clientの接続を許可しますか?'; - } - - @override - String get connection_request_denied => '接続が拒否されました。ユーザーがアクセスを拒否しました。'; - - @override - String get an_error_occurred => 'エラーが発生しました'; - - @override - String get copy_to_clipboard => 'クリップボードにコピー'; - - @override - String get view_logs => 'ログを表示'; - - @override - String get retry => '再試行'; - - @override - String get no_default_metadata_provider_selected => - 'デフォルトのメタデータプロバイダーが設定されていません'; - - @override - String get manage_metadata_providers => 'メタデータプロバイダーを管理'; - - @override - String get open_link_in_browser => 'リンクをブラウザで開きますか?'; - - @override - String get do_you_want_to_open_the_following_link => '次のリンクを開きますか'; - - @override - String get unsafe_url_warning => - '信頼できないソースからのリンクを開くのは安全ではない場合があります。注意してください!\nリンクをクリップボードにコピーすることもできます。'; - - @override - String get copy_link => 'リンクをコピー'; - - @override - String get building_your_timeline => 'あなたの視聴履歴に基づいてタイムラインを作成しています...'; - - @override - String get official => '公式'; - - @override - String author_name(Object author) { - return '作者: $author'; - } - - @override - String get third_party => 'サードパーティ'; - - @override - String get plugin_requires_authentication => 'プラグインには認証が必要です'; - - @override - String get update_available => 'アップデートが利用可能です'; - - @override - String get supports_scrobbling => 'scrobblingに対応'; - - @override - String get plugin_scrobbling_info => 'このプラグインは、あなたの音楽をscrobbleして視聴履歴を生成します。'; - - @override - String get default_metadata_source => 'デフォルトメタデータソース'; - - @override - String get set_default_metadata_source => 'デフォルトメタデータソースを設定'; - - @override - String get default_audio_source => 'デフォルトオーディオソース'; - - @override - String get set_default_audio_source => 'デフォルトオーディオソースを設定'; - - @override - String get set_default => 'デフォルトに設定'; - - @override - String get support => 'サポート'; - - @override - String get support_plugin_development => 'プラグイン開発をサポート'; - - @override - String can_access_name_api(Object name) { - return '- **$name** APIにアクセスできます'; - } - - @override - String get do_you_want_to_install_this_plugin => 'このプラグインをインストールしますか?'; - - @override - String get third_party_plugin_warning => - 'このプラグインはサードパーティのリポジトリからのものです。インストールする前にソースを信頼できるか確認してください。'; - - @override - String get author => '作者'; - - @override - String get this_plugin_can_do_following => 'このプラグインは以下のことができます'; - - @override - String get install => 'インストール'; - - @override - String get install_a_metadata_provider => 'メタデータプロバイダーをインストール'; - - @override - String get no_tracks_playing => '現在再生中のトラックはありません'; - - @override - String get synced_lyrics_not_available => 'この曲の同期歌詞は利用できません。代わりに'; - - @override - String get plain_lyrics => 'シンプルな歌詞'; - - @override - String get tab_instead => 'タブを使用してください。'; - - @override - String get disclaimer => '免責事項'; - - @override - String get third_party_plugin_dmca_notice => - 'Spotubeチームは、いかなる「サードパーティ」プラグインについても責任(法的責任を含む)を負いません。\nご自身の責任でご使用ください。バグや問題については、プラグインリポジトリに報告してください。\n\n「サードパーティ」プラグインが何らかのサービス/法人のToS/DMCAを侵害している場合、その「サードパーティ」プラグインの作者またはホスティングプラットフォーム(例:GitHub/Codeberg)に措置を講じるよう依頼してください。上記に記載されている(「サードパーティ」とラベル付けされた)ものはすべて、パブリック/コミュニティによって維持されているプラグインです。私たちはそれらをキュレーションしていないため、それらに対して措置を講じることはできません。\n\n'; - - @override - String get input_does_not_match_format => '入力が必須フォーマットと一致しません'; - - @override - String get plugins => 'プラグイン'; - - @override - String get paste_plugin_download_url => - 'ダウンロードURL、GitHub/CodebergリポジトリURL、または.smplugファイルへの直接リンクを貼り付けます'; - - @override - String get download_and_install_plugin_from_url => - 'URLからプラグインをダウンロードしてインストール'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'プラグインの追加に失敗しました: $error'; - } - - @override - String get upload_plugin_from_file => 'ファイルからプラグインをアップロード'; - - @override - String get installed => 'インストール済み'; - - @override - String get available_plugins => '利用可能なプラグイン'; - - @override - String get configure_plugins => '独自のメタデータプロバイダーとオーディオソースプラグインを設定'; - - @override - String get audio_scrobblers => 'オーディオスクロッブラー'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'ソース: '; - - @override - String get uncompressed => '非圧縮'; - - @override - String get dab_music_source_description => - 'オーディオファイル向け。高品質/ロスレスオーディオストリームを提供。正確なISRCベースのトラックマッチング。'; -} diff --git a/lib/l10n/generated/app_localizations_ka.dart b/lib/l10n/generated/app_localizations_ka.dart deleted file mode 100644 index c8557037..00000000 --- a/lib/l10n/generated/app_localizations_ka.dart +++ /dev/null @@ -1,1573 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Georgian (`ka`). -class AppLocalizationsKa extends AppLocalizations { - AppLocalizationsKa([String locale = 'ka']) : super(locale); - - @override - String get guest => 'სტუმარი'; - - @override - String get browse => 'ნახვა'; - - @override - String get search => 'ძებნა'; - - @override - String get library => 'ბიბლიოთეკა'; - - @override - String get lyrics => 'ტექსტები'; - - @override - String get settings => 'კონფიგურაციები'; - - @override - String get genre_categories_filter => 'კატეგორიების ან ჟანრების ფილტრი...'; - - @override - String get genre => 'ჟანრი'; - - @override - String get personalized => 'პეერსონალიზებული'; - - @override - String get featured => 'გამორჩეული'; - - @override - String get new_releases => 'ახალი გამოცემები'; - - @override - String get songs => 'სიმღერები'; - - @override - String playing_track(Object track) { - return 'უკრავს $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'ეს გაასუფთავებს მიმდინარე რიგს. $track_length ტრეკი წაიშლება\nᲒინდა გააგრძელო?'; - } - - @override - String get load_more => 'მეტის ჩატვირთვა'; - - @override - String get playlists => 'ფლეილისტები'; - - @override - String get artists => 'არტისტები'; - - @override - String get albums => 'ალბომები'; - - @override - String get tracks => 'ტრეკები'; - - @override - String get downloads => 'ჩამოტვირთვები'; - - @override - String get filter_playlists => 'ფლეილისტების გაფილტვრა...'; - - @override - String get liked_tracks => 'მოწონებული ტრეკები'; - - @override - String get liked_tracks_description => 'ყველა შენი მოწონებული ტრეკი'; - - @override - String get playlist => 'პლეისთი'; - - @override - String get create_a_playlist => 'ფლეილისტის შექმნა'; - - @override - String get update_playlist => 'ფლეილისტის განახლება'; - - @override - String get create => 'შექმნა'; - - @override - String get cancel => 'გაუქმება'; - - @override - String get update => 'განახლება'; - - @override - String get playlist_name => 'ფლეილისტის სახელი'; - - @override - String get name_of_playlist => 'ფლეილისტის სახელი'; - - @override - String get description => 'აღწერა'; - - @override - String get public => 'საჯარო'; - - @override - String get collaborative => 'კოლაბორაციული'; - - @override - String get search_local_tracks => 'ლოცალური ტრეკების ძებნა...'; - - @override - String get play => 'დაკვრა'; - - @override - String get delete => 'წაშლა'; - - @override - String get none => 'არცერთი'; - - @override - String get sort_a_z => 'დალაგება A-Z-ს მიხედვით'; - - @override - String get sort_z_a => 'დალაგება Z-A-ს მიხედვით'; - - @override - String get sort_artist => 'დალაგება არტისტის მიხედვით'; - - @override - String get sort_album => 'დალაგება ალბომის მიხედვით'; - - @override - String get sort_duration => 'დალაგება ხანგრძლივობის მიხედვით'; - - @override - String get sort_tracks => 'ტრეკების დალაგება'; - - @override - String currently_downloading(Object tracks_length) { - return 'მიმდინარეობს ჩამოტვირთვა ($tracks_length)'; - } - - @override - String get cancel_all => 'ყველას გაუქმება'; - - @override - String get filter_artist => 'არტისტების ფილტრი...'; - - @override - String followers(Object followers) { - return '$followers ფოლოვერები'; - } - - @override - String get add_artist_to_blacklist => 'არტისტის შავ სიაში დამატება'; - - @override - String get top_tracks => 'ტოპ ტრეკები'; - - @override - String get fans_also_like => 'ფანებს ასევე მოსწონთ'; - - @override - String get loading => 'იტვირთება...'; - - @override - String get artist => 'არტისტი'; - - @override - String get blacklisted => 'შავ სიაში მყოფი'; - - @override - String get following => 'ფოლოვინგი'; - - @override - String get follow => 'დაფოლოვება'; - - @override - String get artist_url_copied => 'არტისტის ლინკი დაკოპირებულია'; - - @override - String added_to_queue(Object tracks) { - return '$tracks ტრეკი დაემატა რიგში'; - } - - @override - String get filter_albums => 'ალბომების გაფილტვრა...'; - - @override - String get synced => 'სინქრონიზებული'; - - @override - String get plain => 'Plain'; - - @override - String get shuffle => 'რიგის არევა'; - - @override - String get search_tracks => 'ტრეკების ძებნა...'; - - @override - String get released => 'გამოშვებული'; - - @override - String error(Object error) { - return 'შეცდომა $error'; - } - - @override - String get title => 'სათაური'; - - @override - String get time => 'დრო'; - - @override - String get more_actions => 'მეტი მოქმედებები'; - - @override - String download_count(Object count) { - return 'გადმოწერა ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'ფლეილისტში ($count)-ის დამატება'; - } - - @override - String add_count_to_queue(Object count) { - return 'რიგში ($count)-ის დამატება'; - } - - @override - String play_count_next(Object count) { - return 'შემდეგი ($count)-ის დაკვრა'; - } - - @override - String get album => 'ალბომი'; - - @override - String copied_to_clipboard(Object data) { - return '$data დაკოპირებულია'; - } - - @override - String add_to_following_playlists(Object track) { - return 'დაამატე $track ამ ფლეილისტებში'; - } - - @override - String get add => 'დამატება'; - - @override - String added_track_to_queue(Object track) { - return 'რიგში დაემატა $track'; - } - - @override - String get add_to_queue => 'რიგში დამატება'; - - @override - String track_will_play_next(Object track) { - return '$track დაუკრავს შემდეგს'; - } - - @override - String get play_next => 'შემდეგის დაკვრა'; - - @override - String removed_track_from_queue(Object track) { - return 'რიგიდან წაიშალა $track'; - } - - @override - String get remove_from_queue => 'რიგიდან წაშლა'; - - @override - String get remove_from_favorites => 'ფავორიტებიდან წაშლა'; - - @override - String get save_as_favorite => 'ფავორიტებში დამატება'; - - @override - String get add_to_playlist => 'ფლეილისტში დამატება'; - - @override - String get remove_from_playlist => 'ფლეილისტიდან წაშლა'; - - @override - String get add_to_blacklist => 'შავ სიაში დამატება'; - - @override - String get remove_from_blacklist => 'შავი სიიდან წაშლა'; - - @override - String get share => 'გაზიარება'; - - @override - String get mini_player => 'მინი დამკვრელი'; - - @override - String get slide_to_seek => 'გადახვევისთვის გაასრიალეთ წინ ან უკან'; - - @override - String get shuffle_playlist => 'ფლეილისტის არევა'; - - @override - String get unshuffle_playlist => 'ფლეილისტის დალაგება'; - - @override - String get previous_track => 'წინა ტრეკი'; - - @override - String get next_track => 'შემდეგი ტრეკი'; - - @override - String get pause_playback => 'დაკვრის გაჩერება'; - - @override - String get resume_playback => 'დაკვრის გაგრძელება'; - - @override - String get loop_track => 'ტრეკის ლუპზე დაკვრა'; - - @override - String get no_loop => 'არ არის ციკლი'; - - @override - String get repeat_playlist => 'ფლეილისტის გამეორება'; - - @override - String get queue => 'რიგი'; - - @override - String get alternative_track_sources => 'ალტერნატიული ტრეკების წყაროები'; - - @override - String get download_track => 'გადმოწერე ტრეკი'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks ტრეკი რიგში'; - } - - @override - String get clear_all => 'ყველას წაშლა'; - - @override - String get show_hide_ui_on_hover => 'UI-ის ჩვენება/დამალვა ჰოვერზე'; - - @override - String get always_on_top => 'ტოველთვის ზემოდან'; - - @override - String get exit_mini_player => 'მინი დამკვრელიდან გამოსვლა'; - - @override - String get download_location => 'ჩამოტვირთვის მდებარეობა'; - - @override - String get local_library => 'ადგილობრივი ბიბლიოთეკა'; - - @override - String get add_library_location => 'ბიბლიოთეკაში დამატება'; - - @override - String get remove_library_location => 'ბიბლიოთეკიდან წაშლა'; - - @override - String get account => 'ანგარიში'; - - @override - String get logout => 'გასვლა'; - - @override - String get logout_of_this_account => 'ანგარიშიდან გასვლა'; - - @override - String get language_region => 'ენა და რეგიონი'; - - @override - String get language => 'ენა'; - - @override - String get system_default => 'სისტემის ნაგულისხმევი'; - - @override - String get market_place_region => 'მარკეტფლეისის რეგიონი'; - - @override - String get recommendation_country => 'რეკომენდირებული ქვეყანა'; - - @override - String get appearance => 'გარეგნობა'; - - @override - String get layout_mode => 'განლაგების რეჟიმი'; - - @override - String get override_layout_settings => - 'რესფონსივ განლაგების რეჟიმის კონფიგურაციაზე გადაწერა'; - - @override - String get adaptive => 'ადაპტირებული'; - - @override - String get compact => 'კომპაქტური'; - - @override - String get extended => 'გაფართოებული'; - - @override - String get theme => 'თემა'; - - @override - String get dark => 'ბნელი'; - - @override - String get light => 'ღია'; - - @override - String get system => 'სისტემის'; - - @override - String get accent_color => 'აქცენტის ფერი'; - - @override - String get sync_album_color => 'ალბომის ფერის სინქრონიზაცია'; - - @override - String get sync_album_color_description => - 'დომინანტური ალბომის ფერის აქცენტის ფერად გამოყენება'; - - @override - String get playback => 'დაკვრა'; - - @override - String get audio_quality => 'აუდიოს ხარისხი'; - - @override - String get high => 'მაღალი'; - - @override - String get low => 'დაბალი'; - - @override - String get pre_download_play => 'წინასწარ ჩამოტვირთვა და დაკვრა'; - - @override - String get pre_download_play_description => - 'აუდიოს სტრიმინგის ნაცვლად, ბაიტების ჩამოტვირთვა და დაკვრა (რეკომენდებულია უფრო მაღალი გამტარუნარიანობის მომხმარებლებისთვის)'; - - @override - String get skip_non_music => - 'არა მუსიკალური ნაწილის გამოტოვება (სპონსორის ბლოკი)'; - - @override - String get blacklist_description => 'შავ სიაში მყოფი არტისტები და ტრეკები'; - - @override - String get wait_for_download_to_finish => - 'გთხოვთ, დაელოდოთ მიმდინარე ჩამოტვირთვის დასრულებას'; - - @override - String get desktop => 'დესკტოპი'; - - @override - String get close_behavior => 'დახურვის ქცევა'; - - @override - String get close => 'დახურვა'; - - @override - String get minimize_to_tray => 'მინიმიზაცია'; - - @override - String get show_tray_icon => 'სისტემის აიკონის ჩვენება'; - - @override - String get about => 'ჩვენს შესახებ'; - - @override - String get u_love_spotube => 'We know you love Spotube'; - - @override - String get check_for_updates => 'განახლებების შემოწმება'; - - @override - String get about_spotube => 'Spotube-ს შესახებ'; - - @override - String get blacklist => 'შავი სია'; - - @override - String get please_sponsor => 'გთხოვთ დაგვასპონსოროთ'; - - @override - String get spotube_description => - 'Spotube, a lightweight, cross-platform, free-for-all spotify client'; - - @override - String get version => 'ვერსია'; - - @override - String get build_number => 'Build Number'; - - @override - String get founder => 'დამფუძნებელი'; - - @override - String get repository => 'რეპოზიტორია'; - - @override - String get bug_issues => 'Bug+Issues'; - - @override - String get made_with => 'Made with ❤️ in Bangladesh🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'ლიცენზია'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'არ ინერვიულოთ, თქვენი მონაცემები არ იქნება შეგროვებული ან გაზიარებული ვინმესთან'; - - @override - String get know_how_to_login => 'არ იცით როგორ გააკეთოთ ეს?'; - - @override - String get follow_step_by_step_guide => - 'მიჰყევით ნაბიჯ-ნაბიჯ სახელმძღვანელოს'; - - @override - String cookie_name_cookie(Object name) { - return '$name ქუქი'; - } - - @override - String get fill_in_all_fields => 'გთხოვთ შეავსოთ ყველა ველი'; - - @override - String get submit => 'გაგზავნა'; - - @override - String get exit => 'გამოსვლა'; - - @override - String get previous => 'წინა'; - - @override - String get next => 'შემდეგი'; - - @override - String get done => 'მზადაა'; - - @override - String get step_1 => 'ნაბიჯი 1'; - - @override - String get first_go_to => 'პირველი, გადადით'; - - @override - String get something_went_wrong => 'Რაღაც არასწორად წავიდა'; - - @override - String get piped_instance => 'Piped Server Instance'; - - @override - String get piped_description => - 'The Piped server instance to use for track matching'; - - @override - String get piped_warning => 'ზოგიერთი მათგანმა შეიძლება კარგად არ იმუშაოს. '; - - @override - String get invidious_instance => 'Invidious სერვერის ინსტანცია'; - - @override - String get invidious_description => - 'Invidious სერვერის ინსტანცია, რომელიც გამოიყენება ტრეკის შესატყვისად'; - - @override - String get invidious_warning => - 'ზოგიერთი შეიძლება კარგად არ მუშაობდეს. გამოიყენეთ თქვენს პასუხისმგებლობაზე'; - - @override - String get generate => 'გააგენერირეთ'; - - @override - String track_exists(Object track) { - return 'ტრეკი $track უკვე არსებობს'; - } - - @override - String get replace_downloaded_tracks => 'ყველა ჩამოტვირთული ტრეკის შეცვლა'; - - @override - String get skip_download_tracks => 'ყველა ჩამოტვირთული ტრეკის გამოტოვება'; - - @override - String get do_you_want_to_replace => 'გსურთ შეცვალოთ არსებული ტრეკი??'; - - @override - String get replace => 'შეცვლა'; - - @override - String get skip => 'გამოტოვება'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'აირჩიე $count-მდე $type'; - } - - @override - String get select_genres => 'ჟანრების არჩევა'; - - @override - String get add_genres => 'ჟანრების დამატება'; - - @override - String get country => 'ქვეყანა'; - - @override - String get number_of_tracks_generate => 'დასაგენერირებელი ტრეკების რაოდენობა'; - - @override - String get acousticness => 'Acousticness'; - - @override - String get danceability => 'Danceability'; - - @override - String get energy => 'Energy'; - - @override - String get instrumentalness => 'Instrumentalness'; - - @override - String get liveness => 'Liveness'; - - @override - String get loudness => 'Loudness'; - - @override - String get speechiness => 'Speechiness'; - - @override - String get valence => 'Valence'; - - @override - String get popularity => 'Popularity'; - - @override - String get key => 'Key'; - - @override - String get duration => 'Duration (s)'; - - @override - String get tempo => 'Tempo (BPM)'; - - @override - String get mode => 'Mode'; - - @override - String get time_signature => 'Time Signature'; - - @override - String get short => 'Short'; - - @override - String get medium => 'საშუალო'; - - @override - String get long => 'გრძელი'; - - @override - String get min => 'მინიმალური'; - - @override - String get max => 'მაქსიმალური'; - - @override - String get target => 'სამიზნე'; - - @override - String get moderate => 'საშუალო'; - - @override - String get deselect_all => 'ყველა მონიშვნის გაუქმება'; - - @override - String get select_all => 'ყველას მონიშვნა'; - - @override - String get are_you_sure => 'Დარწმუნებული ხართ?'; - - @override - String get generating_playlist => - 'მიმდინარეობს თქვენი მორგებული ფლეილისტის გენერირება...'; - - @override - String selected_count_tracks(Object count) { - return 'არჩეულია $count ტრეკი'; - } - - @override - String get download_warning => - 'If you download all Tracks at bulk you\'re clearly pirating Music & causing damage to the creative society of Music. I hope you are aware of this. Always, try respecting & supporting Artist\'s hard work'; - - @override - String get download_ip_ban_warning => - 'BTW, your IP can get blocked on YouTube due excessive download requests than usual. IP block means you can\'t use YouTube (even if you\'re logged in) for at least 2-3 months from that IP device. And Spotube doesn\'t hold any responsibility if this ever happens'; - - @override - String get by_clicking_accept_terms => - 'By clicking \'accept\' you agree to following terms:'; - - @override - String get download_agreement_1 => 'I know I\'m pirating Music. I\'m bad'; - - @override - String get download_agreement_2 => - 'I\'ll support the Artist wherever I can and I\'m only doing this because I don\'t have money to buy their art'; - - @override - String get download_agreement_3 => - 'I\'m completely aware that my IP can get blocked on YouTube & I don\'t hold Spotube or his owners/contributors responsible for any accidents caused by my current action'; - - @override - String get decline => 'უარყოფა'; - - @override - String get accept => 'დათანხმება'; - - @override - String get details => 'დეტალები'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Channel'; - - @override - String get likes => 'მოწონებები'; - - @override - String get dislikes => 'არ მოწონებები'; - - @override - String get views => 'ნახვები'; - - @override - String get streamUrl => 'სტრიმის ლინკი'; - - @override - String get stop => 'გაჩერება'; - - @override - String get sort_newest => 'ფალაგება სიახლის მიხედიტ'; - - @override - String get sort_oldest => 'დალაგება სიძველის მიხედვით'; - - @override - String get sleep_timer => 'ძილის ტაიმერი'; - - @override - String mins(Object minutes) { - return '$minutes წუთი'; - } - - @override - String hours(Object hours) { - return '$hours საათი'; - } - - @override - String hour(Object hours) { - return '$hours საათი'; - } - - @override - String get custom_hours => 'მორგებული საათები'; - - @override - String get logs => 'ლოგები'; - - @override - String get developers => 'დეველოპერები'; - - @override - String get not_logged_in => 'არ ხარ დალოგინებული'; - - @override - String get search_mode => 'ძებნის რეჟიმი'; - - @override - String get audio_source => 'აუდიოს წყარო'; - - @override - String get ok => 'ოკ'; - - @override - String get failed_to_encrypt => 'დაშიფვრა ვერ მოხერხდა'; - - @override - String get encryption_failed_warning => - 'Spotube uses encryption to securely store your data. But failed to do so. So it\'ll fallback to insecure storage\nIf you\'re using linux, please make sure you\'ve any secret-service (gnome-keyring, kde-wallet, keepassxc etc) installed'; - - @override - String get querying_info => 'Querying info...'; - - @override - String get piped_api_down => 'Piped API is down'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'The Piped instance $pipedInstance is currently down\n\nEither change the instance or change the \'API type\' to official YouTube API\n\nMake sure to restart the app after change'; - } - - @override - String get you_are_offline => 'ამჟამად ხაზგარეშე ხართ'; - - @override - String get connection_restored => 'თქვენი ინტერნეტ კავშირი აღდგა'; - - @override - String get use_system_title_bar => 'სისტემის სათაურის ზოლის გამოყენება'; - - @override - String get crunching_results => 'იტვირთება შედეგები...'; - - @override - String get search_to_get_results => 'მოძებნეთ შედეგების მისაღებად'; - - @override - String get use_amoled_mode => 'Pitch black dark theme'; - - @override - String get pitch_dark_theme => 'AMOLED Mode'; - - @override - String get normalize_audio => 'აუდიოს ნორმალიზება'; - - @override - String get change_cover => 'Ქავერის შეცვლა'; - - @override - String get add_cover => 'Ქავერის ფოტოს დამატება'; - - @override - String get restore_defaults => 'ნაგულისხმევი პარამეტრების აღდგენა'; - - @override - String get download_music_format => 'მუსიკის ჩამოტვირთვის ფორმატი'; - - @override - String get streaming_music_format => 'სტრიმინგის მუსიკის ფორმატი'; - - @override - String get download_music_quality => 'ჩამოტვირთვის ხარისხი'; - - @override - String get streaming_music_quality => 'სტრიმინგის ხარისხი'; - - @override - String get login_with_lastfm => 'Last.fm-ით შესვლა'; - - @override - String get connect => 'დაკავშირება'; - - @override - String get disconnect_lastfm => 'Last.fm-იდან გამოსვლა'; - - @override - String get disconnect => 'გამოსვლა'; - - @override - String get username => 'მომხმარებელი'; - - @override - String get password => 'პაროლი'; - - @override - String get login => 'შესვლა'; - - @override - String get login_with_your_lastfm => 'Last.fm ანგარიშით შესვლა'; - - @override - String get scrobble_to_lastfm => 'Scrobble to Last.fm'; - - @override - String get go_to_album => 'ალბომზე გადასვლა'; - - @override - String get discord_rich_presence => 'Discord Rich Presence'; - - @override - String get browse_all => 'ყველას ნახვა'; - - @override - String get genres => 'ჟანრები'; - - @override - String get explore_genres => 'შეისწავლეთ ჟანრები'; - - @override - String get friends => 'მეგობრები'; - - @override - String get no_lyrics_available => - 'უკაცრავად, ამ ტრეკისთვის ტექსტის პოვნა შეუძლებელია'; - - @override - String get start_a_radio => 'რადიოს ჩართვა'; - - @override - String get how_to_start_radio => 'როგორ გნებავთ რადიოს ჩართვა?'; - - @override - String get replace_queue_question => - 'გნებავთ ჩაანაცვლოთ არსებული რიგი თუ დაამატოთ მასზე?'; - - @override - String get endless_playback => 'დაუსრულებელი დაკვრა'; - - @override - String get delete_playlist => 'ფლეილისტის წაშლა'; - - @override - String get delete_playlist_confirmation => - 'დარწმუნებული ხართ რომ გნებავთ ფლეილისტის წაშლა?'; - - @override - String get local_tracks => 'ლოკალური ტრეკები'; - - @override - String get local_tab => 'ადგილობრივი'; - - @override - String get song_link => 'ტრეკის ლინკი'; - - @override - String get skip_this_nonsense => 'ამ სისულელის გამოტოვება'; - - @override - String get freedom_of_music => '“მუსიკის თავისუფლება”'; - - @override - String get freedom_of_music_palm => '“მუსიკის თავისუფლება შენს ხელის გულზე”'; - - @override - String get get_started => 'დავიწყოთ'; - - @override - String get youtube_source_description => - 'რეკომენდებულია და მუშაობს საუკეთესოდ.'; - - @override - String get piped_source_description => - 'თავისუფლად გრძნობთ თავს? იგივეა, რაც YouTube, მაგრამ ბევრი თავისუფალი.'; - - @override - String get jiosaavn_source_description => - 'საუკეთესოა სამხრეთ აზიის რეგიონისთვის.'; - - @override - String get invidious_source_description => - 'მსგავსია Piped-ის, მაგრამ მაღალი ხელმისაწვდომობით.'; - - @override - String highest_quality(Object quality) { - return 'საუკეთესო ხარისხი: $quality'; - } - - @override - String get select_audio_source => 'აუდიოს წყაროს არჩევა'; - - @override - String get endless_playback_description => - 'ახალი სიმთერების ავტომატურად რიგის ბოლოში დამატება'; - - @override - String get choose_your_region => 'აირჩიე შენი რეგიონი'; - - @override - String get choose_your_region_description => - 'This will help Spotube show you the right content\nfor your location.'; - - @override - String get choose_your_language => 'აირჩიე ენა'; - - @override - String get help_project_grow => 'დაეხმარეთ ამ პროექტს განვითარებაში'; - - @override - String get help_project_grow_description => - 'Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.'; - - @override - String get contribute_on_github => 'GitHub-ზე კონტრიბუცია'; - - @override - String get donate_on_open_collective => 'Open Collective-ზე დონაცია'; - - @override - String get browse_anonymously => 'ანონიმურად ნახვა'; - - @override - String get enable_connect => 'დაკავშირების ჩართვა'; - - @override - String get enable_connect_description => - 'აკონტროლე Spotube სხვა მოწყობილობებიდან'; - - @override - String get devices => 'მოწყობილობები'; - - @override - String get select => 'არჩევა'; - - @override - String connect_client_alert(Object client) { - return 'თქვენ კონტროლირებული ხართ $client მოწყობილობით'; - } - - @override - String get this_device => 'ეს მოწყობილობა'; - - @override - String get remote => 'დისტანციური'; - - @override - String get stats => 'სტატისტიკა'; - - @override - String and_n_more(Object count) { - return 'და $count მეტი'; - } - - @override - String get recently_played => 'მიუწვდელი'; - - @override - String get browse_more => 'დაიცალეთ მეტი'; - - @override - String get no_title => 'არ აქვს სათაური'; - - @override - String get not_playing => 'არ ერთვის'; - - @override - String get epic_failure => 'ეპიკური მარცხი!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'დამატებული $tracks_length ტრეკი რიგში'; - } - - @override - String get spotube_has_an_update => 'Spotube-ს აქვს განახლება'; - - @override - String get download_now => 'ჩამოტვირთეთ ახლავე'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum გამოშვებულია'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version გამოშვებულია'; - } - - @override - String get read_the_latest => 'წაიკითხეთ უახლესი '; - - @override - String get release_notes => 'გამოშვების შენიშვნები'; - - @override - String get pick_color_scheme => 'აირჩიეთ ფერის სქემა'; - - @override - String get save => 'შეინახეთ'; - - @override - String get choose_the_device => 'აირჩიეთ მოწყობილობა:'; - - @override - String get multiple_device_connected => - 'დაკავშირებულია რამდენიმე მოწყობილობა.\nაირჩიეთ მოწყობილობა, რომელზეც უნდა განხორციელდეს ეს მოქმედება'; - - @override - String get nothing_found => 'არაფერი მოიძებნა'; - - @override - String get the_box_is_empty => 'კვადრატია ცარიელი'; - - @override - String get top_artists => 'ტოპ არტისტები'; - - @override - String get top_albums => 'ტოპ ალბომები'; - - @override - String get this_week => 'ამ კვირას'; - - @override - String get this_month => 'ამ თვეში'; - - @override - String get last_6_months => 'ბოლო 6 თვე'; - - @override - String get this_year => 'ამ წელს'; - - @override - String get last_2_years => 'ბოლო 2 წელი'; - - @override - String get all_time => 'ყველა დრო'; - - @override - String powered_by_provider(Object providerName) { - return '$providerName-ით გაწვდილი'; - } - - @override - String get email => 'ელ. ფოსტა'; - - @override - String get profile_followers => 'გამყვანები'; - - @override - String get birthday => 'დაბადების დღე'; - - @override - String get subscription => 'გამოწერა'; - - @override - String get not_born => 'არ დაბადებულა'; - - @override - String get hacker => 'ჰაკერი'; - - @override - String get profile => 'პროფილი'; - - @override - String get no_name => 'არ არის სახელი'; - - @override - String get edit => 'რედაქტირება'; - - @override - String get user_profile => 'მომხმარებლის პროფილი'; - - @override - String count_plays(Object count) { - return '$count გაწვდვა'; - } - - @override - String get streaming_fees_hypothetical => - '*ეს рассчитывается на основе выплат за поток от Spotify\nот \$0.003 до \$0.005. ეს ჰიპოთეტური გამოთვლა იძლევა მომხმარებელს წარმოდგენას იმაზე, რამდენად\nგადახდილი იქნებოდა არტისტებისთვის, თუ მათ მოუსმინოს Spotify-ს ტრეკებს.'; - - @override - String get minutes_listened => 'წუთები მოუსმინეს'; - - @override - String get streamed_songs => 'სტრიმირებული სიმღერები'; - - @override - String count_streams(Object count) { - return '$count სტრიმი'; - } - - @override - String get owned_by_you => 'შენ მიერ საკუთრებული'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl აიღო კლიპბორდზე'; - } - - @override - String get hipotetical_calculation => - '*ეს გამოითვლება ონლაინ მუსიკალური სტრიმინგის პლატფორმების საშუალო ანაზღაურების საფუძველზე, რომელიც შეადგენს \$0.003-დან \$0.005-მდე. ეს არის ჰიპოთეტური გაანგარიშება, რომელიც მომხმარებელს აძლევს წარმოდგენას, თუ რამდენს გადაუხდიდნენ ისინი არტისტებს, თუ მათ სიმღერებს მოუსმენდნენ სხვადასხვა მუსიკალურ სტრიმინგ პლატფორმაზე.'; - - @override - String count_mins(Object minutes) { - return '$minutes წუთი'; - } - - @override - String get summary_minutes => 'წუთები'; - - @override - String get summary_listened_to_music => 'მუსიკა გაწვდილი'; - - @override - String get summary_songs => 'მელოდია'; - - @override - String get summary_streamed_overall => 'გაწვდილი საერთო'; - - @override - String get summary_owed_to_artists => 'გადასახადი არტისტებს\nამ თვეში'; - - @override - String get summary_artists => 'არტისტების'; - - @override - String get summary_music_reached_you => 'მუსიკა ჩაგივარდა'; - - @override - String get summary_full_albums => 'სრული ალბომები'; - - @override - String get summary_got_your_love => 'მოსულა თქვენი სიყვარული'; - - @override - String get summary_playlists => 'პლეილისტები'; - - @override - String get summary_were_on_repeat => 'გადაწვდილი იყო'; - - @override - String total_money(Object money) { - return 'მთლიანი $money'; - } - - @override - String get webview_not_found => 'ვებვიუ ვერ მოიძებნა'; - - @override - String get webview_not_found_description => - 'თქვენს მოწყობილობაზე ვებვიუის შესრულების დრო არ არის დაყენებული.\nთუ დაყენებულია, დარწმუნდით, რომ ის environment PATH-შია\n\nდაყენების შემდეგ, გადატვირთეთ აპი'; - - @override - String get unsupported_platform => 'მოუხერხებელი პლატფორმა'; - - @override - String get cache_music => 'მუსიკის ქეში'; - - @override - String get open => 'გახსენით'; - - @override - String get cache_folder => 'ქეშის საქაღალდე'; - - @override - String get export => 'ექსპორტი'; - - @override - String get clear_cache => 'ქეშის გასუფთავება'; - - @override - String get clear_cache_confirmation => 'გსურთ ქეშის გასუფთავება?'; - - @override - String get export_cache_files => 'ქეშირებული ფაილების ექსპორტი'; - - @override - String found_n_files(Object count) { - return 'ნაპოვნია $count ფაილი'; - } - - @override - String get export_cache_confirmation => 'გსურთ ამ ფაილების ექსპორტი'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return '$filesExported ფაილი $files-დან ექსპორტირებულია'; - } - - @override - String get undo => 'დაბრუნება'; - - @override - String get download_all => 'ყველას ჩამოტვირთვა'; - - @override - String get add_all_to_playlist => 'ყველა დაამატეთ პლეისთში'; - - @override - String get add_all_to_queue => 'ყველა დაამატეთ რიგში'; - - @override - String get play_all_next => 'ყველა შემდეგ ითამაშე'; - - @override - String get pause => 'შეჩერება'; - - @override - String get view_all => 'ყველა ნახვა'; - - @override - String get no_tracks_added_yet => - 'გაჩნდება რომ ჯერ არ გაქვთ დამატებული ტრეკები'; - - @override - String get no_tracks => 'გავლებული არ ჩანს არ არსებობს ტრეკები'; - - @override - String get no_tracks_listened_yet => - 'გქონდეთ გრძნობა, რომ ჯერ არაფერი უსმენია'; - - @override - String get not_following_artists => 'არ მიჰყვებით რომელიმე არტისტს'; - - @override - String get no_favorite_albums_yet => - 'გაჩნდება რომ ჯერ არ გაქვთ დამატებული ალბომები თქვენს ფავორიტებში'; - - @override - String get no_logs_found => 'ჩაწერები ვერ მოიძებნა'; - - @override - String get youtube_engine => 'YouTube ძრავა'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine არ არის ინსტალირებული'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine არ არის ინსტალირებული თქვენს სისტემაში.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'დარწმუნდით, რომ ის ხელმისაწვდომია PATH ცვლადში ან\nდაუყავით $engine პროგრამის ფაილის სრული გზა'; - } - - @override - String get youtube_engine_unix_issue_message => - 'macOS/Linux/Unix მსგავსი ოპერაციული სისტემებში, .zshrc/.bashrc/.bash_profile-ით პათის დაყენება ვერ იმუშავებს.\nთქვენ უნდა დააყენოთ პათი შელ ფაილში'; - - @override - String get download => 'ჩამოტვირთვა'; - - @override - String get file_not_found => 'ფაილი ვერ მოიძებნა'; - - @override - String get custom => 'პერსონალიზირებული'; - - @override - String get add_custom_url => 'დამატება პერსონალური URL'; - - @override - String get edit_port => 'პორტის რედაქტირება'; - - @override - String get port_helper_msg => - 'ნაგულისხმევი არის -1, რაც შემთხვევითი ნომრის მითითებას ნიშნავს. თუ لديك firewall настроен, рекомендуется установить это.'; - - @override - String connect_request(Object client) { - return '$client-ის დაკავშირების ნებართვა?'; - } - - @override - String get connection_request_denied => - 'კავშირი უარყოფილია. მომხმარებელმა უარყო წვდომა.'; - - @override - String get an_error_occurred => 'მოხდა შეცდომა'; - - @override - String get copy_to_clipboard => 'კოპირება ბუფერში'; - - @override - String get view_logs => 'იხილეთ ჟურნალები'; - - @override - String get retry => 'ხელახლა ცდა'; - - @override - String get no_default_metadata_provider_selected => - 'თქვენ არ გაქვთ დაყენებული ნაგულისხმევი მეტამონაცემების პროვაიდერი'; - - @override - String get manage_metadata_providers => - 'მეტამონაცემების პროვაიდერების მართვა'; - - @override - String get open_link_in_browser => 'ბმულის გახსნა ბრაუზერში?'; - - @override - String get do_you_want_to_open_the_following_link => - 'გსურთ გახსნათ შემდეგი ბმული'; - - @override - String get unsafe_url_warning => - 'შეიძლება სახიფათო იყოს ბმულების გახსნა უნდობელი წყაროებიდან. იყავით ფრთხილად!\nასევე შეგიძლიათ დააკოპიროთ ბმული თქვენს ბუფერში.'; - - @override - String get copy_link => 'ბმულის კოპირება'; - - @override - String get building_your_timeline => - 'თქვენი დროის ხაზის აგება თქვენი მოსმენების საფუძველზე...'; - - @override - String get official => 'ოფიციალური'; - - @override - String author_name(Object author) { - return 'ავტორი: $author'; - } - - @override - String get third_party => 'მესამე მხარის'; - - @override - String get plugin_requires_authentication => - 'პლაგინი საჭიროებს ავთენტიფიკაციას'; - - @override - String get update_available => 'განახლება ხელმისაწვდომია'; - - @override - String get supports_scrobbling => 'მხარს უჭერს სქრობლინგს'; - - @override - String get plugin_scrobbling_info => - 'ეს პლაგინი აწარმოებს თქვენი მუსიკის სქრობლინგს, რათა შექმნას თქვენი მოსმენის ისტორია.'; - - @override - String get default_metadata_source => 'ნაგულისხმევი მეტამონაცემების წყარო'; - - @override - String get set_default_metadata_source => - 'ნაგულისხმევი მეტამონაცემების წყაროს დაყენება'; - - @override - String get default_audio_source => 'ნაგულისხმევი აუდიო წყარო'; - - @override - String get set_default_audio_source => 'ნაგულისხმევი აუდიო წყაროს დაყენება'; - - @override - String get set_default => 'ნაგულისხმევად დაყენება'; - - @override - String get support => 'მხარდაჭერა'; - - @override - String get support_plugin_development => 'პლაგინის განვითარების მხარდაჭერა'; - - @override - String can_access_name_api(Object name) { - return '- შეუძლია წვდომა **$name** API-ზე'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'გსურთ ამ პლაგინის დაყენება?'; - - @override - String get third_party_plugin_warning => - 'ეს პლაგინი არის მესამე მხარის საცავიდან. გთხოვთ, დარწმუნდეთ, რომ ენდობით წყაროს დაყენებამდე.'; - - @override - String get author => 'ავტორი'; - - @override - String get this_plugin_can_do_following => - 'ამ პლაგინს შეუძლია შემდეგის გაკეთება'; - - @override - String get install => 'დაყენება'; - - @override - String get install_a_metadata_provider => - 'დააყენეთ მეტამონაცემების პროვაიდერი'; - - @override - String get no_tracks_playing => 'ამჟამად არ უკრავს არცერთი ტრეკი'; - - @override - String get synced_lyrics_not_available => - 'ამ სიმღერისთვის სინქრონიზებული ტექსტები არ არის ხელმისაწვდომი. გთხოვთ, გამოიყენოთ'; - - @override - String get plain_lyrics => 'მარტივი ტექსტები'; - - @override - String get tab_instead => 'ჩანართი, სანაცვლოდ.'; - - @override - String get disclaimer => 'პასუხისმგებლობის უარყოფა'; - - @override - String get third_party_plugin_dmca_notice => - 'Spotube-ის გუნდი არ იღებს პასუხისმგებლობას (მათ შორის, იურიდიულს) არცერთ \"მესამე მხარის\" პლაგინზე.\nგთხოვთ, გამოიყენოთ ისინი თქვენი რისკის ქვეშ. ნებისმიერი ხარვეზის/პრობლემის შესახებ შეატყობინეთ პლაგინის საცავს.\n\nთუ რომელიმე \"მესამე მხარის\" პლაგინი არღვევს რაიმე სერვისის/იურიდიული პირის ToS/DMCA-ს, გთხოვთ, სთხოვეთ \"მესამე მხარის\" პლაგინის ავტორს ან ჰოსტინგის პლატფორმას, მაგალითად GitHub/Codeberg, მიიღოს ზომები. ზემოთ ჩამოთვლილი (\"მესამე მხარის\" ეტიკეტის მქონე) ყველა არის საჯარო/საზოგადოების მიერ შენარჩუნებული პლაგინები. ჩვენ მათ არ ვაკონტროლებთ, ამიტომ არ შეგვიძლია მათზე რაიმე ზომების მიღება.\n\n'; - - @override - String get input_does_not_match_format => - 'შეყვანა არ ემთხვევა საჭირო ფორმატს'; - - @override - String get plugins => 'პლაგინები'; - - @override - String get paste_plugin_download_url => - 'ჩასვით ჩამოტვირთვის url ან GitHub/Codeberg-ის რეპოს url ან პირდაპირი ბმული .smplug ფაილზე'; - - @override - String get download_and_install_plugin_from_url => - 'პლაგინის ჩამოტვირთვა და დაყენება url-დან'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'პლაგინის დამატება ვერ მოხერხდა: $error'; - } - - @override - String get upload_plugin_from_file => 'პლაგინის ატვირთვა ფაილიდან'; - - @override - String get installed => 'დაინსტალირებული'; - - @override - String get available_plugins => 'ხელმისაწვდომი პლაგინები'; - - @override - String get configure_plugins => - 'თქვენი საკუთარი მეტამონაცემებისა და აუდიო წყაროს პლაგინების კონფიგურაცია'; - - @override - String get audio_scrobblers => 'აუდიო სქრობლერები'; - - @override - String get scrobbling => 'სქრობლინგი'; - - @override - String get source => 'წყარო: '; - - @override - String get uncompressed => 'შეუკუმშავი'; - - @override - String get dab_music_source_description => - 'აუდიოფილებისთვის. უზრუნველყოფს მაღალი ხარისხის/უკომპრესო აუდიო სტრიმებს. ზუსტი შესაბამისობა ISRC-ის მიხედვით.'; -} diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart deleted file mode 100644 index 42ea337a..00000000 --- a/lib/l10n/generated/app_localizations_ko.dart +++ /dev/null @@ -1,1538 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Korean (`ko`). -class AppLocalizationsKo extends AppLocalizations { - AppLocalizationsKo([String locale = 'ko']) : super(locale); - - @override - String get guest => '게스트'; - - @override - String get browse => '찾아보기'; - - @override - String get search => '검색'; - - @override - String get library => '라이브러리'; - - @override - String get lyrics => '가사'; - - @override - String get settings => '설정'; - - @override - String get genre_categories_filter => '카테고리 혹은 장르별로 불러오기'; - - @override - String get genre => '장르'; - - @override - String get personalized => '맞춤 추천'; - - @override - String get featured => '인기'; - - @override - String get new_releases => '신곡'; - - @override - String get songs => '노래'; - - @override - String playing_track(Object track) { - return '$track 을 재생'; - } - - @override - String queue_clear_alert(Object track_length) { - return '현재 재생 대기열을 없앱니다。$track_length 곡이 제거됩니다。\n계속 진행할까요?'; - } - - @override - String get load_more => '더 불러오기'; - - @override - String get playlists => '플레이리스트'; - - @override - String get artists => '아티스트'; - - @override - String get albums => '앨범'; - - @override - String get tracks => '곡'; - - @override - String get downloads => '다운로드한 곡'; - - @override - String get filter_playlists => '플레이리스트를 필터링'; - - @override - String get liked_tracks => '좋아하는 곡'; - - @override - String get liked_tracks_description => '좋아요를 남긴 곡들'; - - @override - String get playlist => '재생 목록'; - - @override - String get create_a_playlist => '플레이리스트를 생성'; - - @override - String get update_playlist => '플레이리스트를 업데이트'; - - @override - String get create => '생성'; - - @override - String get cancel => '취소'; - - @override - String get update => '업데이트'; - - @override - String get playlist_name => '플레이리스트명'; - - @override - String get name_of_playlist => '플레이리스트의 이름'; - - @override - String get description => '설명'; - - @override - String get public => '공개'; - - @override - String get collaborative => '공유 플레이리스트'; - - @override - String get search_local_tracks => '기기에 저장된 곡을 검색하기'; - - @override - String get play => '재생'; - - @override - String get delete => '삭제'; - - @override - String get none => '없음'; - - @override - String get sort_a_z => 'A-Z 순으로 정렬'; - - @override - String get sort_z_a => 'Z-A 순으로 정렬'; - - @override - String get sort_artist => '아티스트 순으로 정렬'; - - @override - String get sort_album => '앨범 순으로 정렬'; - - @override - String get sort_duration => '시간순 정렬'; - - @override - String get sort_tracks => '곡명 순으로 정렬'; - - @override - String currently_downloading(Object tracks_length) { - return '현재 ($tracks_length) 곡 다운로드 중'; - } - - @override - String get cancel_all => '모두 취소'; - - @override - String get filter_artist => '아티스트 필터링'; - - @override - String followers(Object followers) { - return '$followers 팔로워'; - } - - @override - String get add_artist_to_blacklist => '이 아티스트를 블랙리스트에 추가'; - - @override - String get top_tracks => '인기곡'; - - @override - String get fans_also_like => '애청자들이 좋아하는 곡'; - - @override - String get loading => '불러오는 중...'; - - @override - String get artist => '아티스트'; - - @override - String get blacklisted => '블랙리스트'; - - @override - String get following => '팔로우 중'; - - @override - String get follow => '팔로우하기'; - - @override - String get artist_url_copied => '아티스트의 URL 주소를 클립보드에 복사함'; - - @override - String added_to_queue(Object tracks) { - return '$tracks 곡을 대기열에 추가함'; - } - - @override - String get filter_albums => '앨범 필터링'; - - @override - String get synced => '동기화됨'; - - @override - String get plain => '그대로'; - - @override - String get shuffle => '셔플'; - - @override - String get search_tracks => '곡 검색하기'; - - @override - String get released => '공개일'; - - @override - String error(Object error) { - return '에러'; - } - - @override - String get title => '타이틀'; - - @override - String get time => '길이'; - - @override - String get more_actions => '다른 작업'; - - @override - String download_count(Object count) { - return '($count) 곡 다운로드'; - } - - @override - String add_count_to_playlist(Object count) { - return '플레이리스트에 ($count) 곡을 추가'; - } - - @override - String add_count_to_queue(Object count) { - return '대기열에 ($count) 곡을 추가'; - } - - @override - String play_count_next(Object count) { - return '이 다음에 ($count) 곡을 재생'; - } - - @override - String get album => '앨범'; - - @override - String copied_to_clipboard(Object data) { - return '$data 를 클립보드에 복사함'; - } - - @override - String add_to_following_playlists(Object track) { - return '$track 을 이 플레이리스트에 추가'; - } - - @override - String get add => '추가'; - - @override - String added_track_to_queue(Object track) { - return '대기열에 $track 을 추가함'; - } - - @override - String get add_to_queue => '대기열에 추가'; - - @override - String track_will_play_next(Object track) { - return '$track 을 이 다음에 재생'; - } - - @override - String get play_next => '이 다음에 재생'; - - @override - String removed_track_from_queue(Object track) { - return '대기열에서 $track 를 제거함'; - } - - @override - String get remove_from_queue => '대기열에서 제거'; - - @override - String get remove_from_favorites => '즐겨찾기에서 제거'; - - @override - String get save_as_favorite => '즐겨찾기에 추가'; - - @override - String get add_to_playlist => '플레이리스트에 추가'; - - @override - String get remove_from_playlist => '플레이리스트에서 제거'; - - @override - String get add_to_blacklist => '블랙리스트에 추가'; - - @override - String get remove_from_blacklist => '블랙리스트에서 제거'; - - @override - String get share => '공유'; - - @override - String get mini_player => '미니 플레이어'; - - @override - String get slide_to_seek => '앞뒤로 슬라이드하여 탐색'; - - @override - String get shuffle_playlist => '플레이리스트를 섞기'; - - @override - String get unshuffle_playlist => '플레이리스트를 섞지 않기'; - - @override - String get previous_track => '이전 곡'; - - @override - String get next_track => '다음 곡'; - - @override - String get pause_playback => '일시정지'; - - @override - String get resume_playback => '재개'; - - @override - String get loop_track => '반복 재생'; - - @override - String get no_loop => '반복 없음'; - - @override - String get repeat_playlist => '플레이리스트 반복'; - - @override - String get queue => '재생 대기열'; - - @override - String get alternative_track_sources => '대체가능한 음악 서버'; - - @override - String get download_track => '곡 다운로드'; - - @override - String tracks_in_queue(Object tracks) { - return '대기열에 $tracks 곡이 있음'; - } - - @override - String get clear_all => '모두 제거'; - - @override - String get show_hide_ui_on_hover => '마우스를 올리면 UI를 표시/숨김'; - - @override - String get always_on_top => '항상 위에 표시'; - - @override - String get exit_mini_player => '미니 플레이어 닫기'; - - @override - String get download_location => '다운로드 경로'; - - @override - String get local_library => '로컬 도서관'; - - @override - String get add_library_location => '도서관에 추가'; - - @override - String get remove_library_location => '도서관에서 제거'; - - @override - String get account => '계정'; - - @override - String get logout => '로그아웃'; - - @override - String get logout_of_this_account => '이 계정에서 로그아웃'; - - @override - String get language_region => '언어 & 지역'; - - @override - String get language => '언어'; - - @override - String get system_default => '시스템 기본설정'; - - @override - String get market_place_region => '마켓플레이스 지역'; - - @override - String get recommendation_country => '추천 국가'; - - @override - String get appearance => '디자인'; - - @override - String get layout_mode => '레이아웃 모드'; - - @override - String get override_layout_settings => '반응형 레이아웃 모드 설정 덮어씌우기'; - - @override - String get adaptive => '적응형'; - - @override - String get compact => '컴팩트'; - - @override - String get extended => '확장'; - - @override - String get theme => '테마'; - - @override - String get dark => '다크'; - - @override - String get light => '라이트'; - - @override - String get system => '시스템과 동일'; - - @override - String get accent_color => '보조색'; - - @override - String get sync_album_color => '앨범 색상'; - - @override - String get sync_album_color_description => '앨범아트의 주요 색상을 보조색으로 사용'; - - @override - String get playback => '재생'; - - @override - String get audio_quality => '음질'; - - @override - String get high => '높음'; - - @override - String get low => '낮음'; - - @override - String get pre_download_play => '재생할 곡을 미리 다운로드'; - - @override - String get pre_download_play_description => - '스트리밍 방식을 쓰는 대신 파일 단위로 다운로드 받고 재생 (인터넷 대역폭이 높은 환경에서 추천)'; - - @override - String get skip_non_music => '음악이 아닌 부분을 스킵 (SponsorBlock)'; - - @override - String get blacklist_description => '블랙리스트에 추가된 곡과 아티스트'; - - @override - String get wait_for_download_to_finish => '현재 진행중인 다운로드가 끝날 때까지 기다려주세요'; - - @override - String get desktop => '데스크톱'; - - @override - String get close_behavior => '닫을 때의 동작'; - - @override - String get close => '닫기'; - - @override - String get minimize_to_tray => '트레이로 최소화'; - - @override - String get show_tray_icon => '시스템 트레이 아이콘 표시'; - - @override - String get about => '앱 정보'; - - @override - String get u_love_spotube => 'Spotube... 사랑하시죠?'; - - @override - String get check_for_updates => '업데이트 확인'; - - @override - String get about_spotube => 'Spotube에 관해'; - - @override - String get blacklist => '블랙리스트'; - - @override - String get please_sponsor => '후원해주시면 감사하겠습니다.'; - - @override - String get spotube_description => - 'Spotube는, 경량에 크로스플랫폼인데다 무료이기까지한 스포티파이 클라이언트입니다'; - - @override - String get version => '버전'; - - @override - String get build_number => '빌드 번호'; - - @override - String get founder => '창시자'; - - @override - String get repository => '리포지토리'; - - @override - String get bug_issues => '버그 및 이슈'; - - @override - String get made_with => '❤️을 담아 방글라데시에서 만듦'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => '라이선스'; - - @override - String get credentials_will_not_be_shared_disclaimer => - '걱정마세요. 개인정보를 수집하거나 공유하지 않습니다.'; - - @override - String get know_how_to_login => '어떻게 하는건지 모르겠나요?'; - - @override - String get follow_step_by_step_guide => '사용법 확인하기'; - - @override - String cookie_name_cookie(Object name) { - return '$name Cookies'; - } - - @override - String get fill_in_all_fields => '모든 필드에 정보를 입력해주세요'; - - @override - String get submit => '제출'; - - @override - String get exit => '종료'; - - @override - String get previous => '이전으로'; - - @override - String get next => '다음으로'; - - @override - String get done => '완료'; - - @override - String get step_1 => '1단계'; - - @override - String get first_go_to => '가장 먼저 먼저 들어갈 곳은 '; - - @override - String get something_went_wrong => '알 수 없는 이유로 동작에 실패했습니다.'; - - @override - String get piped_instance => 'Piped 서버의 인스턴스'; - - @override - String get piped_description => '곡 탐색에 사용할 Piped 서버 인스턴스'; - - @override - String get piped_warning => '몇몇 서버는 제대로 동작하지 않을 수 있습니다. 본인 책임 하에 이용해주세요.'; - - @override - String get invidious_instance => 'Invidious 서버 인스턴스'; - - @override - String get invidious_description => '트랙 매칭에 사용할 Invidious 서버 인스턴스'; - - @override - String get invidious_warning => '일부는 제대로 작동하지 않을 수 있습니다. 자신의 책임 하에 사용하세요'; - - @override - String get generate => '생성'; - - @override - String track_exists(Object track) { - return '곡 $track 은 이미 리스트에 있습니다'; - } - - @override - String get replace_downloaded_tracks => '다운로드한 모든 곡을 교체'; - - @override - String get skip_download_tracks => '다운로드가 끝난 곡을 모두 건너뛰기'; - - @override - String get do_you_want_to_replace => '현재 곡을 교체하시겠습니까?'; - - @override - String get replace => '교체'; - - @override - String get skip => '건너뛰기'; - - @override - String select_up_to_count_type(Object count, Object type) { - return '$type을 $count개까지 선택'; - } - - @override - String get select_genres => '장르 선택'; - - @override - String get add_genres => '장르 추가'; - - @override - String get country => '국가'; - - @override - String get number_of_tracks_generate => '생성할 곡 수'; - - @override - String get acousticness => '반주 구간 (Acousticness)'; - - @override - String get danceability => '흥겨운 정도 (Danceability)'; - - @override - String get energy => '에너지 (Energy)'; - - @override - String get instrumentalness => '기악성 (Instrumentalness)'; - - @override - String get liveness => '생동감 (Liveness)'; - - @override - String get loudness => '라우드니스 (Loudness)'; - - @override - String get speechiness => '회화성 (Speechniss)'; - - @override - String get valence => '감정가 (Valence)'; - - @override - String get popularity => '인기도 (Popularity)'; - - @override - String get key => '조성 (키)'; - - @override - String get duration => '길이 (초)'; - - @override - String get tempo => '템포 (BPM)'; - - @override - String get mode => '장조'; - - @override - String get time_signature => '박자'; - - @override - String get short => '짧음'; - - @override - String get medium => '중간'; - - @override - String get long => '긺'; - - @override - String get min => '최소'; - - @override - String get max => '최대'; - - @override - String get target => '목표'; - - @override - String get moderate => '보통'; - - @override - String get deselect_all => '모두 선택해제'; - - @override - String get select_all => '모두 선택'; - - @override - String get are_you_sure => '괜찮겠습니까?'; - - @override - String get generating_playlist => '커스텀 플레이리스트를 생성하는 중...'; - - @override - String selected_count_tracks(Object count) { - return '$count 곡이 선택되었습니다.'; - } - - @override - String get download_warning => - '모든 트랙을 대량으로 다운로드하는 것은 명백한 불법 복제이며 음악 창작 사회에 피해를 입히는 행위입니다. 이 점을 알아주셨으면 합니다. 항상 아티스트의 노력을 존중하고 응원해 주세요.'; - - @override - String get download_ip_ban_warning => - '참고로, 평소보다 과도한 다운로드 요청으로 인해 YouTube에서 IP가 차단될 수 있습니다. IP 차단은 해당 IP 기기에서 최소 2~3개월 동안 (로그인한 상태에서도) YouTube를 사용할 수 없음을 의미합니다. 그리고 이런 일이 발생하더라도 스포튜브는 어떠한 책임도 지지 않습니다.'; - - @override - String get by_clicking_accept_terms => '\'동의\'를 클릭하면 다음 약관에 동의하는 것입니다:'; - - @override - String get download_agreement_1 => '알고 있습니다. 전 나쁜 사람입니다.'; - - @override - String get download_agreement_2 => - '제가 할 수 있는 모든 곳에서 아티스트를 지원할 것이며, 저는 그들의 작품을 살 돈이 없기 때문에 이렇게 하는 것뿐입니다.'; - - @override - String get download_agreement_3 => - '본인은 YouTube에서 내 IP가 차단될 수 있음을 완전히 알고 있으며, 현재 내 행동으로 인해 발생하는 사고에 대해 Spotube 또는 그 소유자/기여자에게 책임을 묻지 않습니다.'; - - @override - String get decline => '거절'; - - @override - String get accept => '동의'; - - @override - String get details => '상세'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => '채널'; - - @override - String get likes => '좋아요'; - - @override - String get dislikes => '싫어요'; - - @override - String get views => '조회수'; - - @override - String get streamUrl => '스트림 URL'; - - @override - String get stop => '중지'; - - @override - String get sort_newest => '최근에 추가된 순으로 정렬'; - - @override - String get sort_oldest => '예전에 추가된 순으로 정렬'; - - @override - String get sleep_timer => '취침 타이머'; - - @override - String mins(Object minutes) { - return '$minutes 분'; - } - - @override - String hours(Object hours) { - return '$hours 시간'; - } - - @override - String hour(Object hours) { - return '$hours 시간'; - } - - @override - String get custom_hours => '시간 설정'; - - @override - String get logs => '로그'; - - @override - String get developers => '개발'; - - @override - String get not_logged_in => '로그인하지 않았습니다'; - - @override - String get search_mode => '검색 모드'; - - @override - String get audio_source => '오디오 출처'; - - @override - String get ok => '알겠습니다'; - - @override - String get failed_to_encrypt => '암호화에 실패했습니다'; - - @override - String get encryption_failed_warning => - 'Spotube는 암호화를 사용하여 데이터를 안전하게 저장합니다. 하지만 그렇게 하지 못했습니다. 따라서 안전하지 않은 저장소로 대체됩니다.\n리눅스를 사용하는 경우, 비밀 서비스(gnome-keyring, kde-wallet, keepassxc 등)가 설치되어 있는지 확인하세요.'; - - @override - String get querying_info => '정보를 얻는 중...'; - - @override - String get piped_api_down => 'Piped API가 응답하지 않습니다'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'Piped 인스턴스 $pipedInstance가 현재 다운되었습니다.\n\n인스턴스를 변경하거나 \'API 유형\'을 공식 YouTube API로 변경하세요.\n\n변경 후 앱을 다시 시작해야 합니다.'; - } - - @override - String get you_are_offline => '현재 오프라인입니다'; - - @override - String get connection_restored => '인터넷에 다시 연결되었습니다'; - - @override - String get use_system_title_bar => '시스템 타이틀바를 사용'; - - @override - String get crunching_results => '결과를 처리하는 중...'; - - @override - String get search_to_get_results => '결과를 얻으려면 검색해주세요'; - - @override - String get use_amoled_mode => 'AMOLED모드를 사용'; - - @override - String get pitch_dark_theme => '검정색 기반의 어두운 테마'; - - @override - String get normalize_audio => '오디오 노멀라이즈'; - - @override - String get change_cover => '커버 변경'; - - @override - String get add_cover => '커버 추가'; - - @override - String get restore_defaults => '기본값으로 복원'; - - @override - String get download_music_format => '다운로드 음악 포맷'; - - @override - String get streaming_music_format => '스트리밍 음악 포맷'; - - @override - String get download_music_quality => '다운로드 음질'; - - @override - String get streaming_music_quality => '스트리밍 음질'; - - @override - String get login_with_lastfm => 'Last.fm에 로그인'; - - @override - String get connect => '연결'; - - @override - String get disconnect_lastfm => 'Last.fm에서 연결 해제'; - - @override - String get disconnect => '연결 해제'; - - @override - String get username => '사용자명'; - - @override - String get password => '비밀번호'; - - @override - String get login => '로그인'; - - @override - String get login_with_your_lastfm => '내 Last.fm 계정으로로그인'; - - @override - String get scrobble_to_lastfm => 'Scrobble to Last.fm'; - - @override - String get go_to_album => '앨범으로 이동'; - - @override - String get discord_rich_presence => 'Discord Rich Presence'; - - @override - String get browse_all => '모두 탐색'; - - @override - String get genres => '장르'; - - @override - String get explore_genres => '장르 탐색'; - - @override - String get friends => '친구'; - - @override - String get no_lyrics_available => '죄송하지만 이 곡의 가사를 찾지 못했습니다'; - - @override - String get start_a_radio => '라디오 시작'; - - @override - String get how_to_start_radio => '라디오를 어떻게 시작하시겠습니까?'; - - @override - String get replace_queue_question => '현재 큐를 대체하시겠습니까 아니면 추가하시겠습니까?'; - - @override - String get endless_playback => '끝없는 재생'; - - @override - String get delete_playlist => '재생 목록 삭제'; - - @override - String get delete_playlist_confirmation => '이 재생 목록을 삭제하시겠습니까?'; - - @override - String get local_tracks => '로컬 트랙'; - - @override - String get local_tab => '로컬'; - - @override - String get song_link => '곡 링크'; - - @override - String get skip_this_nonsense => '이 허튼소리 건너뛰기'; - - @override - String get freedom_of_music => '“음악의 자유”'; - - @override - String get freedom_of_music_palm => '“손바닥 안의 음악의 자유”'; - - @override - String get get_started => '시작합시다'; - - @override - String get youtube_source_description => '추천되며 가장 잘 작동합니다.'; - - @override - String get piped_source_description => - '자유로운 기분이 듭니까? YouTube와 같지만 훨씬 더 무료합니다.'; - - @override - String get jiosaavn_source_description => '남아시아 지역에 최적입니다.'; - - @override - String get invidious_source_description => 'Piped와 비슷하지만 가용성이 높습니다.'; - - @override - String highest_quality(Object quality) { - return '최고 품질: $quality'; - } - - @override - String get select_audio_source => '오디오 소스 선택'; - - @override - String get endless_playback_description => '자동으로 새로운 노래를 대기열의 끝에 추가'; - - @override - String get choose_your_region => '지역 선택'; - - @override - String get choose_your_region_description => - '이것은 Spotube가 위치에 맞는 콘텐츠를 표시하는 데 도움이 됩니다.'; - - @override - String get choose_your_language => '언어 선택'; - - @override - String get help_project_grow => '이 프로젝트 성장에 도움을 주세요'; - - @override - String get help_project_grow_description => - 'Spotube는 오픈 소스 프로젝트입니다. 프로젝트에 기여하거나 버그를 보고하거나 새로운 기능을 제안하여이 프로젝트의 성장에 도움을 줄 수 있습니다.'; - - @override - String get contribute_on_github => 'GitHub에서 기여하기'; - - @override - String get donate_on_open_collective => 'Open Collective에 기부하기'; - - @override - String get browse_anonymously => '익명으로 둘러보기'; - - @override - String get enable_connect => '연결 활성화'; - - @override - String get enable_connect_description => '다른 장치에서 Spotube 제어'; - - @override - String get devices => '장치'; - - @override - String get select => '선택'; - - @override - String connect_client_alert(Object client) { - return '$client님에 의해 제어되고 있습니다'; - } - - @override - String get this_device => '이 장치'; - - @override - String get remote => '원격'; - - @override - String get stats => '통계'; - - @override - String and_n_more(Object count) { - return '그리고 $count개 더'; - } - - @override - String get recently_played => '최근 재생'; - - @override - String get browse_more => '더 보기'; - - @override - String get no_title => '제목 없음'; - - @override - String get not_playing => '재생 중이 아님'; - - @override - String get epic_failure => '서사적 실패!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return '$tracks_length 곡을 대기열에 추가했습니다'; - } - - @override - String get spotube_has_an_update => 'Spotube에 업데이트가 있습니다'; - - @override - String get download_now => '지금 다운로드'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum이 출시되었습니다'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version이 출시되었습니다'; - } - - @override - String get read_the_latest => '최신 '; - - @override - String get release_notes => '릴리스 노트'; - - @override - String get pick_color_scheme => '색상 테마 선택'; - - @override - String get save => '저장'; - - @override - String get choose_the_device => '디바이스 선택:'; - - @override - String get multiple_device_connected => - '여러 디바이스가 연결되어 있습니다.\n이 작업을 실행할 디바이스를 선택하세요'; - - @override - String get nothing_found => '찾을 수 없음'; - - @override - String get the_box_is_empty => '상자가 비어 있습니다'; - - @override - String get top_artists => '톱 아티스트'; - - @override - String get top_albums => '톱 앨범'; - - @override - String get this_week => '이번 주'; - - @override - String get this_month => '이번 달'; - - @override - String get last_6_months => '지난 6개월'; - - @override - String get this_year => '올해'; - - @override - String get last_2_years => '지난 2년'; - - @override - String get all_time => '모든 시간'; - - @override - String powered_by_provider(Object providerName) { - return '$providerName 제공'; - } - - @override - String get email => '이메일'; - - @override - String get profile_followers => '팔로워'; - - @override - String get birthday => '생일'; - - @override - String get subscription => '구독'; - - @override - String get not_born => '태어나지 않음'; - - @override - String get hacker => '해커'; - - @override - String get profile => '프로필'; - - @override - String get no_name => '이름 없음'; - - @override - String get edit => '편집'; - - @override - String get user_profile => '사용자 프로필'; - - @override - String count_plays(Object count) { - return '$count 재생'; - } - - @override - String get streaming_fees_hypothetical => - '*이것은 Spotify의 스트림당 지급액\n\$0.003에서 \$0.005를 기준으로 계산된 것입니다.\n이것은 사용자가 Spotify에서 곡을 들었을 때\n아티스트에게 지불했을 금액에 대한 통찰을 제공하기 위한\n가상의 계산입니다.'; - - @override - String get minutes_listened => '청취한 시간'; - - @override - String get streamed_songs => '스트리밍된 곡'; - - @override - String count_streams(Object count) { - return '$count 스트림'; - } - - @override - String get owned_by_you => '당신이 소유'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl를 클립보드에 복사했습니다'; - } - - @override - String get hipotetical_calculation => - '*이것은 온라인 음악 스트리밍 플랫폼의 스트림당 평균 지불액인 \$0.003에서 \$0.005를 기준으로 계산됩니다. 이것은 사용자가 다른 음악 스트리밍 플랫폼에서 노래를 들었다면 아티스트에게 얼마를 지불했을지에 대한 통찰력을 제공하기 위한 가상 계산입니다.'; - - @override - String count_mins(Object minutes) { - return '$minutes 분'; - } - - @override - String get summary_minutes => '분'; - - @override - String get summary_listened_to_music => '듣는 음악'; - - @override - String get summary_songs => '곡'; - - @override - String get summary_streamed_overall => '전체 스트리밍'; - - @override - String get summary_owed_to_artists => '이번 달 아티스트에게 지급해야 할 금액'; - - @override - String get summary_artists => '아티스트의'; - - @override - String get summary_music_reached_you => '음악이 도달함'; - - @override - String get summary_full_albums => '전체 앨범'; - - @override - String get summary_got_your_love => '당신의 사랑을 받음'; - - @override - String get summary_playlists => '플레이리스트'; - - @override - String get summary_were_on_repeat => '반복 재생됨'; - - @override - String total_money(Object money) { - return '총 $money'; - } - - @override - String get webview_not_found => '웹뷰를 찾을 수 없음'; - - @override - String get webview_not_found_description => - '기기에 웹뷰 런타임이 설치되지 않았습니다.\n설치되어 있으면 environment PATH에 있는지 확인하십시오\n\n설치 후 앱을 다시 시작하세요'; - - @override - String get unsupported_platform => '지원되지 않는 플랫폼'; - - @override - String get cache_music => '음악 캐시'; - - @override - String get open => '열기'; - - @override - String get cache_folder => '캐시 폴더'; - - @override - String get export => '내보내기'; - - @override - String get clear_cache => '캐시 지우기'; - - @override - String get clear_cache_confirmation => '캐시를 지우시겠습니까?'; - - @override - String get export_cache_files => '캐시된 파일 내보내기'; - - @override - String found_n_files(Object count) { - return '$count개의 파일을 찾았습니다'; - } - - @override - String get export_cache_confirmation => '이 파일들을 내보내시겠습니까'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return '$files개 중 $filesExported개 파일을 내보냈습니다'; - } - - @override - String get undo => '실행 취소'; - - @override - String get download_all => '모두 다운로드'; - - @override - String get add_all_to_playlist => '모두 재생 목록에 추가'; - - @override - String get add_all_to_queue => '모두 큐에 추가'; - - @override - String get play_all_next => '모두 다음에 재생'; - - @override - String get pause => '일시 정지'; - - @override - String get view_all => '모두 보기'; - - @override - String get no_tracks_added_yet => '아직 트랙을 추가하지 않은 것 같습니다'; - - @override - String get no_tracks => '여기에 트랙이 없는 것 같습니다'; - - @override - String get no_tracks_listened_yet => '아직 아무 것도 듣지 않은 것 같습니다'; - - @override - String get not_following_artists => '아티스트를 팔로우하지 않고 있습니다'; - - @override - String get no_favorite_albums_yet => '아직 즐겨찾기 앨범을 추가하지 않은 것 같습니다'; - - @override - String get no_logs_found => '로그를 찾을 수 없습니다'; - - @override - String get youtube_engine => 'YouTube 엔진'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine가 설치되지 않았습니다'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine가 시스템에 설치되지 않았습니다.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'PATH 변수에서 사용할 수 있는지 확인하거나\n아래에 $engine 실행 파일의 절대 경로를 설정하세요'; - } - - @override - String get youtube_engine_unix_issue_message => - 'macOS/Linux/unix와 같은 운영 체제에서는 .zshrc/.bashrc/.bash_profile 등에 경로 설정이 작동하지 않습니다.\n셸 구성 파일에 경로를 설정해야 합니다'; - - @override - String get download => '다운로드'; - - @override - String get file_not_found => '파일을 찾을 수 없습니다'; - - @override - String get custom => '사용자 정의'; - - @override - String get add_custom_url => '사용자 정의 URL 추가'; - - @override - String get edit_port => '포트 편집'; - - @override - String get port_helper_msg => - '기본값은 -1로 무작위 숫자를 나타냅니다. 방화벽이 구성된 경우 이를 설정하는 것이 좋습니다.'; - - @override - String connect_request(Object client) { - return '$client의 연결을 허용하시겠습니까?'; - } - - @override - String get connection_request_denied => '연결이 거부되었습니다. 사용자가 액세스를 거부했습니다.'; - - @override - String get an_error_occurred => '오류가 발생했습니다'; - - @override - String get copy_to_clipboard => '클립보드에 복사'; - - @override - String get view_logs => '로그 보기'; - - @override - String get retry => '다시 시도'; - - @override - String get no_default_metadata_provider_selected => - '기본 메타데이터 제공자가 설정되지 않았습니다'; - - @override - String get manage_metadata_providers => '메타데이터 제공자 관리'; - - @override - String get open_link_in_browser => '브라우저에서 링크를 여시겠습니까?'; - - @override - String get do_you_want_to_open_the_following_link => '다음 링크를 여시겠습니까'; - - @override - String get unsafe_url_warning => - '신뢰할 수 없는 출처의 링크를 여는 것은 안전하지 않을 수 있습니다. 주의하세요!\n링크를 클립보드에 복사할 수도 있습니다.'; - - @override - String get copy_link => '링크 복사'; - - @override - String get building_your_timeline => '청취 기록을 기반으로 타임라인을 구축하고 있습니다...'; - - @override - String get official => '공식'; - - @override - String author_name(Object author) { - return '저자: $author'; - } - - @override - String get third_party => '타사'; - - @override - String get plugin_requires_authentication => '플러그인에 인증이 필요합니다'; - - @override - String get update_available => '업데이트 사용 가능'; - - @override - String get supports_scrobbling => '스크로블링 지원'; - - @override - String get plugin_scrobbling_info => '이 플러그인은 음악을 스크로블하여 청취 기록을 생성합니다.'; - - @override - String get default_metadata_source => '기본 메타데이터 소스'; - - @override - String get set_default_metadata_source => '기본 메타데이터 소스 설정'; - - @override - String get default_audio_source => '기본 오디오 소스'; - - @override - String get set_default_audio_source => '기본 오디오 소스 설정'; - - @override - String get set_default => '기본값으로 설정'; - - @override - String get support => '지원'; - - @override - String get support_plugin_development => '플러그인 개발 지원'; - - @override - String can_access_name_api(Object name) { - return '- **$name** API에 액세스할 수 있습니다'; - } - - @override - String get do_you_want_to_install_this_plugin => '이 플러그인을 설치하시겠습니까?'; - - @override - String get third_party_plugin_warning => - '이 플러그인은 타사 리포지토리에서 제공됩니다. 설치하기 전에 출처를 신뢰하는지 확인하세요.'; - - @override - String get author => '저자'; - - @override - String get this_plugin_can_do_following => '이 플러그인은 다음을 수행할 수 있습니다'; - - @override - String get install => '설치'; - - @override - String get install_a_metadata_provider => '메타데이터 제공자 설치'; - - @override - String get no_tracks_playing => '현재 재생 중인 트랙이 없습니다'; - - @override - String get synced_lyrics_not_available => '이 노래에 대한 동기화된 가사를 사용할 수 없습니다. 대신'; - - @override - String get plain_lyrics => '일반 가사'; - - @override - String get tab_instead => '탭을 사용하세요.'; - - @override - String get disclaimer => '면책 조항'; - - @override - String get third_party_plugin_dmca_notice => - 'Spotube 팀은 어떠한 \"타사\" 플러그인에 대해서도 (법적 포함) 어떠한 책임도 지지 않습니다.\n사용자 자신의 책임하에 사용하시기 바랍니다. 버그/문제에 대해서는 플러그인 리포지토리에 보고해 주세요.\n\n만약 \"타사\" 플러그인이 서비스/법인의 ToS/DMCA를 위반하는 경우, \"타사\" 플러그인 저자 또는 호스팅 플랫폼(예: GitHub/Codeberg)에 조치를 취하도록 요청해 주세요. 위에 나열된 (\"타사\"로 표시된) 플러그인은 모두 공개/커뮤니티에서 유지 관리하는 플러그인입니다. 저희는 이를 큐레이션하지 않으므로 어떠한 조치도 취할 수 없습니다.\n\n'; - - @override - String get input_does_not_match_format => '입력이 필요한 형식과 일치하지 않습니다'; - - @override - String get plugins => '플러그인'; - - @override - String get paste_plugin_download_url => - '다운로드 URL, GitHub/Codeberg 리포지토리 URL 또는 .smplug 파일에 대한 직접 링크를 붙여넣으세요'; - - @override - String get download_and_install_plugin_from_url => 'URL에서 플러그인 다운로드 및 설치'; - - @override - String failed_to_add_plugin_error(Object error) { - return '플러그인 추가 실패: $error'; - } - - @override - String get upload_plugin_from_file => '파일에서 플러그인 업로드'; - - @override - String get installed => '설치됨'; - - @override - String get available_plugins => '사용 가능한 플러그인'; - - @override - String get configure_plugins => '직접 메타데이터 제공자와 오디오 소스 플러그인을 구성하세요'; - - @override - String get audio_scrobblers => '오디오 스크로블러'; - - @override - String get scrobbling => '스크로블링'; - - @override - String get source => '출처: '; - - @override - String get uncompressed => '비압축'; - - @override - String get dab_music_source_description => - '오디오파일을 위한 소스입니다. 고음질/무손실 오디오 스트림을 제공하며 ISRC 기반으로 정확한 트랙 매칭을 지원합니다.'; -} diff --git a/lib/l10n/generated/app_localizations_ne.dart b/lib/l10n/generated/app_localizations_ne.dart deleted file mode 100644 index 8f881b51..00000000 --- a/lib/l10n/generated/app_localizations_ne.dart +++ /dev/null @@ -1,1578 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Nepali (`ne`). -class AppLocalizationsNe extends AppLocalizations { - AppLocalizationsNe([String locale = 'ne']) : super(locale); - - @override - String get guest => 'अतिथि'; - - @override - String get browse => 'ब्राउज़ गर्नुहोस्'; - - @override - String get search => 'खोजी गर्नुहोस्'; - - @override - String get library => 'पुस्तकालय'; - - @override - String get lyrics => 'गीतको शब्द'; - - @override - String get settings => 'सेटिङ'; - - @override - String get genre_categories_filter => 'शैली वा शैलीहरू फिल्टर गर्नुहोस्...'; - - @override - String get genre => 'शैली'; - - @override - String get personalized => 'व्यक्तिगत'; - - @override - String get featured => 'विशेष'; - - @override - String get new_releases => 'नयाँ रिलिज'; - - @override - String get songs => 'गीतहरू'; - - @override - String playing_track(Object track) { - return '$track बज्यो'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'यो हालको कतारलाई हटाउँछ। $track_length ट्र्याकहरू हटाईन्छ\nके तपाईं जारी राख्न चाहनुहुन्छ?'; - } - - @override - String get load_more => 'थप लोड गर्नुहोस्'; - - @override - String get playlists => 'प्लेलिस्टहरू'; - - @override - String get artists => 'कलाकारहरू'; - - @override - String get albums => 'आल्बमहरू'; - - @override - String get tracks => 'ट्र्याकहरू'; - - @override - String get downloads => 'डाउनलोडहरू'; - - @override - String get filter_playlists => 'तपाईंको प्लेलिस्टहरू फिल्टर गर्नुहोस्...'; - - @override - String get liked_tracks => 'मन परेका ट्र्याकहरू'; - - @override - String get liked_tracks_description => 'तपाईंको मन परेका सबै ट्र्याकहरू'; - - @override - String get playlist => 'प्लेलिस्ट'; - - @override - String get create_a_playlist => 'प्लेलिस्ट बनाउनुहोस्'; - - @override - String get update_playlist => 'प्लेलिस्ट अपडेट गर्नुहोस्'; - - @override - String get create => 'बनाउनुहोस्'; - - @override - String get cancel => 'रद्द गर्नुहोस्'; - - @override - String get update => 'अपडेट गर्नुहोस्'; - - @override - String get playlist_name => 'प्लेलिस्टको नाम'; - - @override - String get name_of_playlist => 'प्लेलिस्टको नाम'; - - @override - String get description => 'विवरण'; - - @override - String get public => 'सार्वजनिक'; - - @override - String get collaborative => 'सहकारी'; - - @override - String get search_local_tracks => 'स्थानीय ट्र्याकहरू खोजी गर्नुहोस्...'; - - @override - String get play => 'बजाउनुहोस्'; - - @override - String get delete => 'मेटाउनुहोस्'; - - @override - String get none => 'कुनै पनि होइन'; - - @override - String get sort_a_z => 'A-Zमा क्रमबद्ध गर्नुहोस्'; - - @override - String get sort_z_a => 'Z-Aमा क्रमबद्ध गर्नुहोस्'; - - @override - String get sort_artist => 'कलाकारबाट क्रमबद्ध गर्नुहोस्'; - - @override - String get sort_album => 'आल्बमबाट क्रमबद्ध गर्नुहोस्'; - - @override - String get sort_duration => 'अवधिको अनुसार क्रमबद्ध गर्नुहोस्'; - - @override - String get sort_tracks => 'ट्र्याकहरूलाई क्रमबद्ध गर्नुहोस्'; - - @override - String currently_downloading(Object tracks_length) { - return 'हाल डाउनलोड गर्दैछ ($tracks_length)'; - } - - @override - String get cancel_all => 'सब रद्द गर्नुहोस्'; - - @override - String get filter_artist => 'कलाकारहरूलाई फिल्टर गर्नुहोस्...'; - - @override - String followers(Object followers) { - return '$followers अनुयायीहरू'; - } - - @override - String get add_artist_to_blacklist => 'कलाकारलाई कालोसूचीमा थप्नुहोस्'; - - @override - String get top_tracks => 'शीर्ष ट्र्याकहरू'; - - @override - String get fans_also_like => 'अनुयायीहरू पनि लाइक गर्छन्'; - - @override - String get loading => 'लोड हुँदैछ...'; - - @override - String get artist => 'कलाकार'; - - @override - String get blacklisted => 'कालोसूचीमा'; - - @override - String get following => 'फल्लो गर्दै'; - - @override - String get follow => 'फल्लो गर्नुहोस्'; - - @override - String get artist_url_copied => 'कलाकार URL क्लिपबोर्डमा प्रतिलिपि गरिएको छ'; - - @override - String added_to_queue(Object tracks) { - return '$tracks ट्र्याकहरूलाई कतारमा थपिएको छ'; - } - - @override - String get filter_albums => 'आल्बमहरूलाई फिल्टर गर्नुहोस्...'; - - @override - String get synced => 'सिङ्क गरिएको'; - - @override - String get plain => 'साधा'; - - @override - String get shuffle => 'शफल'; - - @override - String get search_tracks => 'ट्र्याकहरू खोजी गर्नुहोस्...'; - - @override - String get released => 'रिलिज गरिएको'; - - @override - String error(Object error) { - return 'त्रुटि $error'; - } - - @override - String get title => 'शीर्षक'; - - @override - String get time => 'समय'; - - @override - String get more_actions => 'थप कार्यहरू'; - - @override - String download_count(Object count) { - return 'डाउनलोड ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'प्लेलिस्टमा थप्नुहोस् ($count)'; - } - - @override - String add_count_to_queue(Object count) { - return 'कतारमा थप्नुहोस् ($count)'; - } - - @override - String play_count_next(Object count) { - return 'प्लेगरी गर्नुहोस् ($count)'; - } - - @override - String get album => 'आल्बम'; - - @override - String copied_to_clipboard(Object data) { - return '$data क्लिपबोर्डमा प्रतिलिपि गरिएको छ'; - } - - @override - String add_to_following_playlists(Object track) { - return '$track लाई तलका प्लेलिस्टमा थप्नुहोस्'; - } - - @override - String get add => 'थप्नुहोस्'; - - @override - String added_track_to_queue(Object track) { - return '$track लाई कतारमा थपिएको छ'; - } - - @override - String get add_to_queue => 'कतारमा थप्नुहोस्'; - - @override - String track_will_play_next(Object track) { - return '$track अरूलाई पहिलोमा बज्नेछ'; - } - - @override - String get play_next => 'पछिबजाउनुहोस्'; - - @override - String removed_track_from_queue(Object track) { - return '$track लाई कतारबाट हटाइएको छ'; - } - - @override - String get remove_from_queue => 'कतारबाट हटाउनुहोस्'; - - @override - String get remove_from_favorites => 'पसन्दीदामा बाट हटाउनुहोस्'; - - @override - String get save_as_favorite => 'पसन्दीदा बनाउनुहोस्'; - - @override - String get add_to_playlist => 'प्लेलिस्टमा थप्नुहोस्'; - - @override - String get remove_from_playlist => 'प्लेलिस्टबाट हटाउनुहोस्'; - - @override - String get add_to_blacklist => 'कालोसूचीमा थप्नुहोस्'; - - @override - String get remove_from_blacklist => 'कालोसूचीबाट हटाउनुहोस्'; - - @override - String get share => 'साझा गर्नुहोस्'; - - @override - String get mini_player => 'मिनि प्लेयर'; - - @override - String get slide_to_seek => - 'अगाडि वा पछाडि खोजी गर्नका लागि स्लाइड गर्नुहोस्'; - - @override - String get shuffle_playlist => 'प्लेलिस्ट शफल गर्नुहोस्'; - - @override - String get unshuffle_playlist => 'प्लेलिस्ट शफल नगर्नुहोस्'; - - @override - String get previous_track => 'पूर्व ट्र्याक'; - - @override - String get next_track => 'अरू ट्र्याक'; - - @override - String get pause_playback => 'प्लेब्याक रोक्नुहोस्'; - - @override - String get resume_playback => 'प्लेब्याक पुनः सुरु गर्नुहोस्'; - - @override - String get loop_track => 'ट्र्याकलाई दोहोरोपट्टी बजाउनुहोस्'; - - @override - String get no_loop => 'कोई लूप नहीं'; - - @override - String get repeat_playlist => 'प्लेलिस्ट पुनः बजाउनुहोस्'; - - @override - String get queue => 'कतार'; - - @override - String get alternative_track_sources => 'वैकल्पिक ट्र्याक स्रोतहरू'; - - @override - String get download_track => 'ट्र्याक डाउनलोड गर्नुहोस्'; - - @override - String tracks_in_queue(Object tracks) { - return 'कतारमा $tracks ट्र्याकहरू'; - } - - @override - String get clear_all => 'सब मेटाउनुहोस्'; - - @override - String get show_hide_ui_on_hover => 'हवर गरेपछि UI देखाउनुहोस्/लुकाउनुहोस्'; - - @override - String get always_on_top => 'सधैं टपमा राख्नुहोस्'; - - @override - String get exit_mini_player => 'मिनि प्लेयर बाट बाहिर निस्कनुहोस्'; - - @override - String get download_location => 'डाउनलोड स्थान'; - - @override - String get local_library => 'स्थानिय पुस्तकालय'; - - @override - String get add_library_location => 'पुस्तकालयमा थप्नुहोस्'; - - @override - String get remove_library_location => 'पुस्तकालयबाट हटाउनुहोस्'; - - @override - String get account => 'खाता'; - - @override - String get logout => 'बाहिर निस्कनुहोस्'; - - @override - String get logout_of_this_account => 'यो खाताबाट बाहिर निस्कनुहोस्'; - - @override - String get language_region => 'भाषा र क्षेत्र'; - - @override - String get language => 'भाषा'; - - @override - String get system_default => 'सिस्टम पूर्वनिर्धारित'; - - @override - String get market_place_region => 'बजार स्थान'; - - @override - String get recommendation_country => 'सिफारिस गरिएको देश'; - - @override - String get appearance => 'दृष्टिकोण'; - - @override - String get layout_mode => 'लेआउट मोड'; - - @override - String get override_layout_settings => - 'अनुकूलित प्रतिकृयात्मक लेआउट मोड सेटिङ्गहरू'; - - @override - String get adaptive => 'अनुकूलित'; - - @override - String get compact => 'संकुचित'; - - @override - String get extended => 'बढाइएको'; - - @override - String get theme => 'थिम'; - - @override - String get dark => 'गाढा'; - - @override - String get light => 'प्रकाश'; - - @override - String get system => 'सिस्टम'; - - @override - String get accent_color => 'एक्सेन्ट रङ्ग'; - - @override - String get sync_album_color => 'एल्बम रङ्ग सिङ्क गर्नुहोस्'; - - @override - String get sync_album_color_description => - 'एल्बम कला को प्रमुख रङ्गलाई एक्सेन्ट रङ्गको रूपमा प्रयोग गर्दछ'; - - @override - String get playback => 'प्लेब्याक'; - - @override - String get audio_quality => 'आडियो गुणस्तर'; - - @override - String get high => 'उच्च'; - - @override - String get low => 'न्यून'; - - @override - String get pre_download_play => 'पूर्व-डाउनलोड र प्ले गर्नुहोस्'; - - @override - String get pre_download_play_description => - 'आडियो स्ट्रिम गर्नु नगरी बाइटहरू डाउनलोड गरी बजाउँछ (उच्च ब्यान्डविथ उपयोगकर्ताहरूको लागि सिफारिस गरिएको)'; - - @override - String get skip_non_music => - 'गीतहरू बाहेक कुनै अनुष्ठान छोड्नुहोस् (स्पन्सरब्लक)'; - - @override - String get blacklist_description => 'कालोसूची गीत र कलाकारहरू'; - - @override - String get wait_for_download_to_finish => - 'कृपया हालको डाउनलोड समाप्त हुन लागि पर्खनुहोस्'; - - @override - String get desktop => 'डेस्कटप'; - - @override - String get close_behavior => 'बन्द व्यवहार'; - - @override - String get close => 'बन्द गर्नुहोस्'; - - @override - String get minimize_to_tray => 'ट्रेमा कम गर्नुहोस्'; - - @override - String get show_tray_icon => 'सिस्टम ट्रे आइकन देखाउनुहोस्'; - - @override - String get about => 'बारेमा'; - - @override - String get u_love_spotube => - 'हामीले थाहा पारेका छौं तपाईंलाई Spotube मन पर्छ'; - - @override - String get check_for_updates => 'अपडेटहरूको लागि जाँच गर्नुहोस्'; - - @override - String get about_spotube => 'Spotube को बारेमा'; - - @override - String get blacklist => 'कालोसूची'; - - @override - String get please_sponsor => 'कृपया स्पन्सर/डोनेट गर्नुहोस्'; - - @override - String get spotube_description => - 'Spotube, एक हल्का, समृद्ध, स्वतन्त्र Spotify क्लाइयन'; - - @override - String get version => 'संस्करण'; - - @override - String get build_number => 'निर्माण नम्बर'; - - @override - String get founder => 'संस्थापक'; - - @override - String get repository => 'पुनरावलोकन स्थल'; - - @override - String get bug_issues => 'त्रुटि + समस्याहरू'; - - @override - String get made_with => '❤️ 2021-2024 बाट बनाइएको'; - - @override - String get kingkor_roy_tirtho => 'किङ्कोर राय तिर्थो'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year किङ्कोर राय तिर्थो'; - } - - @override - String get license => 'लाइसेन्स'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'चिन्ता नगर्नुहोस्, तपाईंको कुनै पनि क्रेडेन्शियलहरूले कसैले संग्रह वा साझा गर्नेछैन'; - - @override - String get know_how_to_login => 'कसरी लगिन गर्ने भन्ने थाहा छैन?'; - - @override - String get follow_step_by_step_guide => - 'चरणबद्ध मार्गदर्शनमा साथी बनाउनुहोस्'; - - @override - String cookie_name_cookie(Object name) { - return '$name कुकी'; - } - - @override - String get fill_in_all_fields => 'कृपया सबै क्षेत्रहरू भर्नुहोस्'; - - @override - String get submit => 'पेश गर्नुहोस्'; - - @override - String get exit => 'बाहिर निस्कनुहोस्'; - - @override - String get previous => 'पूर्ववत'; - - @override - String get next => 'अरू'; - - @override - String get done => 'गरिएको'; - - @override - String get step_1 => 'कदम 1'; - - @override - String get first_go_to => 'पहिलो, जानुहोस्'; - - @override - String get something_went_wrong => 'केहि गल्ति भएको छ'; - - @override - String get piped_instance => 'पाइपड सर्भर इन्स्ट्यान्स'; - - @override - String get piped_description => - 'गीत मिलाउको लागि प्रयोग गर्ने पाइपड सर्भर इन्स्ट्यान्स'; - - @override - String get piped_warning => - 'तिनीहरूमध्ये केहि ठिक गर्न सक्छ। यसलाई आफ्नो जोखिममा प्रयोग गर्नुहोस्'; - - @override - String get invidious_instance => 'Invidious सर्भर इन्स्टेन्स'; - - @override - String get invidious_description => - 'ट्र्याक मिलाउनका लागि प्रयोग हुने Invidious सर्भर इन्स्टेन्स'; - - @override - String get invidious_warning => - 'केहीले राम्रोसँग काम नगर्न सक्छ। आफ्नो जोखिममा प्रयोग गर्नुहोस्'; - - @override - String get generate => 'जनरेट'; - - @override - String track_exists(Object track) { - return 'ट्र्याक $track पहिले नै छ'; - } - - @override - String get replace_downloaded_tracks => - 'सबै डाउनलोड गरिएका ट्र्याकहरूलाई परिवर्तन गर्नुहोस्'; - - @override - String get skip_download_tracks => - 'सबै डाउनलोड गरिएका ट्र्याकहरूलाई छोड्नुहोस्'; - - @override - String get do_you_want_to_replace => - 'के तपाईंले वर्तमान ट्र्याकलाई परिवर्तन गर्न चाहनुहुन्छ?'; - - @override - String get replace => 'परिवर्तन गर्नुहोस्'; - - @override - String get skip => 'छोड्नुहोस्'; - - @override - String select_up_to_count_type(Object count, Object type) { - return '$count $type सम्म चयन गर्नुहोस्'; - } - - @override - String get select_genres => 'जनरहरू चयन गर्नुहोस्'; - - @override - String get add_genres => 'जनरहरू थप्नुहोस्'; - - @override - String get country => 'देश'; - - @override - String get number_of_tracks_generate => 'बनाउनका लागि ट्र्याकहरूको संख्या'; - - @override - String get acousticness => 'एकोस्टिकनेस'; - - @override - String get danceability => 'नृत्यक्षमता'; - - @override - String get energy => 'ऊर्जा'; - - @override - String get instrumentalness => 'साजा रहेकोता'; - - @override - String get liveness => 'प्राणिकता'; - - @override - String get loudness => 'शोर'; - - @override - String get speechiness => 'भाषण'; - - @override - String get valence => 'मानसिक स्वभाव'; - - @override - String get popularity => 'लोकप्रियता'; - - @override - String get key => 'कुञ्जी'; - - @override - String get duration => 'अवधि (सेकेण्ड)'; - - @override - String get tempo => 'गति (बीपीएम)'; - - @override - String get mode => 'मोड'; - - @override - String get time_signature => 'समय हस्ताक्षर'; - - @override - String get short => 'सानो'; - - @override - String get medium => 'मध्यम'; - - @override - String get long => 'लामो'; - - @override - String get min => 'न्यून'; - - @override - String get max => 'अधिक'; - - @override - String get target => 'लक्ष्य'; - - @override - String get moderate => 'मध्यस्थ'; - - @override - String get deselect_all => 'सबै छान्नुहोस्'; - - @override - String get select_all => 'सबै चयन गर्नुहोस्'; - - @override - String get are_you_sure => 'के तपाईं सुनिश्चित हुनुहुन्छ?'; - - @override - String get generating_playlist => 'तपाईंको विशेष प्लेलिस्ट बनाइएको छ...'; - - @override - String selected_count_tracks(Object count) { - return '$count ट्र्याकहरू छन् चयन गरिएका'; - } - - @override - String get download_warning => - 'यदि तपाईं सबै ट्र्याकहरूलाई बल्कमा डाउनलोड गर्छनु हो भने तपाईं स्पष्ट रूपमा साङ्गीत चोरी गरिरहेका छन् र यो साङ्गीतको रचनात्मक समाजलाई क्षति पनि पुर्याउँछ। उमेराइएको छ कि तपाईं यसको बारेमा जागरूक छिनुहुन्छ। सधैं, कला गर्दै र कलाकारको कडा परम्परा समर्थन गर्दै आइन्छ।'; - - @override - String get download_ip_ban_warning => - 'बितिएका डाउनलोड अनुरोधहरूका कारण तपाईंको आइपीले YouTube मा ब्लक हुन सक्छ। आइपी ब्लक भनेको कम्तीमा 2-3 महिनासम्म तपाईं त्यस आइपी यन्त्रबाट YouTube प्रयोग गर्न सक्नुहुन्छ। र यदि यो हुँदैछ भने स्पट्यूबले यसलाई कसैले गरेको बारेमा कुनै दायित्व लिन्छैन।'; - - @override - String get by_clicking_accept_terms => - '\'स्वीकृत\' गरेर तपाईं निम्नलिखित निर्वाचन गर्दैछिन्:'; - - @override - String get download_agreement_1 => - 'म मन्ने छु कि म साङ्गीत चोरी गरिरहेको छु। म बुरो हुँ'; - - @override - String get download_agreement_2 => - 'म कहिल्यै कहिल्यै तिनीहरूलाई समर्थन गर्नेछु र म यो तिनीहरूको कला किन्ने पैसा छैन भने मा मात्र यो गरेको छु'; - - @override - String get download_agreement_3 => - 'म पूरा रूपमा जान्छु कि मेरो आइपी YouTube मा ब्लक हुन सक्छ र म मन्छेहरूले मेरो चासोबाट भएको कुनै दुर्घटनामा स्पट्यूब वा तिनीहरूको मालिकहरू/सहयोगीहरूलाई दायित्वी ठान्छुँभन्ने पूर्ण जानकारी छैन'; - - @override - String get decline => 'अस्वीकृत'; - - @override - String get accept => 'स्वीकृत'; - - @override - String get details => 'विवरण'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'च्यानल'; - - @override - String get likes => 'लाइकहरू'; - - @override - String get dislikes => 'असुनुहरू'; - - @override - String get views => 'हेरिएको'; - - @override - String get streamUrl => 'स्ट्रिम यूआरएल'; - - @override - String get stop => 'रोक्नुहोस्'; - - @override - String get sort_newest => 'नयाँ थपिएकोमा क्रमबद्ध गर्नुहोस्'; - - @override - String get sort_oldest => 'पुरानो थपिएकोमा क्रमबद्ध गर्नुहोस्'; - - @override - String get sleep_timer => 'सुत्ने टाइमर'; - - @override - String mins(Object minutes) { - return '$minutes मिनेटहरू'; - } - - @override - String hours(Object hours) { - return '$hours घण्टाहरू'; - } - - @override - String hour(Object hours) { - return '$hours घण्टा'; - } - - @override - String get custom_hours => 'कस्टम घण्टाहरू'; - - @override - String get logs => 'लगहरू'; - - @override - String get developers => 'डेभेलपर्स'; - - @override - String get not_logged_in => 'तपाईंले लगइन गरेका छैनौं'; - - @override - String get search_mode => 'खोज मोड'; - - @override - String get audio_source => 'अडियो स्रोत'; - - @override - String get ok => 'ठिक छ'; - - @override - String get failed_to_encrypt => 'एन्क्रिप्ट गर्न सकिएन'; - - @override - String get encryption_failed_warning => - 'स्पट्यूबले तपाईंको डेटा सुरक्षित रूपमा स्टोर गर्नका लागि एन्क्रिप्ट गर्न खोजेको छ। तर यसले गरेको छैन। यसले असुरक्षित स्टोरेजमा फल्लब्याक गर्दछ\nयदि तपाईंले लिनक्स प्रयोग गरिरहेका छन् भने कृपया सुनिश्चित गर्नुहोस् कि तपाईंले कुनै सीक्रेट-सर्भिस (गोनोम-किरिङ, केडीइ-वालेट, किपासेक्ससि इत्यादि) इन्स्टल गरेका छौं'; - - @override - String get querying_info => 'जानकारी हेर्दै...'; - - @override - String get piped_api_down => 'पाइपड एपीआई डाउन छ'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'पाइपड इन्स्ट्यान्स $pipedInstance हाल डाउन छ\n\nजीसनै इन्स्ट्यान्स परिवर्तन गर्नुहोस् वा \'एपीआई प्रकार\' लाइ YouTube आफिसियल एपीआईमा परिवर्तन गर्नुहोस्\n\nपरिवर्तनपछि एप्लिकेसन पुन: सुरु गर्नुहोस्'; - } - - @override - String get you_are_offline => 'तपाईं वर्तमान अफलाइन हुनुहुन्छ'; - - @override - String get connection_restored => - 'तपाईंको इन्टरनेट कनेक्सन पुन: स्थापित भएको छ'; - - @override - String get use_system_title_bar => 'सिस्टम शीर्षक पट्टी प्रयोग गर्नुहोस्'; - - @override - String get crunching_results => 'परिणामहरू कपालबाट पीस्दै...'; - - @override - String get search_to_get_results => - 'परिणामहरू प्राप्त गर्नका लागि खोज्नुहोस्'; - - @override - String get use_amoled_mode => 'कृष्ण ब्ल्याक गाढा थिम प्रयोग गर्नुहोस्'; - - @override - String get pitch_dark_theme => 'एमोलेड मोड'; - - @override - String get normalize_audio => 'अडियो सामान्य गर्नुहोस्'; - - @override - String get change_cover => 'कवर परिवर्तन गर्नुहोस्'; - - @override - String get add_cover => 'कवर थप्नुहोस्'; - - @override - String get restore_defaults => 'पूर्वनिर्धारितहरू पुनः स्थापित गर्नुहोस्'; - - @override - String get download_music_format => 'सङ्गीत डाउनलोड ढाँचा'; - - @override - String get streaming_music_format => 'स्ट्रिमिङ सङ्गीत ढाँचा'; - - @override - String get download_music_quality => 'डाउनलोड गुणस्तर'; - - @override - String get streaming_music_quality => 'स्ट्रिमिङ गुणस्तर'; - - @override - String get login_with_lastfm => 'लास्ट.एफ.एम सँग लगइन गर्नुहोस्'; - - @override - String get connect => 'जडान गर्नुहोस्'; - - @override - String get disconnect_lastfm => 'लास्ट.एफ.एम डिसकनेक्ट गर्नुहोस्'; - - @override - String get disconnect => 'डिसकनेक्ट'; - - @override - String get username => 'प्रयोगकर्ता नाम'; - - @override - String get password => 'पासवर्ड'; - - @override - String get login => 'लगइन'; - - @override - String get login_with_your_lastfm => - 'तपाईंको लास्ट.एफ.एम खातामा लगइन गर्नुहोस्'; - - @override - String get scrobble_to_lastfm => 'लास्ट.एफ.एम मा स्क्रबल गर्नुहोस्'; - - @override - String get go_to_album => 'आल्बममा जानुहोस्'; - - @override - String get discord_rich_presence => 'डिस्कर्ड धनी उपस्थिति'; - - @override - String get browse_all => 'सबै हेर्नुहोस्'; - - @override - String get genres => 'शैलीहरू'; - - @override - String get explore_genres => 'शैलीहरू अन्वेषण गर्नुहोस्'; - - @override - String get friends => 'साथीहरू'; - - @override - String get no_lyrics_available => - 'क्षमा गर्दैछौं, यस ट्र्याकका लागि गीतका शब्दहरू फेला परेन'; - - @override - String get start_a_radio => 'रेडियो सुरु गर्नुहोस्'; - - @override - String get how_to_start_radio => 'तपाईं रेडियो कसरी सुरु गर्न चाहानुहुन्छ?'; - - @override - String get replace_queue_question => - 'के तपाईं वर्तमान कताक्ष कोट बदल्न चाहानुहुन्छ वा यसलाई थप्नुहुन्छ?'; - - @override - String get endless_playback => 'अनन्त प्लेब्याक'; - - @override - String get delete_playlist => 'प्लेलिस्ट मेटाउनुहोस्'; - - @override - String get delete_playlist_confirmation => - 'के तपाईं यो प्लेलिस्ट मेटाउन निश्चित हुनुहुन्छ?'; - - @override - String get local_tracks => 'स्थानिय ट्र्याकहरू'; - - @override - String get local_tab => 'स्थानिय'; - - @override - String get song_link => 'गीत लिंक'; - - @override - String get skip_this_nonsense => 'यस अबश्यकता छोड्नुहोस्'; - - @override - String get freedom_of_music => '“संगीतको स्वतन्त्रता”'; - - @override - String get freedom_of_music_palm => '“तपाईंको हातमा संगीतको स्वतन्त्रता”'; - - @override - String get get_started => 'आइयाँ प्रारम्भ गरौं'; - - @override - String get youtube_source_description => 'सिफारिस गरिएको र बेस्ट काम गर्दछ।'; - - @override - String get piped_source_description => - 'मुक्त सुस्त? YouTube जस्तै तर धेरै मुक्त।'; - - @override - String get jiosaavn_source_description => - 'दक्षिण एशियाली क्षेत्रको लागि सर्वोत्तम।'; - - @override - String get invidious_source_description => 'Piped जस्तै तर उच्च उपलब्धतासँग।'; - - @override - String highest_quality(Object quality) { - return 'उच्चतम गुणस्तर: $quality'; - } - - @override - String get select_audio_source => 'आडियो स्रोत चयन गर्नुहोस्'; - - @override - String get endless_playback_description => - 'नयाँ गीतहरूलाई स्वचालित रूपमा कताक्षको अन्तमा जोड्नुहोस्'; - - @override - String get choose_your_region => 'तपाईंको क्षेत्र छनौट गर्नुहोस्'; - - @override - String get choose_your_region_description => - 'यो Spotubeलाई तपाईंको स्थानका लागि सहि सामग्री देखाउने मद्दत गर्नेछ।'; - - @override - String get choose_your_language => 'तपाईंको भाषा छनौट गर्नुहोस्'; - - @override - String get help_project_grow => 'यस परियोजनामा वृद्धि गराउनुहोस्'; - - @override - String get help_project_grow_description => - 'Spotube एक खुला स्रोतको परियोजना हो। तपाईं परियोजनामा योगदान गरेर, त्रुटिहरू सूचिकै, वा नयाँ सुविधाहरू सुझाव दिएर यस परियोजनामा वृद्धि गर्न सक्नुहुन्छ।'; - - @override - String get contribute_on_github => 'GitHubमा योगदान गर्नुहोस्'; - - @override - String get donate_on_open_collective => 'खुला संगठनमा दान गर्नुहोस्'; - - @override - String get browse_anonymously => 'अनामित रूपमा ब्राउज़ गर्नुहोस्'; - - @override - String get enable_connect => 'कनेक्ट सक्रिय गर्नुहोस्'; - - @override - String get enable_connect_description => - 'अन्य उपकरणहरूबाट Spotube कन्ट्रोल गर्नुहोस्'; - - @override - String get devices => 'उपकरणहरू'; - - @override - String get select => 'चयन गर्नुहोस्'; - - @override - String connect_client_alert(Object client) { - return 'तपाईंलाई $client द्वारा नियन्त्रित गरिएको छ'; - } - - @override - String get this_device => 'यो उपकरण'; - - @override - String get remote => 'दूरसंचार'; - - @override - String get stats => 'तथ्याङ्क'; - - @override - String and_n_more(Object count) { - return 'राम्रो $count थप'; - } - - @override - String get recently_played => 'हालै खेलेको'; - - @override - String get browse_more => 'थप हेर्नुहोस्'; - - @override - String get no_title => 'शीर्षक छैन'; - - @override - String get not_playing => 'खेलिरहेको छैन'; - - @override - String get epic_failure => 'महाकवि असफलता!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return '$tracks_length ट्र्याकहरू तालिकामा थपिएका छन्'; - } - - @override - String get spotube_has_an_update => 'Spotube मा अपडेट छ'; - - @override - String get download_now => 'अहिले डाउनलोड गर्नुहोस्'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum रिलिज गरिएको छ'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version रिलिज गरिएको छ'; - } - - @override - String get read_the_latest => 'अर्को '; - - @override - String get release_notes => 'रिलिज नोटहरू'; - - @override - String get pick_color_scheme => 'रंग योजना चयन गर्नुहोस्'; - - @override - String get save => 'सुरक्षित गर्नुहोस्'; - - @override - String get choose_the_device => 'उपकरण चयन गर्नुहोस्:'; - - @override - String get multiple_device_connected => - 'धेरै उपकरण जडान गरिएको छ।\nयो क्रियाकलाप गर्ने उपकरण चयन गर्नुहोस्'; - - @override - String get nothing_found => 'केही फेला परेन'; - - @override - String get the_box_is_empty => 'बक्स खाली छ'; - - @override - String get top_artists => 'शीर्ष कलाकारहरू'; - - @override - String get top_albums => 'शीर्ष एल्बमहरू'; - - @override - String get this_week => 'यो हप्ता'; - - @override - String get this_month => 'यो महिना'; - - @override - String get last_6_months => 'पछिल्लो ६ महिना'; - - @override - String get this_year => 'यो वर्ष'; - - @override - String get last_2_years => 'पछिल्लो २ वर्ष'; - - @override - String get all_time => 'सबै समय'; - - @override - String powered_by_provider(Object providerName) { - return '$providerName द्वारा शक्ति प्राप्त'; - } - - @override - String get email => 'ईमेल'; - - @override - String get profile_followers => 'अनुयायीहरू'; - - @override - String get birthday => 'जन्मदिन'; - - @override - String get subscription => 'सदस्यता'; - - @override - String get not_born => 'जन्मिएको छैन'; - - @override - String get hacker => 'ह्याकर'; - - @override - String get profile => 'प्रोफाइल'; - - @override - String get no_name => 'नाम छैन'; - - @override - String get edit => 'सम्पादन गर्नुहोस्'; - - @override - String get user_profile => 'प्रयोगकर्ता प्रोफाइल'; - - @override - String count_plays(Object count) { - return '$count खेलाइन्छ'; - } - - @override - String get streaming_fees_hypothetical => - '*यो Spotify को प्रति स्ट्रिमको आधारमा गणना गरिएको छ\n\$0.003 देखि \$0.005 बीचको भुक्तानी। यो एक काल्पनिक गणना हो\nउपयोगकर्तालाई यो थाहा दिनको लागि कि उनीहरूले अर्टिस्टहरूलाई\nSpotify मा गीत सुनेको भए कति भुक्तानी गर्ने थिए।'; - - @override - String get minutes_listened => 'सुनिएका मिनेटहरू'; - - @override - String get streamed_songs => 'स्ट्रीम गरिएका गीतहरू'; - - @override - String count_streams(Object count) { - return '$count स्ट्रिम'; - } - - @override - String get owned_by_you => 'तपाईंले स्वामित्व गरेको'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl क्लिपबोर्डमा कपी गरियो'; - } - - @override - String get hipotetical_calculation => - '*यो अनलाइन संगीत स्ट्रिमिङ प्लेटफर्मको प्रति स्ट्रिम भुक्तानी \$0.003 देखि \$0.005 को औसतमा आधारित छ। यो एक काल्पनिक गणना हो जुन प्रयोगकर्तालाई उनीहरूले विभिन्न संगीत स्ट्रिमिङ प्लेटफर्ममा आफ्ना गीतहरू सुनेमा कलाकारहरूलाई कति भुक्तानी गर्ने थिए भन्ने बारेमा अन्तरदृष्टि दिनको लागि हो।'; - - @override - String count_mins(Object minutes) { - return '$minutes मिनेट'; - } - - @override - String get summary_minutes => 'मिनेट'; - - @override - String get summary_listened_to_music => 'सङ्गीत सुन्नु'; - - @override - String get summary_songs => 'गीतहरू'; - - @override - String get summary_streamed_overall => 'सामान्य रूपले स्ट्रीम गरिएको'; - - @override - String get summary_owed_to_artists => 'यस महिना कलाकारहरूलाई देन'; - - @override - String get summary_artists => 'कलाकारको'; - - @override - String get summary_music_reached_you => 'सङ्गीत तपाईंलाई पुग्यो'; - - @override - String get summary_full_albums => 'पूर्ण एल्बमहरू'; - - @override - String get summary_got_your_love => 'तपाईंको माया प्राप्त गरियो'; - - @override - String get summary_playlists => 'प्लेइस्ट'; - - @override - String get summary_were_on_repeat => 'पुनरावृत्ति गरियो'; - - @override - String total_money(Object money) { - return 'कुल $money'; - } - - @override - String get webview_not_found => 'वेबभ्यू फेला परेन'; - - @override - String get webview_not_found_description => - 'तपाईंको उपकरणमा कुनै वेबभ्यू रनटाइम स्थापना गरिएको छैन।\nयदि स्थापना गरिएको छ भने, environment PATH मा छ कि छैन भनेर सुनिश्चित गर्नुहोस्\n\nस्थापना पछि, अनुप्रयोग पुनः सुरु गर्नुहोस्'; - - @override - String get unsupported_platform => 'असमर्थित प्लेटफार्म'; - - @override - String get cache_music => 'सङ्गीत क्यास गर्नुहोस्'; - - @override - String get open => 'खोल्नुहोस्'; - - @override - String get cache_folder => 'क्यास फोल्डर'; - - @override - String get export => 'निर्यात गर्नुहोस्'; - - @override - String get clear_cache => 'क्यास खाली गर्नुहोस्'; - - @override - String get clear_cache_confirmation => 'के तपाई क्यास खाली गर्न चाहनुहुन्छ?'; - - @override - String get export_cache_files => 'क्यास फाइलहरू निर्यात गर्नुहोस्'; - - @override - String found_n_files(Object count) { - return '$count फाइलहरू फेला परे'; - } - - @override - String get export_cache_confirmation => 'यी फाइलहरू निर्यात गर्न चाहनुहुन्छ'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return '$filesExported मध्ये $files फाइलहरू निर्यात गरियो'; - } - - @override - String get undo => 'पूर्ववत'; - - @override - String get download_all => 'सभी डाउनलोड करें'; - - @override - String get add_all_to_playlist => 'सभी को प्लेलिस्ट में जोड़ें'; - - @override - String get add_all_to_queue => 'सभी को कतार में जोड़ें'; - - @override - String get play_all_next => 'सभी को अगला प्ले करें'; - - @override - String get pause => 'विराम'; - - @override - String get view_all => 'सभी देखें'; - - @override - String get no_tracks_added_yet => - 'लगता है आपने अभी तक कोई ट्रैक नहीं जोड़ा है'; - - @override - String get no_tracks => 'यहाँ कोई ट्रैक नहीं दिख रहे हैं'; - - @override - String get no_tracks_listened_yet => - 'आपने अभी तक कुछ नहीं सुना है ऐसा लगता है'; - - @override - String get not_following_artists => 'आप किसी कलाकार को फॉलो नहीं कर रहे हैं'; - - @override - String get no_favorite_albums_yet => - 'लगता है आपने अभी तक कोई एल्बम पसंदीदा में नहीं जोड़ा है'; - - @override - String get no_logs_found => 'कोई लॉग नहीं मिला'; - - @override - String get youtube_engine => 'YouTube इंजन'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine इंस्टॉल नहीं है'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine आपके सिस्टम में इंस्टॉल नहीं है।'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'सुनिश्चित करें कि यह PATH वेरिएबल में उपलब्ध है या\nनीचे $engine एक्जीक्यूटेबल का पूर्ण पथ सेट करें'; - } - - @override - String get youtube_engine_unix_issue_message => - 'macOS/Linux/unix जैसे ऑपरेटिंग सिस्टम में, .zshrc/.bashrc/.bash_profile आदि में पथ सेट करना काम नहीं करेगा।\nआपको शेल कॉन्फ़िगरेशन फ़ाइल में पथ सेट करना होगा'; - - @override - String get download => 'डाउनलोड'; - - @override - String get file_not_found => 'फ़ाइल नहीं मिली'; - - @override - String get custom => 'कस्टम'; - - @override - String get add_custom_url => 'कस्टम URL जोड़ें'; - - @override - String get edit_port => 'पोर्ट सम्पादन गर्नुहोस्'; - - @override - String get port_helper_msg => - 'डिफ़ॉल्ट -1 हो जुन यादृच्छिक संख्या जनाउँछ। यदि तपाईंले फायरवाल कन्फिगर गर्नुभएको छ भने, यसलाई सेट गर्न सिफारिस गरिन्छ।'; - - @override - String connect_request(Object client) { - return '$client लाई जडान गर्न अनुमति दिनुहोस्?'; - } - - @override - String get connection_request_denied => - 'जडान अस्वीकृत। प्रयोगकर्ताले पहुँच अस्वीकृत गर्यो।'; - - @override - String get an_error_occurred => 'त्रुटि भयो'; - - @override - String get copy_to_clipboard => 'क्लिपबोर्डमा प्रतिलिपि गर्नुहोस्'; - - @override - String get view_logs => 'लगहरू हेर्नुहोस्'; - - @override - String get retry => 'पुनः प्रयास गर्नुहोस्'; - - @override - String get no_default_metadata_provider_selected => - 'तपाईंले कुनै पूर्वनिर्धारित मेटाडेटा प्रदायक सेट गर्नुभएको छैन'; - - @override - String get manage_metadata_providers => - 'मेटाडेटा प्रदायकहरू प्रबन्ध गर्नुहोस्'; - - @override - String get open_link_in_browser => 'ब्राउजरमा लिङ्क खोल्ने?'; - - @override - String get do_you_want_to_open_the_following_link => - 'के तपाईं निम्न लिङ्क खोल्न चाहनुहुन्छ'; - - @override - String get unsafe_url_warning => - 'अविश्वसनीय स्रोतहरूबाट लिङ्कहरू खोल्नु असुरक्षित हुन सक्छ। सावधान रहनुहोस्!\nतपाईं लिङ्कलाई आफ्नो क्लिपबोर्डमा पनि प्रतिलिपि गर्न सक्नुहुन्छ।'; - - @override - String get copy_link => 'लिङ्क प्रतिलिपि गर्नुहोस्'; - - @override - String get building_your_timeline => - 'तपाईंको सुन्ने आधारमा तपाईंको समयरेखा निर्माण गर्दै...'; - - @override - String get official => 'आधिकारिक'; - - @override - String author_name(Object author) { - return 'लेखक: $author'; - } - - @override - String get third_party => 'तेस्रो-पक्ष'; - - @override - String get plugin_requires_authentication => 'प्लगइनलाई प्रमाणीकरण चाहिन्छ'; - - @override - String get update_available => 'अपडेट उपलब्ध छ'; - - @override - String get supports_scrobbling => 'स्क्रब्बलिंगलाई समर्थन गर्दछ'; - - @override - String get plugin_scrobbling_info => - 'यो प्लगइनले तपाईंको सुन्ने इतिहास उत्पन्न गर्न तपाईंको संगीतलाई स्क्रब्बल गर्दछ।'; - - @override - String get default_metadata_source => 'पूर्वनिर्धारित मेटाडाटा स्रोत'; - - @override - String get set_default_metadata_source => - 'पूर्वनिर्धारित मेटाडाटा स्रोत सेट गर्नुहोस्'; - - @override - String get default_audio_source => 'पूर्वनिर्धारित अडियो स्रोत'; - - @override - String get set_default_audio_source => - 'पूर्वनिर्धारित अडियो स्रोत सेट गर्नुहोस्'; - - @override - String get set_default => 'पूर्वनिर्धारित सेट गर्नुहोस्'; - - @override - String get support => 'समर्थन'; - - @override - String get support_plugin_development => 'प्लगइन विकासलाई समर्थन गर्नुहोस्'; - - @override - String can_access_name_api(Object name) { - return '- **$name** API मा पहुँच गर्न सक्छ'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'के तपाईं यो प्लगइन स्थापना गर्न चाहनुहुन्छ?'; - - @override - String get third_party_plugin_warning => - 'यो प्लगइन तेस्रो-पक्ष रिपोसिटरीबाट हो। कृपया स्थापना गर्नु अघि तपाईंले स्रोतमा विश्वास गर्नुहुन्छ भनी सुनिश्चित गर्नुहोस्।'; - - @override - String get author => 'लेखक'; - - @override - String get this_plugin_can_do_following => 'यो प्लगइनले निम्न गर्न सक्छ'; - - @override - String get install => 'स्थापना गर्नुहोस्'; - - @override - String get install_a_metadata_provider => - 'मेटाडेटा प्रदायक स्थापना गर्नुहोस्'; - - @override - String get no_tracks_playing => 'हाल कुनै ट्र्याक बजिरहेको छैन'; - - @override - String get synced_lyrics_not_available => - 'यो गीतको लागि सिङ्क गरिएका बोलहरू उपलब्ध छैनन्। कृपया यसको सट्टा'; - - @override - String get plain_lyrics => 'सादा बोलहरू'; - - @override - String get tab_instead => 'ट्याब प्रयोग गर्नुहोस्।'; - - @override - String get disclaimer => 'अस्वीकरण'; - - @override - String get third_party_plugin_dmca_notice => - 'स्पोट्यूब टोलीले कुनै पनि \"तेस्रो-पक्ष\" प्लगइनहरूको लागि कुनै जिम्मेवारी (कानुनी सहित) लिँदैन।\nकृपया तिनीहरूलाई आफ्नो जोखिममा प्रयोग गर्नुहोस्। कुनै पनि बग/समस्याहरूको लागि, कृपया तिनीहरूलाई प्लगइन रिपोसिटरीमा रिपोर्ट गर्नुहोस्।\n\nयदि कुनै \"तेस्रो-पक्ष\" प्लगइनले कुनै सेवा/कानुनी संस्थाको ToS/DMCA तोडिरहेको छ भने, कृपया \"तेस्रो-पक्ष\" प्लगइन लेखक वा होस्टिङ प्लेटफर्म e.g. GitHub/Codeberg लाई कारबाही गर्न अनुरोध गर्नुहोस्। माथि सूचीबद्ध (\"तेस्रो-पक्ष\" लेबल गरिएका) सबै सार्वजनिक/सामुदायिक रूपमा राखिएका प्लगइनहरू हुन्। हामी तिनीहरूलाई क्युरेट गरिरहेका छैनौं, त्यसैले हामी तिनीहरूमा कुनै कारबाही गर्न सक्दैनौं।\n\n'; - - @override - String get input_does_not_match_format => 'इनपुट आवश्यक ढाँचासँग मेल खाँदैन'; - - @override - String get plugins => 'प्लगइनहरू'; - - @override - String get paste_plugin_download_url => - 'डाउनलोड url वा GitHub/Codeberg repo url वा .smplug फाइलमा सिधा लिङ्क टाँस्नुहोस्'; - - @override - String get download_and_install_plugin_from_url => - 'url बाट प्लगइन डाउनलोड र स्थापना गर्नुहोस्'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'प्लगइन थप्न असफल: $error'; - } - - @override - String get upload_plugin_from_file => 'फाइलबाट प्लगइन अपलोड गर्नुहोस्'; - - @override - String get installed => 'स्थापित'; - - @override - String get available_plugins => 'उपलब्ध प्लगइनहरू'; - - @override - String get configure_plugins => - 'आफ्नै मेटाडाटा प्रदायक र अडियो स्रोत प्लगइनहरू कन्फिगर गर्नुहोस्'; - - @override - String get audio_scrobblers => 'अडियो स्क्रब्बलरहरू'; - - @override - String get scrobbling => 'स्क्रब्बलिंग'; - - @override - String get source => 'स्रोत: '; - - @override - String get uncompressed => 'असंक्षिप्त'; - - @override - String get dab_music_source_description => - 'अडियोप्रेमीहरूका लागि। उच्च गुणस्तर/लसलेस अडियो स्ट्रिमहरू उपलब्ध गराउँछ। ISRC-मा आधारित सटीक ट्र्याक मिलान।'; -} diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart deleted file mode 100644 index 0a73c640..00000000 --- a/lib/l10n/generated/app_localizations_nl.dart +++ /dev/null @@ -1,1570 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Dutch Flemish (`nl`). -class AppLocalizationsNl extends AppLocalizations { - AppLocalizationsNl([String locale = 'nl']) : super(locale); - - @override - String get guest => 'Gast'; - - @override - String get browse => 'Bladeren'; - - @override - String get search => 'Zoeken'; - - @override - String get library => 'Bibliotheek'; - - @override - String get lyrics => 'Teksten'; - - @override - String get settings => 'Instellingen'; - - @override - String get genre_categories_filter => 'Categorieën of genres filteren…'; - - @override - String get genre => 'Genre'; - - @override - String get personalized => 'Gepersonaliseerd'; - - @override - String get featured => 'Aanbevolen'; - - @override - String get new_releases => 'Nieuwe uitgaven'; - - @override - String get songs => 'Liedjes'; - - @override - String playing_track(Object track) { - return '$track afspelen'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Dit zal de huidige wachtrij wissen. $track_length nummers worden verwijderd\nWil je doorgaan?'; - } - - @override - String get load_more => 'Meer laden'; - - @override - String get playlists => 'Afspeellijsten'; - - @override - String get artists => 'Artiesten'; - - @override - String get albums => 'Albums'; - - @override - String get tracks => 'Nummers'; - - @override - String get downloads => 'Downloads'; - - @override - String get filter_playlists => 'Afspeellijsten filteren…'; - - @override - String get liked_tracks => 'Geliefde tracks'; - - @override - String get liked_tracks_description => 'Al je favoriete nummers'; - - @override - String get playlist => 'Afspeellijst'; - - @override - String get create_a_playlist => 'Een afspeellijst aanmaken'; - - @override - String get update_playlist => 'Afspeellijst bijwerken'; - - @override - String get create => 'Aanmaken'; - - @override - String get cancel => 'Annuleren'; - - @override - String get update => 'Bijwerken'; - - @override - String get playlist_name => 'Naam afspeellijst'; - - @override - String get name_of_playlist => 'Naam van de afspeellijst'; - - @override - String get description => 'Beschrijving'; - - @override - String get public => 'Openbaar'; - - @override - String get collaborative => 'Samenwerkend'; - - @override - String get search_local_tracks => 'Lokale nummers zoeken…'; - - @override - String get play => 'Afspelen'; - - @override - String get delete => 'Wissen'; - - @override - String get none => 'Geen'; - - @override - String get sort_a_z => 'Sorteren op A-Z'; - - @override - String get sort_z_a => 'Sorteren op Z-A'; - - @override - String get sort_artist => 'Sorteren op artiest'; - - @override - String get sort_album => 'Sorteren op album'; - - @override - String get sort_duration => 'Sorteren op lengte'; - - @override - String get sort_tracks => 'Nummers sorteren'; - - @override - String currently_downloading(Object tracks_length) { - return 'Momenteel aan het downloaden ($tracks_length)'; - } - - @override - String get cancel_all => 'Alles annuleren'; - - @override - String get filter_artist => 'Artiesten filteren…'; - - @override - String followers(Object followers) { - return '$followers volgers'; - } - - @override - String get add_artist_to_blacklist => 'Artiest toevoegen aan zwarte lijst'; - - @override - String get top_tracks => 'Topnummers'; - - @override - String get fans_also_like => 'Fans luisteren ook'; - - @override - String get loading => 'Laden…'; - - @override - String get artist => 'Artiest'; - - @override - String get blacklisted => 'Zwarte lijst'; - - @override - String get following => 'Volgen'; - - @override - String get follow => 'Volgen'; - - @override - String get artist_url_copied => 'URL artiest gekopieerd naar klembord'; - - @override - String added_to_queue(Object tracks) { - return '$tracks nummers toegevoegd aan wachtrij'; - } - - @override - String get filter_albums => 'Albums filteren…'; - - @override - String get synced => 'Gesynchroniseerd'; - - @override - String get plain => 'Eenvoudig'; - - @override - String get shuffle => 'Willekeurig'; - - @override - String get search_tracks => 'Nummers zoeken…'; - - @override - String get released => 'Uitgegeven'; - - @override - String error(Object error) { - return 'Fout $error'; - } - - @override - String get title => 'Titel'; - - @override - String get time => 'Tijd'; - - @override - String get more_actions => 'Meer acties'; - - @override - String download_count(Object count) { - return '($count) downloads'; - } - - @override - String add_count_to_playlist(Object count) { - return '($count) aan afspeellijst toevoegen'; - } - - @override - String add_count_to_queue(Object count) { - return '($count) aan wachtrij toevoegen'; - } - - @override - String play_count_next(Object count) { - return 'Volgende ($count) afspelen'; - } - - @override - String get album => 'Album'; - - @override - String copied_to_clipboard(Object data) { - return '$data naar klembord gekopieerd'; - } - - @override - String add_to_following_playlists(Object track) { - return '$track aan volgende afspeellijsten toevoegen'; - } - - @override - String get add => 'Toevoegen'; - - @override - String added_track_to_queue(Object track) { - return '$track aan wachtrij toegevoegd'; - } - - @override - String get add_to_queue => 'Toevoegen aan wachtrij'; - - @override - String track_will_play_next(Object track) { - return '$track wordt hierna afgespeeld'; - } - - @override - String get play_next => 'Volgende afspelen'; - - @override - String removed_track_from_queue(Object track) { - return '$track van wachtrij verwijderd'; - } - - @override - String get remove_from_queue => 'Van wachtrij verwijderen'; - - @override - String get remove_from_favorites => 'Van favorieten verwijderen'; - - @override - String get save_as_favorite => 'Opslaan als favoriet'; - - @override - String get add_to_playlist => 'Aan afspeellijst toevoegen'; - - @override - String get remove_from_playlist => 'Van afspeellijst verwijderen'; - - @override - String get add_to_blacklist => 'Aan zwarte lijst toevoegen'; - - @override - String get remove_from_blacklist => 'Van zwarte lijst verwijderen'; - - @override - String get share => 'Delen'; - - @override - String get mini_player => 'Minispeler'; - - @override - String get slide_to_seek => 'Schuiven om vooruit of achteruit te zoeken'; - - @override - String get shuffle_playlist => 'Afspeellijst willekeurig'; - - @override - String get unshuffle_playlist => 'Afspeellijst op volgorde'; - - @override - String get previous_track => 'Vorige nummer'; - - @override - String get next_track => 'Volgende nummer'; - - @override - String get pause_playback => 'Afspelen pauzeren'; - - @override - String get resume_playback => 'Afspelen hervatten'; - - @override - String get loop_track => 'Nummer herhalen'; - - @override - String get no_loop => 'Geen herhaling'; - - @override - String get repeat_playlist => 'Afspeellijst herhalen'; - - @override - String get queue => 'Wachtrij'; - - @override - String get alternative_track_sources => 'Alternatieve bronnen voor nummers'; - - @override - String get download_track => 'Nummer downloaden'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks nummers in wachtrij'; - } - - @override - String get clear_all => 'Alles wissen'; - - @override - String get show_hide_ui_on_hover => 'UI tonen/verbergen bij zweven'; - - @override - String get always_on_top => 'Altijd bovenaan'; - - @override - String get exit_mini_player => 'Minispeler afsluiten'; - - @override - String get download_location => 'Downloadlocatie'; - - @override - String get local_library => 'Lokale bibliotheek'; - - @override - String get add_library_location => 'Toevoegen aan bibliotheek'; - - @override - String get remove_library_location => 'Verwijderen uit bibliotheek'; - - @override - String get account => 'Account'; - - @override - String get logout => 'Afmelden'; - - @override - String get logout_of_this_account => 'Afmelden van dit account'; - - @override - String get language_region => 'Taal & regio'; - - @override - String get language => 'Taal'; - - @override - String get system_default => 'Systeemstandaard'; - - @override - String get market_place_region => 'Marktplaats-regio'; - - @override - String get recommendation_country => 'Aanbeveling Land'; - - @override - String get appearance => 'Uiterlijk'; - - @override - String get layout_mode => 'Opmaakmodus'; - - @override - String get override_layout_settings => - 'Instellingen voor responsieve opmaakmodus opheffen'; - - @override - String get adaptive => 'Adaptief'; - - @override - String get compact => 'Compact'; - - @override - String get extended => 'Uitgebreid'; - - @override - String get theme => 'Thema'; - - @override - String get dark => 'Donker'; - - @override - String get light => 'Licht'; - - @override - String get system => 'Systeem'; - - @override - String get accent_color => 'Accentkleur'; - - @override - String get sync_album_color => 'Albumkleur synchroniseren'; - - @override - String get sync_album_color_description => - 'Gebruikt de overheersende kleur van het album als accentkleur'; - - @override - String get playback => 'Weergave'; - - @override - String get audio_quality => 'Audiokwaliteit'; - - @override - String get high => 'Hoog'; - - @override - String get low => 'Laag'; - - @override - String get pre_download_play => 'Vooraf downloaden en afspelen'; - - @override - String get pre_download_play_description => - 'In plaats van audio te streamen, kun je bytes downloaden en afspelen (aanbevolen voor gebruikers met een hogere bandbreedte)'; - - @override - String get skip_non_music => 'Niet-muzieksegmenten overslaan (SponsorBlock)'; - - @override - String get blacklist_description => 'Nummers en artiesten op de zwarte lijst'; - - @override - String get wait_for_download_to_finish => - 'Wacht tot de huidige download is voltooid'; - - @override - String get desktop => 'Bureaublad'; - - @override - String get close_behavior => 'Sluitgedrag'; - - @override - String get close => 'Afsluiten'; - - @override - String get minimize_to_tray => 'Minimaliseren naar systeemvak'; - - @override - String get show_tray_icon => 'Systeemvakpictogram tonen'; - - @override - String get about => 'Over'; - - @override - String get u_love_spotube => 'We weten dat je van Spotube houd'; - - @override - String get check_for_updates => 'Controleren op updates'; - - @override - String get about_spotube => 'Over Spotube'; - - @override - String get blacklist => 'Zwarte lijst'; - - @override - String get please_sponsor => 'Sponsor/Doneer a.u.b.'; - - @override - String get spotube_description => - 'Spotube, een lichtgewicht, cross-platform, vrij-voor-alles Spotify-client'; - - @override - String get version => 'Versie'; - - @override - String get build_number => 'Bouwnummer'; - - @override - String get founder => 'Grondlegger'; - - @override - String get repository => 'Opslagplaats'; - - @override - String get bug_issues => 'Bug+problemen'; - - @override - String get made_with => 'Met ❤️ gemaakt in Bangladesh🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Licentie'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Maak je geen zorgen, je gegevens worden niet verzameld of gedeeld met anderen.'; - - @override - String get know_how_to_login => 'Weet je niet hoe je dit moet doen?'; - - @override - String get follow_step_by_step_guide => 'Volg de stapsgewijze handleiding'; - - @override - String cookie_name_cookie(Object name) { - return '$name Cookie'; - } - - @override - String get fill_in_all_fields => 'Vul alle velden in a.u.b.'; - - @override - String get submit => 'Verzenden'; - - @override - String get exit => 'Afronden'; - - @override - String get previous => 'Vorige'; - - @override - String get next => 'Volgende'; - - @override - String get done => 'Klaar'; - - @override - String get step_1 => 'Stap 1'; - - @override - String get first_go_to => 'Ga eerst naar'; - - @override - String get something_went_wrong => 'Er ging iets mis'; - - @override - String get piped_instance => 'Piped-serverinstantie'; - - @override - String get piped_description => - 'De Piped-serverinstantie die moet worden gebruikt voor overeenkomstige nummers'; - - @override - String get piped_warning => - 'Sommige werken misschien niet goed. Dus gebruik ze op eigen risico'; - - @override - String get invidious_instance => 'Invidious-serverinstantie'; - - @override - String get invidious_description => - 'De Invidious-serverinstantie die gebruikt wordt voor trackmatching'; - - @override - String get invidious_warning => - 'Sommigen werken mogelijk niet goed. Gebruik op eigen risico'; - - @override - String get generate => 'Genereren'; - - @override - String track_exists(Object track) { - return 'Nummer $track bestaat al'; - } - - @override - String get replace_downloaded_tracks => 'Alle gedownloade nummers vervangen'; - - @override - String get skip_download_tracks => - 'Downloaden van alle gedownloade nummers overslaan'; - - @override - String get do_you_want_to_replace => 'Wil je het bestaande nummer vervangen?'; - - @override - String get replace => 'Vervangen'; - - @override - String get skip => 'Overslaan'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Selecteer tot $count $type'; - } - - @override - String get select_genres => 'Genres selecteren'; - - @override - String get add_genres => 'Genres toevoegen'; - - @override - String get country => 'Land'; - - @override - String get number_of_tracks_generate => 'Aantal nummers om te genereren'; - - @override - String get acousticness => 'Akoestiek'; - - @override - String get danceability => 'Dansbaarheid'; - - @override - String get energy => 'Energie'; - - @override - String get instrumentalness => 'Instrumentaliteit'; - - @override - String get liveness => 'Levendigheid'; - - @override - String get loudness => 'Luidheid'; - - @override - String get speechiness => 'Spraak'; - - @override - String get valence => 'Valentie'; - - @override - String get popularity => 'Populariteit'; - - @override - String get key => 'Sleutel'; - - @override - String get duration => 'Tijdsduur (s)'; - - @override - String get tempo => 'Tempo (SPM)'; - - @override - String get mode => 'Modus'; - - @override - String get time_signature => 'Tijdsnotatie'; - - @override - String get short => 'Kort'; - - @override - String get medium => 'Middel'; - - @override - String get long => 'Lang'; - - @override - String get min => 'Min'; - - @override - String get max => 'Max'; - - @override - String get target => 'Doel'; - - @override - String get moderate => 'Matig'; - - @override - String get deselect_all => 'Selectie opheffen'; - - @override - String get select_all => 'Alles selecteren'; - - @override - String get are_you_sure => 'Weet je het zeker?'; - - @override - String get generating_playlist => 'Aangepaste afspeellijst genereren…'; - - @override - String selected_count_tracks(Object count) { - return '$count nummers geselecteerd'; - } - - @override - String get download_warning => - 'Als je alle nummers in bulk downloadt, ben je duidelijk bezig met muziekpiraterij en breng je schade toe aan de creatieve muziekmaatschappij. Ik hoop dat je je hiervan bewust bent. Probeer altijd het harde werk van artiesten te respecteren en te steunen.'; - - @override - String get download_ip_ban_warning => - 'BTW, je IP-adres kan worden geblokkeerd op YouTube als gevolg van buitensporige downloadverzoeken. IP-blokkering betekent dat je YouTube niet kunt gebruiken (zelfs als je ingelogd bent) voor tenminste 2-3 maanden vanaf dat IP-apparaat. Spotube is niet verantwoordelijk als dit ooit gebeurt.'; - - @override - String get by_clicking_accept_terms => - 'Door op \'accepteren\' te klikken ga je akkoord met de volgende voorwaarden:'; - - @override - String get download_agreement_1 => - 'Ik weet dat ik muziek illegaal donload. Ik ben slecht.'; - - @override - String get download_agreement_2 => - 'Ik steun de artiest waar ik kan en ik doe dit alleen omdat ik geen geld heb om hun kunst te kopen.'; - - @override - String get download_agreement_3 => - 'Ik ben me er volledig van bewust dat mijn IP geblokkeerd kan worden op YouTube & ik houd Spotube of zijn eigenaars/contributeurs niet verantwoordelijk voor ongelukken die veroorzaakt worden door mijn huidige actie.'; - - @override - String get decline => 'Weigeren'; - - @override - String get accept => 'Accepteren'; - - @override - String get details => 'Bijzonderheden'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Kanaal'; - - @override - String get likes => 'Liefs'; - - @override - String get dislikes => 'Hekels'; - - @override - String get views => 'Weergaven'; - - @override - String get streamUrl => 'Stream-URL'; - - @override - String get stop => 'Stoppen'; - - @override - String get sort_newest => 'Sorteren op recent toegevoegd'; - - @override - String get sort_oldest => 'Sorteren op langst toegevoegd'; - - @override - String get sleep_timer => 'Slaaptimer'; - - @override - String mins(Object minutes) { - return '$minutes minuten'; - } - - @override - String hours(Object hours) { - return '$hours uren'; - } - - @override - String hour(Object hours) { - return '$hours uur'; - } - - @override - String get custom_hours => 'Aangepaste uren'; - - @override - String get logs => 'Logboeken'; - - @override - String get developers => 'Ontwikkelaars'; - - @override - String get not_logged_in => 'Je bent niet aangemeld'; - - @override - String get search_mode => 'Zoekmodus'; - - @override - String get audio_source => 'Audiobron'; - - @override - String get ok => 'Oké'; - - @override - String get failed_to_encrypt => 'Versleuteling mislukt'; - - @override - String get encryption_failed_warning => - 'Spotube gebruikt versleuteling om je gegevens veilig op te slaan. Maar dat is niet gelukt. Dus zal het terugvallen op onveilige opslag.\nAls je linux gebruikt, zorg er dan voor dat je een geheim-dienst (gnome-keyring, kde-wallet, keepassxc etc) hebt geïnstalleerd.'; - - @override - String get querying_info => 'Info opvragen…'; - - @override - String get piped_api_down => 'Piped API is uit'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'De Piped-instantie $pipedInstance is momenteel uitgevallen\n\nVerander de instantie of verander het \'API-type\' naar de officiële YouTube API.\n\nZorg ervoor dat u de app herstart na de wijziging'; - } - - @override - String get you_are_offline => 'Je bent momenteel offline'; - - @override - String get connection_restored => 'Je internetverbinding is hersteld'; - - @override - String get use_system_title_bar => 'Systeemtitelbalk gebruiken'; - - @override - String get crunching_results => 'Resultaten verwerken…'; - - @override - String get search_to_get_results => 'Zoeken naar resultaten'; - - @override - String get use_amoled_mode => 'Pikzwart donkerthema'; - - @override - String get pitch_dark_theme => 'AMOLED-modus'; - - @override - String get normalize_audio => 'Audio normaliseren'; - - @override - String get change_cover => 'Hoes aanpassen'; - - @override - String get add_cover => 'Hoes toevoegen'; - - @override - String get restore_defaults => 'Standaardwaarden herstellen'; - - @override - String get download_music_format => 'Download muziekformaat'; - - @override - String get streaming_music_format => 'Streaming muziekformaat'; - - @override - String get download_music_quality => 'Downloadkwaliteit'; - - @override - String get streaming_music_quality => 'Streamingkwaliteit'; - - @override - String get login_with_lastfm => 'Inloggen met Last.fm'; - - @override - String get connect => 'Verbinden'; - - @override - String get disconnect_lastfm => 'Last.fm verbreken'; - - @override - String get disconnect => 'Verbeken'; - - @override - String get username => 'Gebruikersnaam'; - - @override - String get password => 'Wachtwoord'; - - @override - String get login => 'Inloggen'; - - @override - String get login_with_your_lastfm => 'Inloggen met je Last.fm account'; - - @override - String get scrobble_to_lastfm => 'Scrobbelen naar Last.fm'; - - @override - String get go_to_album => 'Ga naar album'; - - @override - String get discord_rich_presence => 'Discord Rich Presence'; - - @override - String get browse_all => 'Alles doorbladeren'; - - @override - String get genres => 'Genres'; - - @override - String get explore_genres => 'Genres verkennen'; - - @override - String get friends => 'Vrienden'; - - @override - String get no_lyrics_available => - 'Sorry, geen teksten gevonden voor dit nummer'; - - @override - String get start_a_radio => 'Een radio starten'; - - @override - String get how_to_start_radio => 'Hoe wil je de radio starten?'; - - @override - String get replace_queue_question => - 'Wil je de huidige wachtrij vervangen of eraan toevoegen?'; - - @override - String get endless_playback => 'Oneindig afspelen'; - - @override - String get delete_playlist => 'Afspeellijst verwijderen'; - - @override - String get delete_playlist_confirmation => - 'Weet je zeker dat je deze afspeellijst wilt verwijderen?'; - - @override - String get local_tracks => 'Lokale nummers'; - - @override - String get local_tab => 'Lokaal'; - - @override - String get song_link => 'Song-link'; - - @override - String get skip_this_nonsense => 'Deze onzin overslaan'; - - @override - String get freedom_of_music => '“Vrijheid van muziek”'; - - @override - String get freedom_of_music_palm => '“Vrijheid van muziek in je hand”'; - - @override - String get get_started => 'Laten we beginnen'; - - @override - String get youtube_source_description => 'Aangeraden en werkt het best.'; - - @override - String get piped_source_description => - 'Voel je je vrij? Net als YouTube, maar meer vrij.'; - - @override - String get jiosaavn_source_description => - 'Het beste voor de regio Zuid-Azië.'; - - @override - String get invidious_source_description => - 'Vergelijkbaar met Piped, maar met een hogere beschikbaarheid.'; - - @override - String highest_quality(Object quality) { - return 'Hoogste kwaliteit: $quality'; - } - - @override - String get select_audio_source => 'Audiobron kiezen'; - - @override - String get endless_playback_description => - 'Nieuwe nummers automatisch achteraan de wachtrij toevoegen'; - - @override - String get choose_your_region => 'Kies je regio'; - - @override - String get choose_your_region_description => - 'Dit helpt Spotube om de juiste inhoud\nvoor jouw locatie te tonen.'; - - @override - String get choose_your_language => 'Kies je taal'; - - @override - String get help_project_grow => 'Help dit project met groeien'; - - @override - String get help_project_grow_description => - 'Spotube is een open-source project. Je kunt dit project helpen groeien door eraan bij te dragen, problemen te melden of nieuwe functies voor te stellen.'; - - @override - String get contribute_on_github => 'Bijdragen on GitHub'; - - @override - String get donate_on_open_collective => 'Doneren on Open Collective'; - - @override - String get browse_anonymously => 'Anoniem browsen'; - - @override - String get enable_connect => 'Verbinding inschakelen'; - - @override - String get enable_connect_description => - 'Spotube bedienen vanaf andere apparaten'; - - @override - String get devices => 'Apparaten'; - - @override - String get select => 'Selecteren'; - - @override - String connect_client_alert(Object client) { - return 'Je wordt gecontroleerd door $client'; - } - - @override - String get this_device => 'Dit apparaat'; - - @override - String get remote => 'Afstandsbediening'; - - @override - String get stats => 'Statistieken'; - - @override - String and_n_more(Object count) { - return 'en $count meer'; - } - - @override - String get recently_played => 'Onlangs afgespeeld'; - - @override - String get browse_more => 'Meer bekijken'; - - @override - String get no_title => 'Geen titel'; - - @override - String get not_playing => 'Niet aan het afspelen'; - - @override - String get epic_failure => 'Epische mislukking!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return '$tracks_length nummers aan de wachtrij toegevoegd'; - } - - @override - String get spotube_has_an_update => 'Spotube heeft een update'; - - @override - String get download_now => 'Nu downloaden'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum is uitgebracht'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version is uitgebracht'; - } - - @override - String get read_the_latest => 'Lees de nieuwste '; - - @override - String get release_notes => 'release-opmerkingen'; - - @override - String get pick_color_scheme => 'Kies kleurenschema'; - - @override - String get save => 'Opslaan'; - - @override - String get choose_the_device => 'Kies het apparaat:'; - - @override - String get multiple_device_connected => - 'Er zijn meerdere apparaten verbonden.\nKies het apparaat waarop je deze actie wilt uitvoeren'; - - @override - String get nothing_found => 'Niets gevonden'; - - @override - String get the_box_is_empty => 'De doos is leeg'; - - @override - String get top_artists => 'Topartiesten'; - - @override - String get top_albums => 'Topalbums'; - - @override - String get this_week => 'Deze week'; - - @override - String get this_month => 'Deze maand'; - - @override - String get last_6_months => 'Laatste 6 maanden'; - - @override - String get this_year => 'Dit jaar'; - - @override - String get last_2_years => 'Laatste 2 jaar'; - - @override - String get all_time => 'All time'; - - @override - String powered_by_provider(Object providerName) { - return 'Aangedreven door $providerName'; - } - - @override - String get email => 'E-mail'; - - @override - String get profile_followers => 'Volgers'; - - @override - String get birthday => 'Verjaardag'; - - @override - String get subscription => 'Abonnement'; - - @override - String get not_born => 'Niet geboren'; - - @override - String get hacker => 'Hacker'; - - @override - String get profile => 'Profiel'; - - @override - String get no_name => 'Geen naam'; - - @override - String get edit => 'Bewerken'; - - @override - String get user_profile => 'Gebruikersprofiel'; - - @override - String count_plays(Object count) { - return '$count afspeelbeurten'; - } - - @override - String get streaming_fees_hypothetical => - '*Dit is berekend op basis van Spotify\'s uitbetaling per stream\nvan \$0.003 tot \$0.005. Dit is een hypothetische\nberekening om gebruikers inzicht te geven in hoeveel ze\naan de artiesten zouden hebben betaald als ze hun lied op Spotify zouden hebben beluisterd.'; - - @override - String get minutes_listened => 'Luistertijd'; - - @override - String get streamed_songs => 'Gestreamde nummers'; - - @override - String count_streams(Object count) { - return '$count streams'; - } - - @override - String get owned_by_you => 'Bezit door jou'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl gekopieerd naar klembord'; - } - - @override - String get hipotetical_calculation => - '*Dit is berekend op basis van de gemiddelde uitbetaling per stream van online muziekstreamingplatforms van \$0,003 tot \$0,005. Dit is een hypothetische berekening om de gebruiker inzicht te geven in hoeveel ze aan de artiesten zouden hebben betaald als ze hun nummer op een ander muziekstreamingplatform zouden beluisteren.'; - - @override - String count_mins(Object minutes) { - return '$minutes min'; - } - - @override - String get summary_minutes => 'minuten'; - - @override - String get summary_listened_to_music => 'Beluisterde muziek'; - - @override - String get summary_songs => 'nummers'; - - @override - String get summary_streamed_overall => 'Totaal gestreamd'; - - @override - String get summary_owed_to_artists => 'Te betalen aan artiesten\ndeze maand'; - - @override - String get summary_artists => 'van de artiest'; - - @override - String get summary_music_reached_you => 'Muziek heeft je bereikt'; - - @override - String get summary_full_albums => 'volledige albums'; - - @override - String get summary_got_your_love => 'Kreeg je liefde'; - - @override - String get summary_playlists => 'afspeellijsten'; - - @override - String get summary_were_on_repeat => 'Was op herhaling'; - - @override - String total_money(Object money) { - return 'Totaal $money'; - } - - @override - String get webview_not_found => 'Webview niet gevonden'; - - @override - String get webview_not_found_description => - 'Er is geen Webview-runtime geïnstalleerd op uw apparaat.\nAls het is geïnstalleerd, zorg ervoor dat het in het environment PATH staat\n\nHerstart de app na installatie'; - - @override - String get unsupported_platform => 'Niet ondersteund platform'; - - @override - String get cache_music => 'Cache muziek'; - - @override - String get open => 'Open'; - - @override - String get cache_folder => 'Cachemap'; - - @override - String get export => 'Exporteren'; - - @override - String get clear_cache => 'Cache wissen'; - - @override - String get clear_cache_confirmation => 'Wilt u de cache wissen?'; - - @override - String get export_cache_files => 'Gecacheerde bestanden exporteren'; - - @override - String found_n_files(Object count) { - return '$count bestanden gevonden'; - } - - @override - String get export_cache_confirmation => - 'Wilt u deze bestanden exporteren naar'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return '$filesExported van de $files bestanden geëxporteerd'; - } - - @override - String get undo => 'Ongedaan maken'; - - @override - String get download_all => 'Alles downloaden'; - - @override - String get add_all_to_playlist => 'Voeg alles toe aan afspeellijst'; - - @override - String get add_all_to_queue => 'Voeg alles toe aan wachtrij'; - - @override - String get play_all_next => 'Speel alles volgende'; - - @override - String get pause => 'Pauzeren'; - - @override - String get view_all => 'Bekijk alles'; - - @override - String get no_tracks_added_yet => - 'Het lijkt erop dat je nog geen nummers hebt toegevoegd'; - - @override - String get no_tracks => 'Het lijkt erop dat er hier geen nummers zijn'; - - @override - String get no_tracks_listened_yet => - 'Het lijkt erop dat je nog niets hebt beluisterd'; - - @override - String get not_following_artists => 'Je volgt geen artiesten'; - - @override - String get no_favorite_albums_yet => - 'Het lijkt erop dat je nog geen albums aan je favorieten hebt toegevoegd'; - - @override - String get no_logs_found => 'Geen logbestanden gevonden'; - - @override - String get youtube_engine => 'YouTube Engine'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine is niet geïnstalleerd'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine is niet geïnstalleerd op je systeem.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Zorg ervoor dat het beschikbaar is in de PATH-variabele of\nstel het absolute pad naar de $engine uitvoerbare bestanden in'; - } - - @override - String get youtube_engine_unix_issue_message => - 'Op macOS/Linux/unix-achtige besturingssystemen werkt het instellen van paden in .zshrc/.bashrc/.bash_profile enz. niet.\nJe moet het pad instellen in het shell-configuratiebestand'; - - @override - String get download => 'Downloaden'; - - @override - String get file_not_found => 'Bestand niet gevonden'; - - @override - String get custom => 'Aangepast'; - - @override - String get add_custom_url => 'Voeg aangepaste URL toe'; - - @override - String get edit_port => 'Poort bewerken'; - - @override - String get port_helper_msg => - 'Standaard is -1, wat een willekeurig nummer aangeeft. Als je een firewall hebt geconfigureerd, wordt aanbevolen dit in te stellen.'; - - @override - String connect_request(Object client) { - return 'Toestaan dat $client verbinding maakt?'; - } - - @override - String get connection_request_denied => - 'Verbinding geweigerd. Gebruiker heeft toegang geweigerd.'; - - @override - String get an_error_occurred => 'Er is een fout opgetreden'; - - @override - String get copy_to_clipboard => 'Kopiëren naar klembord'; - - @override - String get view_logs => 'Logboeken bekijken'; - - @override - String get retry => 'Opnieuw proberen'; - - @override - String get no_default_metadata_provider_selected => - 'U heeft geen standaard metadata-aanbieder ingesteld'; - - @override - String get manage_metadata_providers => 'Metadata-aanbieders beheren'; - - @override - String get open_link_in_browser => 'Link openen in browser?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Wilt u de volgende link openen'; - - @override - String get unsafe_url_warning => - 'Het kan onveilig zijn om links van onbetrouwbare bronnen te openen. Wees voorzichtig!\nU kunt de link ook naar uw klembord kopiëren.'; - - @override - String get copy_link => 'Link kopiëren'; - - @override - String get building_your_timeline => - 'Uw tijdlijn wordt opgebouwd op basis van uw luistergedrag...'; - - @override - String get official => 'Officieel'; - - @override - String author_name(Object author) { - return 'Auteur: $author'; - } - - @override - String get third_party => 'Derden'; - - @override - String get plugin_requires_authentication => 'Plugin vereist authenticatie'; - - @override - String get update_available => 'Update beschikbaar'; - - @override - String get supports_scrobbling => 'Ondersteunt scrobbling'; - - @override - String get plugin_scrobbling_info => - 'Deze plugin scrobblet uw muziek om uw luistergeschiedenis te genereren.'; - - @override - String get default_metadata_source => 'Standaard metadata-bron'; - - @override - String get set_default_metadata_source => 'Standaard metadata-bron instellen'; - - @override - String get default_audio_source => 'Standaard audiobron'; - - @override - String get set_default_audio_source => 'Standaard audiobron instellen'; - - @override - String get set_default => 'Instellen als standaard'; - - @override - String get support => 'Ondersteuning'; - - @override - String get support_plugin_development => 'Ondersteun plugin-ontwikkeling'; - - @override - String can_access_name_api(Object name) { - return '- Kan de **$name** API benaderen'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Wilt u deze plugin installeren?'; - - @override - String get third_party_plugin_warning => - 'Deze plugin is afkomstig van een repository van derden. Zorg ervoor dat u de bron vertrouwt voordat u installeert.'; - - @override - String get author => 'Auteur'; - - @override - String get this_plugin_can_do_following => - 'Deze plugin kan het volgende doen'; - - @override - String get install => 'Installeren'; - - @override - String get install_a_metadata_provider => - 'Een metadata-aanbieder installeren'; - - @override - String get no_tracks_playing => 'Er wordt momenteel geen nummer afgespeeld'; - - @override - String get synced_lyrics_not_available => - 'Gesynchroniseerde songteksten zijn niet beschikbaar voor dit nummer. Gebruik in plaats daarvan het tabblad'; - - @override - String get plain_lyrics => 'Eenvoudige songteksten'; - - @override - String get tab_instead => 'in plaats daarvan.'; - - @override - String get disclaimer => 'Disclaimer'; - - @override - String get third_party_plugin_dmca_notice => - 'Het Spotube-team draagt geen enkele verantwoordelijkheid (inclusief juridische) voor \"derden\" plugins.\nGebruik ze op eigen risico. Voor bugs/problemen kunt u deze melden bij de plugin-repository.\n\nAls een \"derden\" plugin de ToS/DMCA van een service/juridische entiteit schendt, vraag dan de auteur van de \"derden\" plugin of het hostingplatform, bijvoorbeeld GitHub/Codeberg, om actie te ondernemen. De hierboven vermelde (gelabelde \"derden\") plugins zijn allemaal openbare/door de gemeenschap onderhouden plugins. We beheren ze niet, dus we kunnen geen actie tegen ze ondernemen.\n\n'; - - @override - String get input_does_not_match_format => - 'Invoer komt niet overeen met het vereiste formaat'; - - @override - String get plugins => 'Plug-ins'; - - @override - String get paste_plugin_download_url => - 'Plak de download-URL of de URL van de GitHub/Codeberg-repository of een directe link naar het .smplug-bestand'; - - @override - String get download_and_install_plugin_from_url => - 'Download en installeer de plugin via URL'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Kon de plugin niet toevoegen: $error'; - } - - @override - String get upload_plugin_from_file => 'Plugin uploaden vanuit bestand'; - - @override - String get installed => 'Geïnstalleerd'; - - @override - String get available_plugins => 'Beschikbare plugins'; - - @override - String get configure_plugins => - 'Configureer je eigen metadata- en audiobron-plug-ins'; - - @override - String get audio_scrobblers => 'Audioscrobblers'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Bron: '; - - @override - String get uncompressed => 'Ongecomprimeerd'; - - @override - String get dab_music_source_description => - 'Voor audiofielen. Biedt hoge kwaliteit/lossless audiostreams. Nauwkeurige trackmatching op basis van ISRC.'; -} diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart deleted file mode 100644 index 5e185035..00000000 --- a/lib/l10n/generated/app_localizations_pl.dart +++ /dev/null @@ -1,1572 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Polish (`pl`). -class AppLocalizationsPl extends AppLocalizations { - AppLocalizationsPl([String locale = 'pl']) : super(locale); - - @override - String get guest => 'Gość'; - - @override - String get browse => 'Przeglądaj'; - - @override - String get search => 'Szukaj'; - - @override - String get library => 'Biblioteka'; - - @override - String get lyrics => 'Tekst utworu'; - - @override - String get settings => 'Ustawienia'; - - @override - String get genre_categories_filter => 'Filtruj kategorie lub gatunki...'; - - @override - String get genre => 'Gatunki'; - - @override - String get personalized => 'Spersonalizowane'; - - @override - String get featured => 'Wyróżnione'; - - @override - String get new_releases => 'Nowo wydane'; - - @override - String get songs => 'Utwory'; - - @override - String playing_track(Object track) { - return 'Odtwarzanie $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'To spowoduje wyczyszczenie całej kolejki! $track_length pozycji zostanie usuniętych.\nCzy chcesz kontynuować?'; - } - - @override - String get load_more => 'Załaduj więcej'; - - @override - String get playlists => 'Playlisty'; - - @override - String get artists => 'Artyści'; - - @override - String get albums => 'Albumy'; - - @override - String get tracks => 'Utwory'; - - @override - String get downloads => 'Pobrane'; - - @override - String get filter_playlists => 'Filtruj swoje playlisty...'; - - @override - String get liked_tracks => 'Ulubione utwory'; - - @override - String get liked_tracks_description => 'Wszystkie twoje ulubione utwory'; - - @override - String get playlist => 'Playlista'; - - @override - String get create_a_playlist => 'Utwórz playlistę'; - - @override - String get update_playlist => 'Zaktualizuj playlistę'; - - @override - String get create => 'Utwórz'; - - @override - String get cancel => 'Anuluj'; - - @override - String get update => 'Aktualizuj'; - - @override - String get playlist_name => 'Nazwa playlisty'; - - @override - String get name_of_playlist => 'Nazwa playlisty'; - - @override - String get description => 'Opis'; - - @override - String get public => 'Publiczny'; - - @override - String get collaborative => 'Współpraca'; - - @override - String get search_local_tracks => 'Szukanie lokalnych utworów...'; - - @override - String get play => 'Odtwórz'; - - @override - String get delete => 'Usuń'; - - @override - String get none => 'Brak'; - - @override - String get sort_a_z => 'Sortuj od A do Z'; - - @override - String get sort_z_a => 'Sortuj od Z do A'; - - @override - String get sort_artist => 'Sortuj po Artyście'; - - @override - String get sort_album => 'Sortuj po Albumie'; - - @override - String get sort_duration => 'Sortuj według Czasu Trwania'; - - @override - String get sort_tracks => 'Sortuj Utwory'; - - @override - String currently_downloading(Object tracks_length) { - return 'Obecnie pobieram $tracks_length utworów.'; - } - - @override - String get cancel_all => 'Anuluj wszystkie'; - - @override - String get filter_artist => 'Filtruj artystów...'; - - @override - String followers(Object followers) { - return '$followers obserwujących'; - } - - @override - String get add_artist_to_blacklist => 'Dodaj artystę do czarnej listy'; - - @override - String get top_tracks => 'Popularne Utwory'; - - @override - String get fans_also_like => 'Fani lubią także'; - - @override - String get loading => 'Ładowanie...'; - - @override - String get artist => 'Artysta'; - - @override - String get blacklisted => 'Dodano do czarnej listy'; - - @override - String get following => 'Obserwujesz'; - - @override - String get follow => 'Zaobserwuj'; - - @override - String get artist_url_copied => 'Skopiowano URL artysty do schowka'; - - @override - String added_to_queue(Object tracks) { - return 'Dodano $tracks utworów do kolejki'; - } - - @override - String get filter_albums => 'Filtruj albumy...'; - - @override - String get synced => 'Zsynchronizowano'; - - @override - String get plain => 'Zwykły'; - - @override - String get shuffle => 'Losowe odtwarzanie'; - - @override - String get search_tracks => 'Szukam utworu...'; - - @override - String get released => 'Wydano'; - - @override - String error(Object error) { - return 'Błąd $error'; - } - - @override - String get title => 'Tytuł'; - - @override - String get time => 'Czas'; - - @override - String get more_actions => 'Więcej akcji'; - - @override - String download_count(Object count) { - return 'Pobrane ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Dodaj ($count) do Playlisty'; - } - - @override - String add_count_to_queue(Object count) { - return 'Dodaj ($count) do Kolejki'; - } - - @override - String play_count_next(Object count) { - return 'Odtwórz ($count) następne'; - } - - @override - String get album => 'Album'; - - @override - String copied_to_clipboard(Object data) { - return 'Skopiowano $data do schowka'; - } - - @override - String add_to_following_playlists(Object track) { - return 'Dodano $track do danych Playlist'; - } - - @override - String get add => 'Dodaj'; - - @override - String added_track_to_queue(Object track) { - return 'Dodano $track do kolejki'; - } - - @override - String get add_to_queue => 'Dodano do kolejki'; - - @override - String track_will_play_next(Object track) { - return '$track następny'; - } - - @override - String get play_next => 'Odtwórz następny'; - - @override - String removed_track_from_queue(Object track) { - return 'Usunięto $track z kolejki'; - } - - @override - String get remove_from_queue => 'Usunięto z kolejki'; - - @override - String get remove_from_favorites => 'Usunięto z ulubionych'; - - @override - String get save_as_favorite => 'Zapisz do ulubionych'; - - @override - String get add_to_playlist => 'Dodaj do playlisty'; - - @override - String get remove_from_playlist => 'Usuń z playlisty'; - - @override - String get add_to_blacklist => 'Dodaj do czarnej listy'; - - @override - String get remove_from_blacklist => 'Usuń z czarnej listy'; - - @override - String get share => 'Udostępnij'; - - @override - String get mini_player => 'Mały odwarzacz'; - - @override - String get slide_to_seek => 'Przesuń, aby przewinąć do przodu lub do tyłu.'; - - @override - String get shuffle_playlist => 'Odtwarzaj losowo z playlisty'; - - @override - String get unshuffle_playlist => 'Nie odtwarzaj losowo z playlisty'; - - @override - String get previous_track => 'Poprzedni utwór'; - - @override - String get next_track => 'Następny utwór'; - - @override - String get pause_playback => 'Zatrzymaj odwarzanie'; - - @override - String get resume_playback => 'Wznów odwarzanie'; - - @override - String get loop_track => 'Zapętl utwór'; - - @override - String get no_loop => 'Brak pętli'; - - @override - String get repeat_playlist => 'Powtarzaj playlistę'; - - @override - String get queue => 'Kolejka'; - - @override - String get alternative_track_sources => 'Alternatywne źródła utworów'; - - @override - String get download_track => 'Pobierz utwór'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks utworów w kolejce'; - } - - @override - String get clear_all => 'Wyczyść wszystko'; - - @override - String get show_hide_ui_on_hover => 'Pokaż/Ukryj unoszący się interfejs'; - - @override - String get always_on_top => 'Zawsze na wierzchu'; - - @override - String get exit_mini_player => 'Opuść Mały odtwarzacz'; - - @override - String get download_location => 'Zmień lokalizację'; - - @override - String get local_library => 'Biblioteka lokalna'; - - @override - String get add_library_location => 'Dodaj do biblioteki'; - - @override - String get remove_library_location => 'Usuń z biblioteki'; - - @override - String get account => 'Konto'; - - @override - String get logout => 'Wyloguj'; - - @override - String get logout_of_this_account => 'Wyloguj z tego konta'; - - @override - String get language_region => 'Język i Region'; - - @override - String get language => 'Język'; - - @override - String get system_default => 'Domyślny systemowy'; - - @override - String get market_place_region => 'Region Rynku'; - - @override - String get recommendation_country => 'Kraj rekomendacji'; - - @override - String get appearance => 'Wygląd'; - - @override - String get layout_mode => 'Tryb Układu'; - - @override - String get override_layout_settings => - 'Nadpisz responsywne ustawienia trybu układu'; - - @override - String get adaptive => 'Adaptacyjny'; - - @override - String get compact => 'Kompaktowy'; - - @override - String get extended => 'Rozszerzony'; - - @override - String get theme => 'Motyw'; - - @override - String get dark => 'Ciemny'; - - @override - String get light => 'Jasny'; - - @override - String get system => 'Systemowy'; - - @override - String get accent_color => 'Kolor Akcentu'; - - @override - String get sync_album_color => 'Synchronizuj kolor albumu'; - - @override - String get sync_album_color_description => - 'Używa dominującego koloru okładki albumu jako koloru akcentującego'; - - @override - String get playback => 'Odtwarzanie'; - - @override - String get audio_quality => 'Jakość dźwięku'; - - @override - String get high => 'Duża'; - - @override - String get low => 'Mała'; - - @override - String get pre_download_play => 'Wstępnie pobierz i odtwórz'; - - @override - String get pre_download_play_description => - 'Zamiast przesyłać strumieniowo dźwięk, pobiera odpowiedni bufor i odtwarza (zalecane dla użytkowników o większej przepustowości)'; - - @override - String get skip_non_music => 'Pomiń nie-muzyczne segmenty (SponsorBlock)'; - - @override - String get blacklist_description => 'Czarna lista utworów i artystów'; - - @override - String get wait_for_download_to_finish => - 'Proszę poczekać na zakończenie obecnego pobierania.'; - - @override - String get desktop => 'Pulpit'; - - @override - String get close_behavior => 'Zamknij'; - - @override - String get close => 'Zamknij'; - - @override - String get minimize_to_tray => 'Zminimalizuj do zasobnika'; - - @override - String get show_tray_icon => 'Pokazuj ikonę w zasobniku'; - - @override - String get about => 'O projekcie'; - - @override - String get u_love_spotube => 'Wiemy jak kochacie Spotube'; - - @override - String get check_for_updates => 'Sprawdź aktualizacje'; - - @override - String get about_spotube => 'O Spotube'; - - @override - String get blacklist => 'Czarna lista'; - - @override - String get please_sponsor => 'Proszę wesprzyj projekt'; - - @override - String get spotube_description => - 'Spotube, lekki, wieloplatformowy, darmowy dla wszystkich klient Spotify'; - - @override - String get version => 'Wersja'; - - @override - String get build_number => 'Numer Build\'a'; - - @override - String get founder => 'Twórca Założyciel'; - - @override - String get repository => 'Repozytorium'; - - @override - String get bug_issues => 'Błędy i propozycje'; - - @override - String get made_with => 'Stworzono z ❤️ w Bangladesh\'u 🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Licencja'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Nie martw się, żadne dane logowania nie są zbierane ani udostępniane nikomu'; - - @override - String get know_how_to_login => 'Nie wiesz, jak się zalogować?'; - - @override - String get follow_step_by_step_guide => - 'Postępuj zgodnie z poradnikiem krok po kroku'; - - @override - String cookie_name_cookie(Object name) { - return '$name Ciasteczko'; - } - - @override - String get fill_in_all_fields => 'Proszę wypełnić wszystkie pola'; - - @override - String get submit => 'Zatwierdź'; - - @override - String get exit => 'Zamknij'; - - @override - String get previous => 'Poprzedni'; - - @override - String get next => 'Następny'; - - @override - String get done => 'Gotowe 🙂'; - - @override - String get step_1 => 'Krok 1'; - - @override - String get first_go_to => 'Po pierwsze przejdź do'; - - @override - String get something_went_wrong => 'Coś poszło nie tak 🙁'; - - @override - String get piped_instance => 'Instancja serwera Piped'; - - @override - String get piped_description => - 'Instancja serwera Piped używana jest do dopasowania utworów.'; - - @override - String get piped_warning => - 'Niektóre z nich mogą nie działać. Używasz na własną odpowiedzialność!'; - - @override - String get invidious_instance => 'Instancja serwera Invidious'; - - @override - String get invidious_description => - 'Instancja serwera Invidious do dopasowywania utworów'; - - @override - String get invidious_warning => - 'Niektóre z nich mogą nie działać dobrze. Używaj na własne ryzyko'; - - @override - String get generate => 'Generuj'; - - @override - String track_exists(Object track) { - return 'Utwór $track już istnieje'; - } - - @override - String get replace_downloaded_tracks => 'Zamień wszystkie pobrane utwory'; - - @override - String get skip_download_tracks => - 'Pomiń pobieranie wszystkich pobranych utworów'; - - @override - String get do_you_want_to_replace => 'Chcesz zamienić istniejący utwór ??'; - - @override - String get replace => 'Zamień'; - - @override - String get skip => 'Pomiń'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Wybierz do $count $type'; - } - - @override - String get select_genres => 'Wybierz Gatunki'; - - @override - String get add_genres => 'Dodaj Gatunki'; - - @override - String get country => 'Kraj'; - - @override - String get number_of_tracks_generate => 'Liczba utworów do wygenerowania'; - - @override - String get acousticness => 'Akustyczna'; - - @override - String get danceability => 'Taneczna'; - - @override - String get energy => 'Energiczna'; - - @override - String get instrumentalness => 'Instrumentalna'; - - @override - String get liveness => 'Żywa'; - - @override - String get loudness => 'Głośna'; - - @override - String get speechiness => 'Wymowna'; - - @override - String get valence => 'Wartościowa'; - - @override - String get popularity => 'Popularność'; - - @override - String get key => 'Kluczowa'; - - @override - String get duration => 'Długość (s)'; - - @override - String get tempo => 'Tempo (BPM)'; - - @override - String get mode => 'Tryb'; - - @override - String get time_signature => 'Sygnatura Czasowa'; - - @override - String get short => 'Krótka'; - - @override - String get medium => 'Średnia'; - - @override - String get long => 'Długa'; - - @override - String get min => 'Minimalnie'; - - @override - String get max => 'Maksymalnie'; - - @override - String get target => 'Cel'; - - @override - String get moderate => 'Umiarkowanie'; - - @override - String get deselect_all => 'Odznacz wszystkie'; - - @override - String get select_all => 'Zaznacz wszystkie'; - - @override - String get are_you_sure => 'Jesteś pewny?'; - - @override - String get generating_playlist => 'Generowanie twojej własnej playlisty...'; - - @override - String selected_count_tracks(Object count) { - return 'Wybrano $count utworów'; - } - - @override - String get download_warning => - 'Jeśli hurtowo pobierasz wszystkie utwory, wyraźnie piracisz muzykę i wyrządzasz szkody kreatywnej społeczności muzycznej. Mam nadzieję, że jesteś tego świadomy. Zawsze staraj się szanować i wspierać ciężką pracę Artysty'; - - @override - String get download_ip_ban_warning => - 'Przy okazji, Twój adres IP może zostać zablokowany w YouTube z powodu nadmiernych żądań pobierania niż zwykle. Blokada IP oznacza, że nie możesz korzystać z YouTube (nawet jeśli jesteś zalogowany) przez co najmniej 2-3 miesiące z IP tego urządzenia. Spotube nie ponosi żadnej odpowiedzialności, jeśli tak się stanie'; - - @override - String get by_clicking_accept_terms => - 'Klikając \'Akceptuj\' zgadzasz się z następującymi warunkami:'; - - @override - String get download_agreement_1 => 'Wiem, że piracę muzykę. Jestem zły.'; - - @override - String get download_agreement_2 => - 'Będę wspierał artystę i robię to tylko dlatego, że nie mam pieniędzy na albumy wykonawcy. '; - - @override - String get download_agreement_3 => - 'Jestem całkowicie świadomy, że moje IP może zostać zablokowane w YouTube i nie pociągam Spotube ani jego właścicieli/współtwórców do odpowiedzialności za jakiekolwiek wypadki spowodowane moimi obecnymi działaniami'; - - @override - String get decline => 'Odrzuć'; - - @override - String get accept => 'Akceptuj'; - - @override - String get details => 'Szczegóły'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Kanał'; - - @override - String get likes => 'Polubienia'; - - @override - String get dislikes => 'Nie lubi'; - - @override - String get views => 'Wyświetlenia'; - - @override - String get streamUrl => 'URL strumienia'; - - @override - String get stop => 'Stop'; - - @override - String get sort_newest => 'Sortuj według ostatnio dodanych'; - - @override - String get sort_oldest => 'Sortuj według najstarszych dodanych'; - - @override - String get sleep_timer => 'Minutnik'; - - @override - String mins(Object minutes) { - return '$minutes Minuty'; - } - - @override - String hours(Object hours) { - return '$hours Godziny'; - } - - @override - String hour(Object hours) { - return '$hours Godzina'; - } - - @override - String get custom_hours => 'Własne godziny'; - - @override - String get logs => 'Logi'; - - @override - String get developers => 'Developerzy'; - - @override - String get not_logged_in => 'Nie jesteś zalogowany'; - - @override - String get search_mode => 'Tryb szukania'; - - @override - String get audio_source => 'Źródło dźwięku'; - - @override - String get ok => 'Ok'; - - @override - String get failed_to_encrypt => 'Nie można zaszyfrować :('; - - @override - String get encryption_failed_warning => - 'Spotube używa szyfrowania do bezpiecznego przechowywania danych. Ale nie udało się tego zrobić. Więc powróci do niezabezpieczonego przechowywania\nJeśli używasz Linuksa, upewnij się, że masz zainstalowane jakieś usługi do szyfrowania (gnome-keyring, kde-wallet, keepassxc itp.)'; - - @override - String get querying_info => 'Szukam informacji...'; - - @override - String get piped_api_down => 'API Piped jest niedostępne'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'Instancja Piped $pipedInstance jest obecnie niedostępna\n\nZmień instancję lub zmień \'Rodzaj API\' na oficjalne API YouTube\n\nUpewnij się, że po zmianie zrestartujesz aplikację'; - } - - @override - String get you_are_offline => 'Obecnie jesteś offline'; - - @override - String get connection_restored => - 'Twoje połączenie z internetem zostało przywrócone'; - - @override - String get use_system_title_bar => 'Użyj paska tytułu systemu'; - - @override - String get crunching_results => 'Przetwarzanie wyników...'; - - @override - String get search_to_get_results => 'Szukaj, aby uzyskać wyniki'; - - @override - String get use_amoled_mode => 'Tryb AMOLED'; - - @override - String get pitch_dark_theme => 'Ciemny motyw'; - - @override - String get normalize_audio => 'Normalizuj dźwięk'; - - @override - String get change_cover => 'Zmień okładkę'; - - @override - String get add_cover => 'Dodaj okładkę'; - - @override - String get restore_defaults => 'Przywróć domyślne'; - - @override - String get download_music_format => 'Format pobierania muzyki'; - - @override - String get streaming_music_format => 'Format strumieniowania muzyki'; - - @override - String get download_music_quality => 'Jakość pobierania'; - - @override - String get streaming_music_quality => 'Jakość strumieniowania'; - - @override - String get login_with_lastfm => 'Zaloguj się z Last.fm'; - - @override - String get connect => 'Połącz'; - - @override - String get disconnect_lastfm => 'Rozłącz z Last.fm'; - - @override - String get disconnect => 'Rozłącz'; - - @override - String get username => 'Nazwa użytkownika'; - - @override - String get password => 'Hasło'; - - @override - String get login => 'Zaloguj'; - - @override - String get login_with_your_lastfm => 'Zaloguj się na swoje konto Last.fm'; - - @override - String get scrobble_to_lastfm => 'Scrobbluj do Last.fm'; - - @override - String get go_to_album => 'Przejdź do albumu'; - - @override - String get discord_rich_presence => 'Obecność na Discordzie'; - - @override - String get browse_all => 'Przeglądaj wszystko'; - - @override - String get genres => 'Gatunki muzyczne'; - - @override - String get explore_genres => 'Eksploruj gatunki'; - - @override - String get friends => 'Przyjaciele'; - - @override - String get no_lyrics_available => - 'Przepraszamy, nie można znaleźć tekstu dla tego utworu'; - - @override - String get start_a_radio => 'Uruchom radio'; - - @override - String get how_to_start_radio => 'Jak chcesz uruchomić radio?'; - - @override - String get replace_queue_question => - 'Czy chcesz zastąpić bieżącą kolejkę czy dodać do niej?'; - - @override - String get endless_playback => 'Nieskończona Odtwarzanie'; - - @override - String get delete_playlist => 'Usuń Playlistę'; - - @override - String get delete_playlist_confirmation => - 'Czy na pewno chcesz usunąć tę listę odtwarzania?'; - - @override - String get local_tracks => 'Lokalne Utwory'; - - @override - String get local_tab => 'Lokalny'; - - @override - String get song_link => 'Link do Utworu'; - - @override - String get skip_this_nonsense => 'Pomiń tę bzdurę'; - - @override - String get freedom_of_music => '“Wolność Muzyki”'; - - @override - String get freedom_of_music_palm => '“Wolność Muzyki w Twojej dłoni”'; - - @override - String get get_started => 'Zacznijmy'; - - @override - String get youtube_source_description => 'Polecane i działa najlepiej.'; - - @override - String get piped_source_description => - 'Czujesz się wolny? To samo co YouTube, ale dużo za darmo.'; - - @override - String get jiosaavn_source_description => - 'Najlepszy dla regionu Azji Południowej.'; - - @override - String get invidious_source_description => - 'Podobne do Piped, ale o wyższej dostępności.'; - - @override - String highest_quality(Object quality) { - return 'Najwyższa Jakość: $quality'; - } - - @override - String get select_audio_source => 'Wybierz Źródło Audio'; - - @override - String get endless_playback_description => - 'Automatycznie dodaj nowe utwory na koniec kolejki'; - - @override - String get choose_your_region => 'Wybierz swoją region'; - - @override - String get choose_your_region_description => - 'To pomoże Spotube pokazać Ci odpowiednią treść dla Twojej lokalizacji.'; - - @override - String get choose_your_language => 'Wybierz swój język'; - - @override - String get help_project_grow => 'Pomóż temu projektowi rosnąć'; - - @override - String get help_project_grow_description => - 'Spotube to projekt open-source. Możesz pomóc temu projektowi rosnąć, przyczyniając się do projektu, zgłaszając błędy lub sugerując nowe funkcje.'; - - @override - String get contribute_on_github => 'Przyczyniaj się na GitHubie'; - - @override - String get donate_on_open_collective => 'Dotuj na Open Collective'; - - @override - String get browse_anonymously => 'Przeglądaj Anonimowo'; - - @override - String get enable_connect => 'Włącz połączenie'; - - @override - String get enable_connect_description => - 'Kontroluj Spotube z innych urządzeń'; - - @override - String get devices => 'Urządzenia'; - - @override - String get select => 'Wybierz'; - - @override - String connect_client_alert(Object client) { - return 'Jesteś sterowany przez $client'; - } - - @override - String get this_device => 'To urządzenie'; - - @override - String get remote => 'Zdalny'; - - @override - String get stats => 'Statystyki'; - - @override - String and_n_more(Object count) { - return 'i $count więcej'; - } - - @override - String get recently_played => 'Ostatnio odtwarzane'; - - @override - String get browse_more => 'Zobacz więcej'; - - @override - String get no_title => 'Brak tytułu'; - - @override - String get not_playing => 'Nie odtwarzane'; - - @override - String get epic_failure => 'Epicka porażka!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'Dodano $tracks_length utworów do kolejki'; - } - - @override - String get spotube_has_an_update => 'Spotube ma aktualizację'; - - @override - String get download_now => 'Pobierz teraz'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum został wydany'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version został wydany'; - } - - @override - String get read_the_latest => 'Przeczytaj najnowsze '; - - @override - String get release_notes => 'notatki o wersji'; - - @override - String get pick_color_scheme => 'Wybierz schemat kolorów'; - - @override - String get save => 'Zapisz'; - - @override - String get choose_the_device => 'Wybierz urządzenie:'; - - @override - String get multiple_device_connected => - 'Jest wiele urządzeń podłączonych.\nWybierz urządzenie, na którym chcesz wykonać tę akcję'; - - @override - String get nothing_found => 'Nic nie znaleziono'; - - @override - String get the_box_is_empty => 'Pudełko jest puste'; - - @override - String get top_artists => 'Najlepsi artyści'; - - @override - String get top_albums => 'Najlepsze albumy'; - - @override - String get this_week => 'W tym tygodniu'; - - @override - String get this_month => 'W tym miesiącu'; - - @override - String get last_6_months => 'Ostatnie 6 miesięcy'; - - @override - String get this_year => 'W tym roku'; - - @override - String get last_2_years => 'Ostatnie 2 lata'; - - @override - String get all_time => 'Wszystkie czasy'; - - @override - String powered_by_provider(Object providerName) { - return 'Napędzane przez $providerName'; - } - - @override - String get email => 'E-mail'; - - @override - String get profile_followers => 'Obserwujący'; - - @override - String get birthday => 'Data urodzenia'; - - @override - String get subscription => 'Subskrypcja'; - - @override - String get not_born => 'Nie urodzony'; - - @override - String get hacker => 'Haker'; - - @override - String get profile => 'Profil'; - - @override - String get no_name => 'Brak nazwy'; - - @override - String get edit => 'Edytuj'; - - @override - String get user_profile => 'Profil użytkownika'; - - @override - String count_plays(Object count) { - return '$count odtworzeń'; - } - - @override - String get streaming_fees_hypothetical => - '*Obliczone na podstawie wypłaty Spotify za stream\nod \$0.003 do \$0.005. Jest to hipotetyczne\nobliczenie, które ma na celu pokazanie, ile\nużytkownik zapłaciłby artystom, gdyby odsłuchał\ntych utworów na Spotify.'; - - @override - String get minutes_listened => 'Minuty odsłuchane'; - - @override - String get streamed_songs => 'Strumieniowane utwory'; - - @override - String count_streams(Object count) { - return '$count strumieni'; - } - - @override - String get owned_by_you => 'Własność Twoja'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl skopiowano do schowka'; - } - - @override - String get hipotetical_calculation => - '*Jest to obliczone na podstawie średniej wypłaty z internetowych platform streamingowych za jeden stream w wysokości 0,003 do 0,005 USD. Jest to hipotetyczne obliczenie, które ma na celu dać użytkownikowi wgląd w to, ile zapłaciłby artystom, gdyby słuchał ich piosenek na różnych platformach streamingowych.'; - - @override - String count_mins(Object minutes) { - return '$minutes min'; - } - - @override - String get summary_minutes => 'minuty'; - - @override - String get summary_listened_to_music => 'Słuchana muzyka'; - - @override - String get summary_songs => 'utwory'; - - @override - String get summary_streamed_overall => 'Ogółem streamowane'; - - @override - String get summary_owed_to_artists => 'Do zapłaty artystom\nw tym miesiącu'; - - @override - String get summary_artists => 'artystów'; - - @override - String get summary_music_reached_you => 'Muzyka dotarła do Ciebie'; - - @override - String get summary_full_albums => 'pełne albumy'; - - @override - String get summary_got_your_love => 'Otrzymał Twoją miłość'; - - @override - String get summary_playlists => 'playlisty'; - - @override - String get summary_were_on_repeat => 'Były na powtarzaniu'; - - @override - String total_money(Object money) { - return 'Łącznie $money'; - } - - @override - String get webview_not_found => 'Nie znaleziono Webview'; - - @override - String get webview_not_found_description => - 'Na twoim urządzeniu nie zainstalowano środowiska uruchomieniowego Webview.\nJeśli jest zainstalowany, upewnij się, że jest w environment PATH\n\nPo instalacji uruchom ponownie aplikację'; - - @override - String get unsupported_platform => 'Nieobsługiwana platforma'; - - @override - String get cache_music => 'Pamięć podręczna muzyki'; - - @override - String get open => 'Otwórz'; - - @override - String get cache_folder => 'Folder pamięci podręcznej'; - - @override - String get export => 'Eksportuj'; - - @override - String get clear_cache => 'Wyczyść pamięć podręczną'; - - @override - String get clear_cache_confirmation => - 'Czy chcesz wyczyścić pamięć podręczną?'; - - @override - String get export_cache_files => 'Eksportuj pliki z pamięci podręcznej'; - - @override - String found_n_files(Object count) { - return 'Znaleziono $count plików'; - } - - @override - String get export_cache_confirmation => - 'Czy chcesz wyeksportować te pliki do'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return 'Wyeksportowano $filesExported z $files plików'; - } - - @override - String get undo => 'Cofnij'; - - @override - String get download_all => 'Pobierz wszystko'; - - @override - String get add_all_to_playlist => 'Dodaj wszystko do playlisty'; - - @override - String get add_all_to_queue => 'Dodaj wszystko do kolejki'; - - @override - String get play_all_next => 'Odtwórz wszystko następnie'; - - @override - String get pause => 'Pauza'; - - @override - String get view_all => 'Zobacz wszystko'; - - @override - String get no_tracks_added_yet => - 'Wygląda na to, że jeszcze nie dodałeś żadnych utworów'; - - @override - String get no_tracks => 'Wygląda na to, że tutaj nie ma żadnych utworów'; - - @override - String get no_tracks_listened_yet => - 'Wygląda na to, że jeszcze nic nie słuchałeś'; - - @override - String get not_following_artists => 'Nie obserwujesz żadnych artystów'; - - @override - String get no_favorite_albums_yet => - 'Wygląda na to, że jeszcze nie dodałeś żadnych albumów do ulubionych'; - - @override - String get no_logs_found => 'Nie znaleziono żadnych logów'; - - @override - String get youtube_engine => 'Silnik YouTube'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine nie jest zainstalowany'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine nie jest zainstalowany w systemie.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Upewnij się, że jest dostępny w zmiennej PATH lub\nustaw absolutną ścieżkę do pliku wykonywalnego $engine poniżej'; - } - - @override - String get youtube_engine_unix_issue_message => - 'W systemach macOS/Linux/unix, ustawianie ścieżki w .zshrc/.bashrc/.bash_profile itp. nie będzie działać.\nMusisz ustawić ścieżkę w pliku konfiguracyjnym powłoki'; - - @override - String get download => 'Pobierz'; - - @override - String get file_not_found => 'Plik nie znaleziony'; - - @override - String get custom => 'Niestandardowy'; - - @override - String get add_custom_url => 'Dodaj niestandardowy URL'; - - @override - String get edit_port => 'Edytuj port'; - - @override - String get port_helper_msg => - 'Domyślna wartość to -1, co oznacza losową liczbę. Jeśli masz skonfigurowany zaporę, zaleca się jej ustawienie.'; - - @override - String connect_request(Object client) { - return 'Zezwolić $client na połączenie?'; - } - - @override - String get connection_request_denied => - 'Połączenie odrzucone. Użytkownik odmówił dostępu.'; - - @override - String get an_error_occurred => 'Wystąpił błąd'; - - @override - String get copy_to_clipboard => 'Kopiuj do schowka'; - - @override - String get view_logs => 'Wyświetl logi'; - - @override - String get retry => 'Ponów'; - - @override - String get no_default_metadata_provider_selected => - 'Nie masz ustawionego domyślnego dostawcy metadanych'; - - @override - String get manage_metadata_providers => 'Zarządzaj dostawcami metadanych'; - - @override - String get open_link_in_browser => 'Otworzyć link w przeglądarce?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Czy chcesz otworzyć następujący link'; - - @override - String get unsafe_url_warning => - 'Otwieranie linków z niezaufanych źródeł może być niebezpieczne. Zachowaj ostrożność!\nMożesz również skopiować link do schowka.'; - - @override - String get copy_link => 'Kopiuj link'; - - @override - String get building_your_timeline => - 'Budowanie Twojej osi czasu na podstawie Twoich odsłuchań...'; - - @override - String get official => 'Oficjalny'; - - @override - String author_name(Object author) { - return 'Autor: $author'; - } - - @override - String get third_party => 'Zewnętrzny'; - - @override - String get plugin_requires_authentication => - 'Wtyczka wymaga uwierzytelnienia'; - - @override - String get update_available => 'Dostępna aktualizacja'; - - @override - String get supports_scrobbling => 'Obsługuje scrobbling'; - - @override - String get plugin_scrobbling_info => - 'Ta wtyczka scrobbluje Twoją muzykę, aby wygenerować historię odsłuchań.'; - - @override - String get default_metadata_source => 'Domyślne źródło metadanych'; - - @override - String get set_default_metadata_source => 'Ustaw domyślne źródło metadanych'; - - @override - String get default_audio_source => 'Domyślne źródło audio'; - - @override - String get set_default_audio_source => 'Ustaw domyślne źródło audio'; - - @override - String get set_default => 'Ustaw jako domyślną'; - - @override - String get support => 'Wsparcie'; - - @override - String get support_plugin_development => 'Wspieraj rozwój wtyczki'; - - @override - String can_access_name_api(Object name) { - return '- Może uzyskać dostęp do API **$name**'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Czy chcesz zainstalować tę wtyczkę?'; - - @override - String get third_party_plugin_warning => - 'Ta wtyczka pochodzi z zewnętrznego repozytorium. Upewnij się, że ufasz źródłu przed instalacją.'; - - @override - String get author => 'Autor'; - - @override - String get this_plugin_can_do_following => - 'Ta wtyczka może wykonywać następujące czynności'; - - @override - String get install => 'Instaluj'; - - @override - String get install_a_metadata_provider => 'Zainstaluj dostawcę metadanych'; - - @override - String get no_tracks_playing => 'Obecnie nie odtwarzany jest żaden utwór'; - - @override - String get synced_lyrics_not_available => - 'Zsynchronizowane teksty nie są dostępne dla tego utworu. Zamiast tego użyj zakładki'; - - @override - String get plain_lyrics => 'Zwykłe teksty'; - - @override - String get tab_instead => 'zamiast tego.'; - - @override - String get disclaimer => 'Zastrzeżenie'; - - @override - String get third_party_plugin_dmca_notice => - 'Zespół Spotube nie ponosi żadnej odpowiedzialności (w tym prawnej) za żadne wtyczki \"zewnętrzne\".\nUżywaj ich na własne ryzyko. Wszelkie błędy/problemy prosimy zgłaszać w repozytorium wtyczki.\n\nJeśli jakakolwiek wtyczka \"zewnętrzna\" narusza ToS/DMCA jakiejkolwiek usługi/podmiotu prawnego, prosimy o kontakt z autorem wtyczki \"zewnętrznej\" lub platformą hostingową, np. GitHub/Codeberg, w celu podjęcia działań. Wymienione powyżej (oznaczone jako \"zewnętrzne\") są publicznymi wtyczkami utrzymywanymi przez społeczność. Nie kuratujemy ich, więc nie możemy podjąć żadnych działań w ich sprawie.\n\n'; - - @override - String get input_does_not_match_format => - 'Wprowadzony tekst nie pasuje do wymaganego formatu'; - - @override - String get plugins => 'Wtyczki'; - - @override - String get paste_plugin_download_url => - 'Wklej adres URL do pobrania lub adres URL repozytorium GitHub/Codeberg lub bezpośredni link do pliku .smplug'; - - @override - String get download_and_install_plugin_from_url => - 'Pobierz i zainstaluj wtyczkę z adresu URL'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Nie udało się dodać wtyczki: $error'; - } - - @override - String get upload_plugin_from_file => 'Prześlij wtyczkę z pliku'; - - @override - String get installed => 'Zainstalowane'; - - @override - String get available_plugins => 'Dostępne wtyczki'; - - @override - String get configure_plugins => - 'Skonfiguruj własne wtyczki dostawców metadanych i źródeł audio'; - - @override - String get audio_scrobblers => 'Scrobblery audio'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Źródło: '; - - @override - String get uncompressed => 'Nieskompresowany'; - - @override - String get dab_music_source_description => - 'Dla audiofilów. Oferuje strumienie audio wysokiej jakości/lossless. Precyzyjne dopasowanie utworów na podstawie ISRC.'; -} diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart deleted file mode 100644 index 8d2eabe7..00000000 --- a/lib/l10n/generated/app_localizations_pt.dart +++ /dev/null @@ -1,1569 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Portuguese (`pt`). -class AppLocalizationsPt extends AppLocalizations { - AppLocalizationsPt([String locale = 'pt']) : super(locale); - - @override - String get guest => 'Visitante'; - - @override - String get browse => 'Explorar'; - - @override - String get search => 'Buscar'; - - @override - String get library => 'Biblioteca'; - - @override - String get lyrics => 'Letras'; - - @override - String get settings => 'Configurações'; - - @override - String get genre_categories_filter => 'Filtrar categorias ou gêneros...'; - - @override - String get genre => 'Gênero'; - - @override - String get personalized => 'Personalizado'; - - @override - String get featured => 'Destaque'; - - @override - String get new_releases => 'Novos Lançamentos'; - - @override - String get songs => 'Músicas'; - - @override - String playing_track(Object track) { - return 'Tocando $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Isso irá limpar a fila atual. $track_length músicas serão removidas.\nDeseja continuar?'; - } - - @override - String get load_more => 'Carregar mais'; - - @override - String get playlists => 'Playlists'; - - @override - String get artists => 'Artistas'; - - @override - String get albums => 'Álbuns'; - - @override - String get tracks => 'Faixas'; - - @override - String get downloads => 'Downloads'; - - @override - String get filter_playlists => 'Filtrar suas playlists...'; - - @override - String get liked_tracks => 'Músicas Curtidas'; - - @override - String get liked_tracks_description => 'Todas as suas músicas curtidas'; - - @override - String get playlist => 'Playlist'; - - @override - String get create_a_playlist => 'Criar uma playlist'; - - @override - String get update_playlist => 'Atualizar lista de reprodução'; - - @override - String get create => 'Criar'; - - @override - String get cancel => 'Cancelar'; - - @override - String get update => 'Atualizar'; - - @override - String get playlist_name => 'Nome da Playlist'; - - @override - String get name_of_playlist => 'Nome da playlist'; - - @override - String get description => 'Descrição'; - - @override - String get public => 'Pública'; - - @override - String get collaborative => 'Colaborativa'; - - @override - String get search_local_tracks => 'Buscar músicas locais...'; - - @override - String get play => 'Reproduzir'; - - @override - String get delete => 'Excluir'; - - @override - String get none => 'Nenhum'; - - @override - String get sort_a_z => 'Ordenar de A-Z'; - - @override - String get sort_z_a => 'Ordenar de Z-A'; - - @override - String get sort_artist => 'Ordenar por Artista'; - - @override - String get sort_album => 'Ordenar por Álbum'; - - @override - String get sort_duration => 'Ordenar por Duração'; - - @override - String get sort_tracks => 'Ordenar Faixas'; - - @override - String currently_downloading(Object tracks_length) { - return 'Baixando no momento ($tracks_length)'; - } - - @override - String get cancel_all => 'Cancelar Tudo'; - - @override - String get filter_artist => 'Filtrar artistas...'; - - @override - String followers(Object followers) { - return '$followers Seguidores'; - } - - @override - String get add_artist_to_blacklist => 'Adicionar artista à lista negra'; - - @override - String get top_tracks => 'Principais Músicas'; - - @override - String get fans_also_like => 'Fãs também curtiram'; - - @override - String get loading => 'Carregando...'; - - @override - String get artist => 'Artista'; - - @override - String get blacklisted => 'Na Lista Negra'; - - @override - String get following => 'Seguindo'; - - @override - String get follow => 'Seguir'; - - @override - String get artist_url_copied => - 'URL do artista copiada para a área de transferência'; - - @override - String added_to_queue(Object tracks) { - return 'Adicionadas $tracks músicas à fila'; - } - - @override - String get filter_albums => 'Filtrar álbuns...'; - - @override - String get synced => 'Sincronizado'; - - @override - String get plain => 'Simples'; - - @override - String get shuffle => 'Aleatório'; - - @override - String get search_tracks => 'Buscar músicas...'; - - @override - String get released => 'Lançado'; - - @override - String error(Object error) { - return 'Erro $error'; - } - - @override - String get title => 'Título'; - - @override - String get time => 'Tempo'; - - @override - String get more_actions => 'Mais ações'; - - @override - String download_count(Object count) { - return 'Baixar ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Adicionar ($count) à Playlist'; - } - - @override - String add_count_to_queue(Object count) { - return 'Adicionar ($count) à Fila'; - } - - @override - String play_count_next(Object count) { - return 'Reproduzir ($count) em seguida'; - } - - @override - String get album => 'Álbum'; - - @override - String copied_to_clipboard(Object data) { - return '$data copiado para a área de transferência'; - } - - @override - String add_to_following_playlists(Object track) { - return 'Adicionar $track às Playlists Seguintes'; - } - - @override - String get add => 'Adicionar'; - - @override - String added_track_to_queue(Object track) { - return 'Adicionada $track à fila'; - } - - @override - String get add_to_queue => 'Adicionar à fila'; - - @override - String track_will_play_next(Object track) { - return '$track será reproduzida em seguida'; - } - - @override - String get play_next => 'Reproduzir em seguida'; - - @override - String removed_track_from_queue(Object track) { - return '$track removida da fila'; - } - - @override - String get remove_from_queue => 'Remover da fila'; - - @override - String get remove_from_favorites => 'Remover dos favoritos'; - - @override - String get save_as_favorite => 'Salvar como favorita'; - - @override - String get add_to_playlist => 'Adicionar à playlist'; - - @override - String get remove_from_playlist => 'Remover da playlist'; - - @override - String get add_to_blacklist => 'Adicionar à lista negra'; - - @override - String get remove_from_blacklist => 'Remover da lista negra'; - - @override - String get share => 'Compartilhar'; - - @override - String get mini_player => 'Mini Player'; - - @override - String get slide_to_seek => 'Arraste para avançar ou retroceder'; - - @override - String get shuffle_playlist => 'Embaralhar playlist'; - - @override - String get unshuffle_playlist => 'Desembaralhar playlist'; - - @override - String get previous_track => 'Faixa anterior'; - - @override - String get next_track => 'Próxima faixa'; - - @override - String get pause_playback => 'Pausar Reprodução'; - - @override - String get resume_playback => 'Continuar Reprodução'; - - @override - String get loop_track => 'Repetir faixa'; - - @override - String get no_loop => 'Sem loop'; - - @override - String get repeat_playlist => 'Repetir playlist'; - - @override - String get queue => 'Fila'; - - @override - String get alternative_track_sources => 'Fontes alternativas de faixas'; - - @override - String get download_track => 'Baixar faixa'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks músicas na fila'; - } - - @override - String get clear_all => 'Limpar tudo'; - - @override - String get show_hide_ui_on_hover => 'Mostrar/Ocultar UI ao passar o mouse'; - - @override - String get always_on_top => 'Sempre no topo'; - - @override - String get exit_mini_player => 'Sair do Mini player'; - - @override - String get download_location => 'Local de download'; - - @override - String get local_library => 'Biblioteca local'; - - @override - String get add_library_location => 'Adicionar à biblioteca'; - - @override - String get remove_library_location => 'Remover da biblioteca'; - - @override - String get account => 'Conta'; - - @override - String get logout => 'Sair'; - - @override - String get logout_of_this_account => 'Sair desta conta'; - - @override - String get language_region => 'Idioma e Região'; - - @override - String get language => 'Idioma'; - - @override - String get system_default => 'Padrão do Sistema'; - - @override - String get market_place_region => 'Região da Loja'; - - @override - String get recommendation_country => 'País de Recomendação'; - - @override - String get appearance => 'Aparência'; - - @override - String get layout_mode => 'Modo de Layout'; - - @override - String get override_layout_settings => - 'Substituir configurações do modo de layout responsivo'; - - @override - String get adaptive => 'Adaptável'; - - @override - String get compact => 'Compacto'; - - @override - String get extended => 'Estendido'; - - @override - String get theme => 'Tema'; - - @override - String get dark => 'Escuro'; - - @override - String get light => 'Claro'; - - @override - String get system => 'Sistema'; - - @override - String get accent_color => 'Cor de Destaque'; - - @override - String get sync_album_color => 'Sincronizar cor do álbum'; - - @override - String get sync_album_color_description => - 'Usa a cor predominante da capa do álbum como cor de destaque'; - - @override - String get playback => 'Reprodução'; - - @override - String get audio_quality => 'Qualidade do Áudio'; - - @override - String get high => 'Alta'; - - @override - String get low => 'Baixa'; - - @override - String get pre_download_play => 'Pré-download e reprodução'; - - @override - String get pre_download_play_description => - 'Em vez de transmitir áudio, baixar bytes e reproduzir (recomendado para usuários com maior largura de banda)'; - - @override - String get skip_non_music => 'Pular segmentos não musicais (SponsorBlock)'; - - @override - String get blacklist_description => 'Faixas e artistas na lista negra'; - - @override - String get wait_for_download_to_finish => - 'Aguarde o download atual ser concluído'; - - @override - String get desktop => 'Desktop'; - - @override - String get close_behavior => 'Comportamento de Fechamento'; - - @override - String get close => 'Fechar'; - - @override - String get minimize_to_tray => 'Minimizar para a bandeja'; - - @override - String get show_tray_icon => 'Mostrar ícone na bandeja do sistema'; - - @override - String get about => 'Sobre'; - - @override - String get u_love_spotube => 'Sabemos que você adora o Spotube'; - - @override - String get check_for_updates => 'Verificar atualizações'; - - @override - String get about_spotube => 'Sobre o Spotube'; - - @override - String get blacklist => 'Lista Negra'; - - @override - String get please_sponsor => 'Por favor, patrocine/doe'; - - @override - String get spotube_description => - 'Spotube, um cliente leve, multiplataforma e gratuito para o Spotify'; - - @override - String get version => 'Versão'; - - @override - String get build_number => 'Número de Build'; - - @override - String get founder => 'Fundador'; - - @override - String get repository => 'Repositório'; - - @override - String get bug_issues => 'Bugs/Problemas'; - - @override - String get made_with => 'Feito com ❤️ em Bangladesh🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Licença'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Não se preocupe, suas credenciais não serão coletadas nem compartilhadas com ninguém'; - - @override - String get know_how_to_login => 'Não sabe como fazer isso?'; - - @override - String get follow_step_by_step_guide => 'Siga o guia passo a passo'; - - @override - String cookie_name_cookie(Object name) { - return 'Cookie $name'; - } - - @override - String get fill_in_all_fields => 'Preencha todos os campos, por favor'; - - @override - String get submit => 'Enviar'; - - @override - String get exit => 'Sair'; - - @override - String get previous => 'Anterior'; - - @override - String get next => 'Próximo'; - - @override - String get done => 'Concluído'; - - @override - String get step_1 => 'Passo 1'; - - @override - String get first_go_to => 'Primeiro, vá para'; - - @override - String get something_went_wrong => 'Algo deu errado'; - - @override - String get piped_instance => 'Instância do Servidor Piped'; - - @override - String get piped_description => - 'A instância do servidor Piped a ser usada para correspondência de faixas'; - - @override - String get piped_warning => - 'Algumas delas podem não funcionar bem. Use por sua conta e risco'; - - @override - String get invidious_instance => 'Instância do Servidor Invidious'; - - @override - String get invidious_description => - 'A instância do servidor Invidious a ser usada para correspondência de faixas'; - - @override - String get invidious_warning => - 'Alguns podem não funcionar bem. Use por sua conta e risco'; - - @override - String get generate => 'Gerar'; - - @override - String track_exists(Object track) { - return 'A faixa $track já existe'; - } - - @override - String get replace_downloaded_tracks => 'Substituir todas as faixas baixadas'; - - @override - String get skip_download_tracks => - 'Pular o download de todas as faixas baixadas'; - - @override - String get do_you_want_to_replace => 'Deseja substituir a faixa existente?'; - - @override - String get replace => 'Substituir'; - - @override - String get skip => 'Pular'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Selecione até $count $type'; - } - - @override - String get select_genres => 'Selecionar Gêneros'; - - @override - String get add_genres => 'Adicionar Gêneros'; - - @override - String get country => 'País'; - - @override - String get number_of_tracks_generate => 'Número de faixas a gerar'; - - @override - String get acousticness => 'Acústica'; - - @override - String get danceability => 'Dançabilidade'; - - @override - String get energy => 'Energia'; - - @override - String get instrumentalness => 'Instrumentalidade'; - - @override - String get liveness => 'Vivacidade'; - - @override - String get loudness => 'Volume'; - - @override - String get speechiness => 'Discurso'; - - @override - String get valence => 'Valência'; - - @override - String get popularity => 'Popularidade'; - - @override - String get key => 'Tonalidade'; - - @override - String get duration => 'Duração (s)'; - - @override - String get tempo => 'Tempo (BPM)'; - - @override - String get mode => 'Modo'; - - @override - String get time_signature => 'Assinatura de tempo'; - - @override - String get short => 'Curto'; - - @override - String get medium => 'Médio'; - - @override - String get long => 'Longo'; - - @override - String get min => 'Min'; - - @override - String get max => 'Máx'; - - @override - String get target => 'Alvo'; - - @override - String get moderate => 'Moderado'; - - @override - String get deselect_all => 'Desmarcar Todos'; - - @override - String get select_all => 'Selecionar Todos'; - - @override - String get are_you_sure => 'Tem certeza?'; - - @override - String get generating_playlist => 'Gerando sua playlist personalizada...'; - - @override - String selected_count_tracks(Object count) { - return '$count faixas selecionadas'; - } - - @override - String get download_warning => - 'Se você baixar todas as faixas em massa, estará claramente pirateando música e causando danos à sociedade criativa da música. Espero que você esteja ciente disso. Sempre tente respeitar e apoiar o trabalho árduo dos artistas'; - - @override - String get download_ip_ban_warning => - 'Além disso, seu IP pode ser bloqueado no YouTube devido a solicitações de download excessivas. O bloqueio de IP significa que você não poderá usar o YouTube (mesmo se estiver conectado) por pelo menos 2-3 meses a partir do dispositivo IP. E o Spotube não se responsabiliza se isso acontecer'; - - @override - String get by_clicking_accept_terms => - 'Ao clicar em \'aceitar\', você concorda com os seguintes termos:'; - - @override - String get download_agreement_1 => - 'Eu sei que estou pirateando música. Sou mau'; - - @override - String get download_agreement_2 => - 'Vou apoiar o artista onde puder e estou fazendo isso porque não tenho dinheiro para comprar sua arte'; - - @override - String get download_agreement_3 => - 'Estou completamente ciente de que meu IP pode ser bloqueado no YouTube e não responsabilizo o Spotube ou seus proprietários/colaboradores por quaisquer acidentes causados pela minha ação atual'; - - @override - String get decline => 'Recusar'; - - @override - String get accept => 'Aceitar'; - - @override - String get details => 'Detalhes'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Canal'; - - @override - String get likes => 'Curtidas'; - - @override - String get dislikes => 'Descurtidas'; - - @override - String get views => 'Visualizações'; - - @override - String get streamUrl => 'URL do Stream'; - - @override - String get stop => 'Parar'; - - @override - String get sort_newest => 'Ordenar por mais recente adicionado'; - - @override - String get sort_oldest => 'Ordenar por mais antigo adicionado'; - - @override - String get sleep_timer => 'Temporizador de Sono'; - - @override - String mins(Object minutes) { - return '$minutes Minutos'; - } - - @override - String hours(Object hours) { - return '$hours Horas'; - } - - @override - String hour(Object hours) { - return '$hours Hora'; - } - - @override - String get custom_hours => 'Horas Personalizadas'; - - @override - String get logs => 'Registros'; - - @override - String get developers => 'Desenvolvedores'; - - @override - String get not_logged_in => 'Você não está logado'; - - @override - String get search_mode => 'Modo de Busca'; - - @override - String get audio_source => 'Fonte de Áudio'; - - @override - String get ok => 'Ok'; - - @override - String get failed_to_encrypt => 'Falha ao criptografar'; - - @override - String get encryption_failed_warning => - 'O Spotube usa criptografia para armazenar seus dados com segurança, mas falhou em fazê-lo. Portanto, ele voltará para o armazenamento não seguro.\nSe você estiver usando o Linux, certifique-se de ter algum serviço secreto (gnome-keyring, kde-wallet, keepassxc, etc.) instalado'; - - @override - String get querying_info => 'Consultando informações...'; - - @override - String get piped_api_down => 'A API do Piped está indisponível'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'A instância do Piped $pipedInstance está atualmente indisponível\n\nMude a instância ou mude o \'Tipo de API\' para a API oficial do YouTube\n\nCertifique-se de reiniciar o aplicativo após a alteração'; - } - - @override - String get you_are_offline => 'Você está offline no momento'; - - @override - String get connection_restored => 'Sua conexão com a internet foi restaurada'; - - @override - String get use_system_title_bar => 'Usar a barra de título do sistema'; - - @override - String get crunching_results => 'Processando resultados...'; - - @override - String get search_to_get_results => 'Pesquisar para obter resultados'; - - @override - String get use_amoled_mode => 'Modo AMOLED'; - - @override - String get pitch_dark_theme => 'Tema escuro'; - - @override - String get normalize_audio => 'Normalizar áudio'; - - @override - String get change_cover => 'Alterar capa'; - - @override - String get add_cover => 'Adicionar capa'; - - @override - String get restore_defaults => 'Restaurar padrões'; - - @override - String get download_music_format => 'Formato de download de música'; - - @override - String get streaming_music_format => 'Formato de streaming de música'; - - @override - String get download_music_quality => 'Qualidade de download'; - - @override - String get streaming_music_quality => 'Qualidade de streaming'; - - @override - String get login_with_lastfm => 'Iniciar sessão com o Last.fm'; - - @override - String get connect => 'Ligar'; - - @override - String get disconnect_lastfm => 'Desligar do Last.fm'; - - @override - String get disconnect => 'Desligar'; - - @override - String get username => 'Nome de utilizador'; - - @override - String get password => 'Palavra-passe'; - - @override - String get login => 'Iniciar sessão'; - - @override - String get login_with_your_lastfm => 'Inicie sessão na sua conta Last.fm'; - - @override - String get scrobble_to_lastfm => 'Scrobble para o Last.fm'; - - @override - String get go_to_album => 'Ir para o álbum'; - - @override - String get discord_rich_presence => 'Presença rica no Discord'; - - @override - String get browse_all => 'Navegar por tudo'; - - @override - String get genres => 'Gêneros'; - - @override - String get explore_genres => 'Explorar gêneros'; - - @override - String get friends => 'Amigos'; - - @override - String get no_lyrics_available => - 'Desculpe, não foi possível encontrar a letra desta faixa'; - - @override - String get start_a_radio => 'Iniciar uma Rádio'; - - @override - String get how_to_start_radio => 'Como você deseja iniciar a rádio?'; - - @override - String get replace_queue_question => - 'Você deseja substituir a fila atual ou acrescentar a ela?'; - - @override - String get endless_playback => 'Reprodução sem fim'; - - @override - String get delete_playlist => 'Excluir Lista de Reprodução'; - - @override - String get delete_playlist_confirmation => - 'Tem certeza de que deseja excluir esta lista de reprodução?'; - - @override - String get local_tracks => 'Faixas Locais'; - - @override - String get local_tab => 'Local'; - - @override - String get song_link => 'Link da Música'; - - @override - String get skip_this_nonsense => 'Pular essa bobagem'; - - @override - String get freedom_of_music => '“Liberdade da Música”'; - - @override - String get freedom_of_music_palm => - '“Liberdade da Música na palma da sua mão”'; - - @override - String get get_started => 'Vamos começar'; - - @override - String get youtube_source_description => 'Recomendado e funciona melhor.'; - - @override - String get piped_source_description => - 'Sentindo-se livre? Igual ao YouTube, mas muito mais grátis.'; - - @override - String get jiosaavn_source_description => - 'Melhor para a região da Ásia do Sul.'; - - @override - String get invidious_source_description => - 'Semelhante ao Piped, mas com maior disponibilidade.'; - - @override - String highest_quality(Object quality) { - return 'Melhor Qualidade: $quality'; - } - - @override - String get select_audio_source => 'Selecionar Fonte de Áudio'; - - @override - String get endless_playback_description => - 'Adicionar automaticamente novas músicas\nao final da fila'; - - @override - String get choose_your_region => 'Escolha sua região'; - - @override - String get choose_your_region_description => - 'Isso ajudará o Spotube a mostrar o conteúdo certo\npara sua localização.'; - - @override - String get choose_your_language => 'Escolha seu idioma'; - - @override - String get help_project_grow => 'Ajude este projeto a crescer'; - - @override - String get help_project_grow_description => - 'Spotube é um projeto de código aberto. Você pode ajudar este projeto a crescer contribuindo para o projeto, relatando bugs ou sugerindo novos recursos.'; - - @override - String get contribute_on_github => 'Contribuir no GitHub'; - - @override - String get donate_on_open_collective => 'Doar no Open Collective'; - - @override - String get browse_anonymously => 'Navegar Anonimamente'; - - @override - String get enable_connect => 'Ativar conexão'; - - @override - String get enable_connect_description => - 'Controle o Spotube a partir de outros dispositivos'; - - @override - String get devices => 'Dispositivos'; - - @override - String get select => 'Selecionar'; - - @override - String connect_client_alert(Object client) { - return 'Você está sendo controlado por $client'; - } - - @override - String get this_device => 'Este dispositivo'; - - @override - String get remote => 'Remoto'; - - @override - String get stats => 'Estatísticas'; - - @override - String and_n_more(Object count) { - return 'e $count mais'; - } - - @override - String get recently_played => 'Reproduzido Recentemente'; - - @override - String get browse_more => 'Ver Mais'; - - @override - String get no_title => 'Sem Título'; - - @override - String get not_playing => 'Não está a reproduzir'; - - @override - String get epic_failure => 'Fracasso épico!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'Adicionados $tracks_length faixas à fila'; - } - - @override - String get spotube_has_an_update => 'Spotube tem uma atualização'; - - @override - String get download_now => 'Baixar Agora'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum foi lançado'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version foi lançado'; - } - - @override - String get read_the_latest => 'Leia o mais recente '; - - @override - String get release_notes => 'notas de versão'; - - @override - String get pick_color_scheme => 'Escolha o esquema de cores'; - - @override - String get save => 'Salvar'; - - @override - String get choose_the_device => 'Escolha o dispositivo:'; - - @override - String get multiple_device_connected => - 'Há vários dispositivos conectados.\nEscolha o dispositivo no qual deseja executar esta ação'; - - @override - String get nothing_found => 'Nada encontrado'; - - @override - String get the_box_is_empty => 'A caixa está vazia'; - - @override - String get top_artists => 'Principais Artistas'; - - @override - String get top_albums => 'Principais Álbuns'; - - @override - String get this_week => 'Esta semana'; - - @override - String get this_month => 'Este mês'; - - @override - String get last_6_months => 'Últimos 6 meses'; - - @override - String get this_year => 'Este ano'; - - @override - String get last_2_years => 'Últimos 2 anos'; - - @override - String get all_time => 'De todos os tempos'; - - @override - String powered_by_provider(Object providerName) { - return 'Desenvolvido por $providerName'; - } - - @override - String get email => 'E-mail'; - - @override - String get profile_followers => 'Seguidores'; - - @override - String get birthday => 'Aniversário'; - - @override - String get subscription => 'Assinatura'; - - @override - String get not_born => 'Não nascido'; - - @override - String get hacker => 'Hacker'; - - @override - String get profile => 'Perfil'; - - @override - String get no_name => 'Sem Nome'; - - @override - String get edit => 'Editar'; - - @override - String get user_profile => 'Perfil do Usuário'; - - @override - String count_plays(Object count) { - return '$count reproduzidos'; - } - - @override - String get streaming_fees_hypothetical => - '*Calculado com base no pagamento por stream do Spotify\nque varia de \$0.003 a \$0.005. Isso é um cálculo hipotético\npara fornecer uma visão ao usuário sobre quanto eles\nteriam pago aos artistas se estivessem ouvindo\no seu som no Spotify.'; - - @override - String get minutes_listened => 'Minutos ouvidos'; - - @override - String get streamed_songs => 'Músicas transmitidas'; - - @override - String count_streams(Object count) { - return '$count streams'; - } - - @override - String get owned_by_you => 'De sua propriedade'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl copiado para a área de transferência'; - } - - @override - String get hipotetical_calculation => - '*Isso é calculado com base no pagamento médio por stream de plataformas de streaming de música online de US\$ 0,003 a US\$ 0,005. Esta é uma estimativa hipotética para dar ao usuário uma ideia de quanto ele teria pago aos artistas se ouvisse sua música em diferentes plataformas de streaming de música.'; - - @override - String count_mins(Object minutes) { - return '$minutes min'; - } - - @override - String get summary_minutes => 'minutos'; - - @override - String get summary_listened_to_music => 'Música ouvida'; - - @override - String get summary_songs => 'faixas'; - - @override - String get summary_streamed_overall => 'Total de streams'; - - @override - String get summary_owed_to_artists => 'Devido aos artistas\neste mês'; - - @override - String get summary_artists => 'artista'; - - @override - String get summary_music_reached_you => 'A música chegou até você'; - - @override - String get summary_full_albums => 'álbuns completos'; - - @override - String get summary_got_your_love => 'Recebeu seu amor'; - - @override - String get summary_playlists => 'playlists'; - - @override - String get summary_were_on_repeat => 'Estavam em repetição'; - - @override - String total_money(Object money) { - return 'Total $money'; - } - - @override - String get webview_not_found => 'Webview não encontrado'; - - @override - String get webview_not_found_description => - 'Nenhum runtime Webview está instalado no seu dispositivo.\nSe estiver instalado, certifique-se de que está no environment PATH\n\nApós a instalação, reinicie o aplicativo'; - - @override - String get unsupported_platform => 'Plataforma não suportada'; - - @override - String get cache_music => 'Música em cache'; - - @override - String get open => 'Abrir'; - - @override - String get cache_folder => 'Pasta de cache'; - - @override - String get export => 'Exportar'; - - @override - String get clear_cache => 'Limpar cache'; - - @override - String get clear_cache_confirmation => 'Deseja limpar o cache?'; - - @override - String get export_cache_files => 'Exportar Arquivos em Cache'; - - @override - String found_n_files(Object count) { - return 'Encontrados $count arquivos'; - } - - @override - String get export_cache_confirmation => 'Deseja exportar estes arquivos para'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return 'Exportados $filesExported de $files arquivos'; - } - - @override - String get undo => 'Desfazer'; - - @override - String get download_all => 'Baixar tudo'; - - @override - String get add_all_to_playlist => 'Adicionar tudo à playlist'; - - @override - String get add_all_to_queue => 'Adicionar tudo à fila'; - - @override - String get play_all_next => 'Reproduzir tudo a seguir'; - - @override - String get pause => 'Pausar'; - - @override - String get view_all => 'Ver tudo'; - - @override - String get no_tracks_added_yet => - 'Parece que você ainda não adicionou nenhuma faixa'; - - @override - String get no_tracks => 'Parece que não há faixas aqui'; - - @override - String get no_tracks_listened_yet => 'Parece que você ainda não ouviu nada'; - - @override - String get not_following_artists => 'Você não está seguindo nenhum artista'; - - @override - String get no_favorite_albums_yet => - 'Parece que você ainda não adicionou nenhum álbum aos favoritos'; - - @override - String get no_logs_found => 'Nenhum log encontrado'; - - @override - String get youtube_engine => 'Motor YouTube'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine não está instalado'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine não está instalado no seu sistema.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Certifique-se de que está disponível na variável PATH ou\ndefina o caminho absoluto para o executável $engine abaixo'; - } - - @override - String get youtube_engine_unix_issue_message => - 'Em sistemas macOS/Linux/unix, definir o caminho no .zshrc/.bashrc/.bash_profile etc. não funcionará.\nVocê precisa definir o caminho no arquivo de configuração do shell'; - - @override - String get download => 'Baixar'; - - @override - String get file_not_found => 'Arquivo não encontrado'; - - @override - String get custom => 'Personalizado'; - - @override - String get add_custom_url => 'Adicionar URL personalizada'; - - @override - String get edit_port => 'Editar porta'; - - @override - String get port_helper_msg => - 'O padrão é -1, que indica um número aleatório. Se você tiver um firewall configurado, é recomendável definir isso.'; - - @override - String connect_request(Object client) { - return 'Permitir que $client se conecte?'; - } - - @override - String get connection_request_denied => - 'Conexão negada. O usuário negou o acesso .'; - - @override - String get an_error_occurred => 'Ocorreu um erro'; - - @override - String get copy_to_clipboard => 'Copiar para a área de transferência'; - - @override - String get view_logs => 'Ver logs'; - - @override - String get retry => 'Tentar novamente'; - - @override - String get no_default_metadata_provider_selected => - 'Você não tem um provedor de metadados padrão definido'; - - @override - String get manage_metadata_providers => 'Gerenciar provedores de metadados'; - - @override - String get open_link_in_browser => 'Abrir link no navegador?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Você deseja abrir o seguinte link'; - - @override - String get unsafe_url_warning => - 'Pode ser inseguro abrir links de fontes não confiáveis. Tenha cautela!\nVocê também pode copiar o link para sua área de transferência.'; - - @override - String get copy_link => 'Copiar link'; - - @override - String get building_your_timeline => - 'Construindo sua linha do tempo com base em suas audições...'; - - @override - String get official => 'Oficial'; - - @override - String author_name(Object author) { - return 'Autor: $author'; - } - - @override - String get third_party => 'Terceiros'; - - @override - String get plugin_requires_authentication => 'Plugin requer autenticação'; - - @override - String get update_available => 'Atualização disponível'; - - @override - String get supports_scrobbling => 'Suporta scrobbling'; - - @override - String get plugin_scrobbling_info => - 'Este plugin faz o scrobbling de sua música para gerar seu histórico de audição.'; - - @override - String get default_metadata_source => 'Fonte padrão de metadados'; - - @override - String get set_default_metadata_source => 'Definir fonte padrão de metadados'; - - @override - String get default_audio_source => 'Fonte de áudio padrão'; - - @override - String get set_default_audio_source => 'Definir fonte de áudio padrão'; - - @override - String get set_default => 'Definir como padrão'; - - @override - String get support => 'Suporte'; - - @override - String get support_plugin_development => 'Apoiar o desenvolvimento do plugin'; - - @override - String can_access_name_api(Object name) { - return '- Pode acessar a API **$name**'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Você deseja instalar este plugin?'; - - @override - String get third_party_plugin_warning => - 'Este plugin é de um repositório de terceiros. Certifique-se de que você confia na fonte antes de instalá-lo.'; - - @override - String get author => 'Autor'; - - @override - String get this_plugin_can_do_following => - 'Este plugin pode fazer o seguinte'; - - @override - String get install => 'Instalar'; - - @override - String get install_a_metadata_provider => 'Instalar um provedor de metadados'; - - @override - String get no_tracks_playing => 'Nenhuma música sendo reproduzida no momento'; - - @override - String get synced_lyrics_not_available => - 'As letras sincronizadas não estão disponíveis para esta música. Por favor, use a aba'; - - @override - String get plain_lyrics => 'Letras simples'; - - @override - String get tab_instead => 'em vez disso.'; - - @override - String get disclaimer => 'Aviso'; - - @override - String get third_party_plugin_dmca_notice => - 'A equipe Spotube não se responsabiliza (incluindo legalmente) por quaisquer plugins de \"terceiros\".\nUse-os por sua conta e risco. Para quaisquer bugs/problemas, por favor, relate-os ao repositório do plugin.\n\nSe algum plugin de \"terceiros\" estiver violando os Termos de Serviço/DMCA de qualquer serviço/entidade legal, por favor, peça ao autor do plugin \"terceiro\" ou à plataforma de hospedagem, por exemplo, GitHub/Codeberg, para tomar medidas. Os plugins listados acima (rotulados como \"terceiros\") são todos plugins públicos/mantidos pela comunidade. Não os estamos curando, então não podemos tomar nenhuma medida sobre eles.\n\n'; - - @override - String get input_does_not_match_format => - 'A entrada não corresponde ao formato exigido'; - - @override - String get plugins => 'Plugins'; - - @override - String get paste_plugin_download_url => - 'Cole a url de download ou a url do repositório GitHub/Codeberg ou o link direto para o arquivo .smplug'; - - @override - String get download_and_install_plugin_from_url => - 'Baixar e instalar o plugin a partir da url'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Falha ao adicionar plugin: $error'; - } - - @override - String get upload_plugin_from_file => 'Carregar plugin a partir de arquivo'; - - @override - String get installed => 'Instalado'; - - @override - String get available_plugins => 'Plugins disponíveis'; - - @override - String get configure_plugins => - 'Configure seus próprios plugins de provedores de metadados e fontes de áudio'; - - @override - String get audio_scrobblers => 'Scrobblers de áudio'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Fonte: '; - - @override - String get uncompressed => 'Não comprimido'; - - @override - String get dab_music_source_description => - 'Para audiófilos. Fornece streams de áudio de alta qualidade/sem perdas. Correspondência precisa de faixas baseada em ISRC.'; -} diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart deleted file mode 100644 index 31be6a7b..00000000 --- a/lib/l10n/generated/app_localizations_ru.dart +++ /dev/null @@ -1,1573 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Russian (`ru`). -class AppLocalizationsRu extends AppLocalizations { - AppLocalizationsRu([String locale = 'ru']) : super(locale); - - @override - String get guest => 'Гость'; - - @override - String get browse => 'Обзор'; - - @override - String get search => 'Поиск'; - - @override - String get library => 'Библиотека'; - - @override - String get lyrics => 'Текст'; - - @override - String get settings => 'Настройки'; - - @override - String get genre_categories_filter => 'Фильтр по категориям или жанрам...'; - - @override - String get genre => 'Жанр'; - - @override - String get personalized => 'Персонализированный'; - - @override - String get featured => 'Популярное'; - - @override - String get new_releases => 'Новое'; - - @override - String get songs => 'Треки'; - - @override - String playing_track(Object track) { - return 'Играет $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Это удалит текущую очередь. $track_length треков будет удалено. Вы хотите продолжить?'; - } - - @override - String get load_more => 'Загрузить больше'; - - @override - String get playlists => 'Плейлисты'; - - @override - String get artists => 'Исполнители'; - - @override - String get albums => 'Альбомы'; - - @override - String get tracks => 'Треки'; - - @override - String get downloads => 'Загрузки'; - - @override - String get filter_playlists => 'Применить фильтры к вашим плейлистам...'; - - @override - String get liked_tracks => 'Понравившиеся треки'; - - @override - String get liked_tracks_description => 'Все понравившиеся треки'; - - @override - String get playlist => 'Плейлист'; - - @override - String get create_a_playlist => 'Создать плейлист'; - - @override - String get update_playlist => 'Обновить плейлист'; - - @override - String get create => 'Создать'; - - @override - String get cancel => 'Отмена'; - - @override - String get update => 'Обновить'; - - @override - String get playlist_name => 'Назвать плейлист'; - - @override - String get name_of_playlist => 'Название плейлиста'; - - @override - String get description => 'Описание'; - - @override - String get public => 'Публичный'; - - @override - String get collaborative => 'Совместный'; - - @override - String get search_local_tracks => 'Поиск песен на вашем устройстве...'; - - @override - String get play => 'Играть'; - - @override - String get delete => 'Удалить'; - - @override - String get none => 'Пусто'; - - @override - String get sort_a_z => 'Сортировка по алфавиту'; - - @override - String get sort_z_a => 'Сортировка по алфавиту в обратную сторону'; - - @override - String get sort_artist => 'Сортировать по исполнителю'; - - @override - String get sort_album => 'Сортировать по альбомам'; - - @override - String get sort_duration => 'Сортировать по длительности'; - - @override - String get sort_tracks => 'Сортировать треки'; - - @override - String currently_downloading(Object tracks_length) { - return 'Загружается ($tracks_length)'; - } - - @override - String get cancel_all => 'Отменить все'; - - @override - String get filter_artist => 'Фильтровать по исполнителю...'; - - @override - String followers(Object followers) { - return '$followers Подписчики'; - } - - @override - String get add_artist_to_blacklist => 'Добавить исполнителя в черный список'; - - @override - String get top_tracks => 'Чарт'; - - @override - String get fans_also_like => 'Поклонникам также нравится'; - - @override - String get loading => 'Загрузка...'; - - @override - String get artist => 'Исполнитель'; - - @override - String get blacklisted => 'Внесен в черный список'; - - @override - String get following => 'Подписаны'; - - @override - String get follow => 'Подписаться'; - - @override - String get artist_url_copied => - 'URL-адрес исполнителя скопирован в буфер обмена'; - - @override - String added_to_queue(Object tracks) { - return 'Добавлено $tracks треков в очередь'; - } - - @override - String get filter_albums => 'Фильтровать альбомы...'; - - @override - String get synced => 'Синхронизировано'; - - @override - String get plain => 'Обычный'; - - @override - String get shuffle => 'Перемешать'; - - @override - String get search_tracks => 'Поиск треков...'; - - @override - String get released => 'Дата выхода'; - - @override - String error(Object error) { - return 'Ошибка $error'; - } - - @override - String get title => 'Заголовок'; - - @override - String get time => 'Время'; - - @override - String get more_actions => 'Больше действий'; - - @override - String download_count(Object count) { - return 'Скачать ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Добавить ($count) в плейлист'; - } - - @override - String add_count_to_queue(Object count) { - return 'Добавить ($count) в очередь'; - } - - @override - String play_count_next(Object count) { - return 'Воспроизвести ($count) следующий'; - } - - @override - String get album => 'Альбом'; - - @override - String copied_to_clipboard(Object data) { - return 'Скопировано $data в буфер обмена'; - } - - @override - String add_to_following_playlists(Object track) { - return 'Добавить $track в этот плейлист'; - } - - @override - String get add => 'Добавить'; - - @override - String added_track_to_queue(Object track) { - return 'Добавлен $track в очередь'; - } - - @override - String get add_to_queue => 'Добавить в очередь'; - - @override - String track_will_play_next(Object track) { - return '$track будет воспроизведен следующим'; - } - - @override - String get play_next => 'Воспроизвести следующий'; - - @override - String removed_track_from_queue(Object track) { - return '$track удален из очереди'; - } - - @override - String get remove_from_queue => 'Удалить из очереди'; - - @override - String get remove_from_favorites => 'Удалить из избранного'; - - @override - String get save_as_favorite => 'Сохранить в избранное'; - - @override - String get add_to_playlist => 'Добавить в плейлист'; - - @override - String get remove_from_playlist => 'Удалить из плейлиста'; - - @override - String get add_to_blacklist => 'Добавить в черный список'; - - @override - String get remove_from_blacklist => 'Удалить из черного списка'; - - @override - String get share => 'Поделиться'; - - @override - String get mini_player => 'Мини-плеер'; - - @override - String get slide_to_seek => 'Потяните для перемотки вперед или назад'; - - @override - String get shuffle_playlist => 'Перемешать плейлист'; - - @override - String get unshuffle_playlist => 'Снять перемешивание плейлиста'; - - @override - String get previous_track => 'Предыдущий трек'; - - @override - String get next_track => 'Следующий трек'; - - @override - String get pause_playback => 'Пауза воспроизведения'; - - @override - String get resume_playback => 'Возобновить воспроизведение'; - - @override - String get loop_track => 'Циклический трек'; - - @override - String get no_loop => 'Без повтора'; - - @override - String get repeat_playlist => 'Повторите плейлист'; - - @override - String get queue => 'Очередь'; - - @override - String get alternative_track_sources => 'Альтернативные источники треков'; - - @override - String get download_track => 'Скачать трек'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks треков в очереди'; - } - - @override - String get clear_all => 'Очистить все'; - - @override - String get show_hide_ui_on_hover => 'Показать/Скрыть интерфейс при наведении'; - - @override - String get always_on_top => 'Всегда сверху'; - - @override - String get exit_mini_player => 'Выйти из мини-плеера'; - - @override - String get download_location => 'Место загрузки'; - - @override - String get local_library => 'Локальная библиотека'; - - @override - String get add_library_location => 'Добавить в библиотеку'; - - @override - String get remove_library_location => 'Удалить из библиотеки'; - - @override - String get account => 'Аккаунт'; - - @override - String get logout => 'Выйти'; - - @override - String get logout_of_this_account => 'Выйдите из этого аккаунта'; - - @override - String get language_region => 'Язык и регион'; - - @override - String get language => 'Язык'; - - @override - String get system_default => 'Системное значение по умолчанию'; - - @override - String get market_place_region => 'Региональное пространство'; - - @override - String get recommendation_country => 'Страна рекомендаций'; - - @override - String get appearance => 'Внешний вид'; - - @override - String get layout_mode => 'Режим компоновки'; - - @override - String get override_layout_settings => - 'Изменить настройки режима адаптивной компоновки'; - - @override - String get adaptive => 'Адаптивный'; - - @override - String get compact => 'Компактный'; - - @override - String get extended => 'Расширенный'; - - @override - String get theme => 'Тема'; - - @override - String get dark => 'Тёмная'; - - @override - String get light => 'Светлая'; - - @override - String get system => 'Системная'; - - @override - String get accent_color => 'Акцентный цвет'; - - @override - String get sync_album_color => 'Синхронизировать цвет альбома'; - - @override - String get sync_album_color_description => - 'Использует основной цвет обложки альбома как цвет акцента'; - - @override - String get playback => 'Воспроизведение'; - - @override - String get audio_quality => 'Качество звука'; - - @override - String get high => 'Высокое'; - - @override - String get low => 'Низкое'; - - @override - String get pre_download_play => 'Предварительная загрузка и воспроизведение'; - - @override - String get pre_download_play_description => - 'Вместо потоковой передачи аудио используйте загруженные байты и воспроизводьте их (рекомендуется для пользователей с высокой пропускной способностью)'; - - @override - String get skip_non_music => - 'Пропускать немузыкальные сегменты (SponsorBlock)'; - - @override - String get blacklist_description => 'Черный список треков и артистов'; - - @override - String get wait_for_download_to_finish => - 'Пожалуйста, дождитесь завершения текущей загрузки'; - - @override - String get desktop => 'Компьютер'; - - @override - String get close_behavior => 'Поведение при закрытии'; - - @override - String get close => 'Закрыть'; - - @override - String get minimize_to_tray => 'Свернуть'; - - @override - String get show_tray_icon => 'Показать значок на панели задач'; - - @override - String get about => 'О нас'; - - @override - String get u_love_spotube => 'Мы знаем что вам нравится Spotube'; - - @override - String get check_for_updates => 'Проверьте наличие обновлений'; - - @override - String get about_spotube => 'О Spotube'; - - @override - String get blacklist => 'Чёрный список'; - - @override - String get please_sponsor => 'Стать спосором/поддержать'; - - @override - String get spotube_description => - 'Spotube – это легкий, кросс-платформенный клиент Spotify, предоставляющий бесплатный доступ для всех пользователей'; - - @override - String get version => 'Версия'; - - @override - String get build_number => 'Номер сборки'; - - @override - String get founder => 'Создатель'; - - @override - String get repository => 'Репозиторий'; - - @override - String get bug_issues => 'Ошибки и проблемы'; - - @override - String get made_with => 'Сделано Bangladesh🇧🇩 с ❤️'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Лицензия'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Не беспокойся, никакая личная информация не собирается и не передается'; - - @override - String get know_how_to_login => 'Не знаете, как это сделать?'; - - @override - String get follow_step_by_step_guide => 'Следуйте пошаговому руководству'; - - @override - String cookie_name_cookie(Object name) { - return '$name Cookie'; - } - - @override - String get fill_in_all_fields => 'Пожалуйста, заполните все поля'; - - @override - String get submit => 'Отправить'; - - @override - String get exit => 'Выйти'; - - @override - String get previous => 'Предыдущий'; - - @override - String get next => 'Следующий'; - - @override - String get done => 'Готово'; - - @override - String get step_1 => 'Шаг 1'; - - @override - String get first_go_to => 'Сначала перейдите в'; - - @override - String get something_went_wrong => 'Что-то пошло не так'; - - @override - String get piped_instance => 'Экземпляр сервера Piped'; - - @override - String get piped_description => - 'Серверный экземпляр Piped для сопоставления треков'; - - @override - String get piped_warning => - 'Некоторые из них могут работать неправильно, поэтому используйте на свой страх и риск'; - - @override - String get invidious_instance => 'Экземпляр сервера Invidious'; - - @override - String get invidious_description => - 'Экземпляр сервера Invidious для сопоставления треков'; - - @override - String get invidious_warning => - 'Некоторые могут работать не очень хорошо. Используйте на свой страх и риск'; - - @override - String get generate => 'Генерировать'; - - @override - String track_exists(Object track) { - return 'Трек $track уже существует'; - } - - @override - String get replace_downloaded_tracks => 'Заменить все ранее скачанные треки'; - - @override - String get skip_download_tracks => - 'Пропустить загрузку всех ранее скачанных треков'; - - @override - String get do_you_want_to_replace => 'Хотите заменить существующий трек??'; - - @override - String get replace => 'Заменить'; - - @override - String get skip => 'Пропустить'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Выберите до $count $type'; - } - - @override - String get select_genres => 'Выберите жанр'; - - @override - String get add_genres => 'Добавить жанр'; - - @override - String get country => 'Страна'; - - @override - String get number_of_tracks_generate => 'Количество треков для создания'; - - @override - String get acousticness => 'Акустичность'; - - @override - String get danceability => 'Ритмичность'; - - @override - String get energy => 'Энергичность'; - - @override - String get instrumentalness => 'Инструментальность'; - - @override - String get liveness => 'Живость'; - - @override - String get loudness => 'Громкость'; - - @override - String get speechiness => 'Речевой характер'; - - @override - String get valence => 'Значимость'; - - @override - String get popularity => 'Популярность'; - - @override - String get key => 'Ключ'; - - @override - String get duration => 'Продолжительность (с)'; - - @override - String get tempo => 'Темп (BPM)'; - - @override - String get mode => 'Режим'; - - @override - String get time_signature => 'Тактовый размер'; - - @override - String get short => 'Короткий'; - - @override - String get medium => 'Средний'; - - @override - String get long => 'Длинный'; - - @override - String get min => 'Минимум'; - - @override - String get max => 'Максимум'; - - @override - String get target => 'Цель'; - - @override - String get moderate => 'Отобрать'; - - @override - String get deselect_all => 'Убрать выделение со всех'; - - @override - String get select_all => 'Выделить все'; - - @override - String get are_you_sure => 'Вы уверены?'; - - @override - String get generating_playlist => 'Создание собственного плейлиста...'; - - @override - String selected_count_tracks(Object count) { - return 'Выбрано $count треков'; - } - - @override - String get download_warning => - 'При скачивании всех треков пакетом вы фактически занимаетесь пиратством и наносите ущерб творческому обществу музыки. Надеюсь, что вы осознаете это. Всегда старайтесь уважать и поддерживать усилия исполнителей, вложенные в их творчество'; - - @override - String get download_ip_ban_warning => - 'Кроме того, стоит учитывать, что из-за чрезмерного количества запросов на скачивание ваш IP-адрес может быть заблокирован на YouTube. Блокировка IP означает, что вы не сможете использовать YouTube (даже если вы вошли в свою учетную запись) в течение, как минимум, 2-3 месяцев с того устройства, с которого были сделаны эти запросы. Важно заметить, что Spotube не несет ответственности за такие события'; - - @override - String get by_clicking_accept_terms => - 'Нажимая \'принять\', вы соглашаетесь с следующими условиями:'; - - @override - String get download_agreement_1 => - 'Я осознаю, что я использую музыку незаконно. Это плохо.'; - - @override - String get download_agreement_2 => - 'Я бы поддержал исполнителей, где только смог, и делаю это, так как не имею средств на приобретение их творчества'; - - @override - String get download_agreement_3 => - 'Я полностью осознаю, что мой IP-адрес может быть заблокирован на YouTube, и я не считаю Spotube или его владельцев/соавторов ответственными за какие-либо неприятности, вызванные моими текущими действиями'; - - @override - String get decline => 'Отклонить'; - - @override - String get accept => 'Принять'; - - @override - String get details => 'Детали'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Канал'; - - @override - String get likes => 'Нравится'; - - @override - String get dislikes => 'Не нравится'; - - @override - String get views => 'Просмотров'; - - @override - String get streamUrl => 'URL-адрес потока'; - - @override - String get stop => 'Остановить'; - - @override - String get sort_newest => 'Сортировать по самым новым добавленным'; - - @override - String get sort_oldest => 'Сортировать по самым старым добавленным'; - - @override - String get sleep_timer => 'Таймер сна'; - - @override - String mins(Object minutes) { - return '$minutes Минут'; - } - - @override - String hours(Object hours) { - return '$hours Часы'; - } - - @override - String hour(Object hours) { - return '$hours Час'; - } - - @override - String get custom_hours => 'Пользовательские часы'; - - @override - String get logs => 'Журналы'; - - @override - String get developers => 'Разработчики'; - - @override - String get not_logged_in => 'Вы не выполнили вход'; - - @override - String get search_mode => 'Режим поиска'; - - @override - String get audio_source => 'Источник аудио'; - - @override - String get ok => 'Ок'; - - @override - String get failed_to_encrypt => 'Не удалось зашифровать'; - - @override - String get encryption_failed_warning => - 'Spotube использует шифрование для безопасного хранения ваших данных. Однако в этом случае произошла ошибка. Поэтому будет использовано небезопасное хранилище.\nЕсли вы используете Linux, убедитесь, что у вас установлен какой-либо инструмент для работы с секретами (gnome-keyring, kde-wallet, keepassxc и т.д.)'; - - @override - String get querying_info => 'Запрос информации...'; - - @override - String get piped_api_down => 'Piped API не отвечает'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'Экземпляр Piped $pipedInstance в данный момент недоступен.\n\nВы можете либо изменить экземпляр, либо переключиться на использование официального API YouTube.\n\nНе забудьте перезапустить приложение после внесенных изменений'; - } - - @override - String get you_are_offline => 'Нет доступа к сети'; - - @override - String get connection_restored => 'Ваше интернет-соединение восстановлено'; - - @override - String get use_system_title_bar => 'Использовать системную панель заголовка'; - - @override - String get crunching_results => 'Обработка результатов...'; - - @override - String get search_to_get_results => 'Поиск для получения результатов'; - - @override - String get use_amoled_mode => 'Режим AMOLED'; - - @override - String get pitch_dark_theme => 'Темная тема'; - - @override - String get normalize_audio => 'Нормализовать звук'; - - @override - String get change_cover => 'Изменить обложку'; - - @override - String get add_cover => 'Добавить обложку'; - - @override - String get restore_defaults => 'Восстановить настройки по умолчанию'; - - @override - String get download_music_format => 'Формат загрузки музыки'; - - @override - String get streaming_music_format => 'Формат потоковой музыки'; - - @override - String get download_music_quality => 'Качество загрузки'; - - @override - String get streaming_music_quality => 'Качество стриминга'; - - @override - String get login_with_lastfm => 'Войти с помощью Last.fm'; - - @override - String get connect => 'Подключить'; - - @override - String get disconnect_lastfm => 'Отключиться от Last.fm'; - - @override - String get disconnect => 'Отключить'; - - @override - String get username => 'Имя пользователя'; - - @override - String get password => 'Пароль'; - - @override - String get login => 'Войти'; - - @override - String get login_with_your_lastfm => 'Войти в свою учетную запись Last.fm'; - - @override - String get scrobble_to_lastfm => 'Скробблинг на Last.fm'; - - @override - String get go_to_album => 'Перейти к альбому'; - - @override - String get discord_rich_presence => 'Богатое присутствие в Discord'; - - @override - String get browse_all => 'Просмотреть все'; - - @override - String get genres => 'Жанры'; - - @override - String get explore_genres => 'Исследовать жанры'; - - @override - String get friends => 'Друзья'; - - @override - String get no_lyrics_available => - 'Извините, не удается найти текст для этого трека'; - - @override - String get start_a_radio => 'Запустить радио'; - - @override - String get how_to_start_radio => 'Как вы хотите запустить радио?'; - - @override - String get replace_queue_question => - 'Хотите заменить текущую очередь или добавить к ней?'; - - @override - String get endless_playback => 'Бесконечное воспроизведение'; - - @override - String get delete_playlist => 'Удалить плейлист'; - - @override - String get delete_playlist_confirmation => - 'Вы уверены, что хотите удалить этот плейлист?'; - - @override - String get local_tracks => 'Локальные треки'; - - @override - String get local_tab => 'Локальное'; - - @override - String get song_link => 'Ссылка на песню'; - - @override - String get skip_this_nonsense => 'Пропустить этот бред'; - - @override - String get freedom_of_music => '“Свобода музыки”'; - - @override - String get freedom_of_music_palm => '“Свобода музыки в вашей ладони”'; - - @override - String get get_started => 'Начнем'; - - @override - String get youtube_source_description => - 'Рекомендуется и лучше всего работает.'; - - @override - String get piped_source_description => - 'Чувствуете себя свободно? То же самое, что и YouTube, но намного бесплатно.'; - - @override - String get jiosaavn_source_description => - 'Лучший для Южно-Азиатского региона.'; - - @override - String get invidious_source_description => - 'Похож на Piped, но с более высокой доступностью.'; - - @override - String highest_quality(Object quality) { - return 'Наивысшее качество: $quality'; - } - - @override - String get select_audio_source => 'Выберите аудиоисточник'; - - @override - String get endless_playback_description => - 'Автоматически добавляйте новые песни\nв конец очереди'; - - @override - String get choose_your_region => 'Выберите ваш регион'; - - @override - String get choose_your_region_description => - 'Это поможет Spotube показать вам правильный контент\nдля вашего местоположения.'; - - @override - String get choose_your_language => 'Выберите ваш язык'; - - @override - String get help_project_grow => 'Помогите этому проекту расти'; - - @override - String get help_project_grow_description => - 'Spotube - это проект с открытым исходным кодом. Вы можете помочь этому проекту развиваться, внося вклад в проект, сообщая ошибках или предлагая новые функции.'; - - @override - String get contribute_on_github => 'Внести вклад на GitHub'; - - @override - String get donate_on_open_collective => 'Пожертвовать на Open Collective'; - - @override - String get browse_anonymously => 'Анонимно просматривать'; - - @override - String get enable_connect => 'Включить подключение'; - - @override - String get enable_connect_description => - 'Управление Spotube с других устройств'; - - @override - String get devices => 'Устройства'; - - @override - String get select => 'Выбрать'; - - @override - String connect_client_alert(Object client) { - return 'Вас контролирует $client'; - } - - @override - String get this_device => 'Это устройство'; - - @override - String get remote => 'Дистанционное управление'; - - @override - String get stats => 'Статистика'; - - @override - String and_n_more(Object count) { - return 'и $count еще'; - } - - @override - String get recently_played => 'Недавно воспроизведено'; - - @override - String get browse_more => 'Посмотреть больше'; - - @override - String get no_title => 'Без названия'; - - @override - String get not_playing => 'Не воспроизводится'; - - @override - String get epic_failure => 'Эпическое фиаско!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'Добавлено $tracks_length треков в очередь'; - } - - @override - String get spotube_has_an_update => 'В Spotube доступно обновление'; - - @override - String get download_now => 'Скачать сейчас'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum выпущен'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version выпущен'; - } - - @override - String get read_the_latest => 'Читать последние '; - - @override - String get release_notes => 'заметки о версии'; - - @override - String get pick_color_scheme => 'Выберите цветовую схему'; - - @override - String get save => 'Сохранить'; - - @override - String get choose_the_device => 'Выберите устройство:'; - - @override - String get multiple_device_connected => - 'Подключено несколько устройств.\nВыберите устройство, на котором вы хотите выполнить это действие'; - - @override - String get nothing_found => 'Ничего не найдено'; - - @override - String get the_box_is_empty => 'Коробка пуста'; - - @override - String get top_artists => 'Лучшие артисты'; - - @override - String get top_albums => 'Лучшие альбомы'; - - @override - String get this_week => 'На этой неделе'; - - @override - String get this_month => 'В этом месяце'; - - @override - String get last_6_months => 'Последние 6 месяцев'; - - @override - String get this_year => 'В этом году'; - - @override - String get last_2_years => 'Последние 2 года'; - - @override - String get all_time => 'Все время'; - - @override - String powered_by_provider(Object providerName) { - return 'При поддержке $providerName'; - } - - @override - String get email => 'Электронная почта'; - - @override - String get profile_followers => 'Подписчики'; - - @override - String get birthday => 'День рождения'; - - @override - String get subscription => 'Подписка'; - - @override - String get not_born => 'Не рожден'; - - @override - String get hacker => 'Хакер'; - - @override - String get profile => 'Профиль'; - - @override - String get no_name => 'Без имени'; - - @override - String get edit => 'Редактировать'; - - @override - String get user_profile => 'Профиль пользователя'; - - @override - String count_plays(Object count) { - return '$count воспроизведений'; - } - - @override - String get streaming_fees_hypothetical => - '*Рассчитано на основе выплат Spotify за стрим\nот \$0.003 до \$0.005. Это гипотетический\nрасчет, чтобы показать пользователю, сколько бы он\nзаплатил артистам, если бы слушал их песни на Spotify.'; - - @override - String get minutes_listened => 'Минут прослушивания'; - - @override - String get streamed_songs => 'Стримленные песни'; - - @override - String count_streams(Object count) { - return '$count стримов'; - } - - @override - String get owned_by_you => 'Ваша собственность'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl скопировано в буфер обмена'; - } - - @override - String get hipotetical_calculation => - '*Это рассчитано на основе средней выплаты за прослушивание на онлайн-платформах для потоковой передачи музыки в размере от 0,003 до 0,005 долларов США. Это гипотетический расчет, чтобы дать пользователю представление о том, сколько бы они заплатили артистам, если бы слушали их песни на разных музыкальных стриминговых платформах.'; - - @override - String count_mins(Object minutes) { - return '$minutes мин'; - } - - @override - String get summary_minutes => 'минуты'; - - @override - String get summary_listened_to_music => 'Слушанная музыка'; - - @override - String get summary_songs => 'песни'; - - @override - String get summary_streamed_overall => 'Всего стримов'; - - @override - String get summary_owed_to_artists => 'К выплате артистам\nв этом месяце'; - - @override - String get summary_artists => 'артиста'; - - @override - String get summary_music_reached_you => 'Музыка дошла до вас'; - - @override - String get summary_full_albums => 'полные альбомы'; - - @override - String get summary_got_your_love => 'Получил вашу любовь'; - - @override - String get summary_playlists => 'плейлисты'; - - @override - String get summary_were_on_repeat => 'Были на повторе'; - - @override - String total_money(Object money) { - return 'Всего $money'; - } - - @override - String get webview_not_found => 'Webview не найден'; - - @override - String get webview_not_found_description => - 'На вашем устройстве не установлена среда выполнения Webview.\nЕсли он установлен, убедитесь, что он находится в environment PATH\n\nПосле установки перезапустите приложение'; - - @override - String get unsupported_platform => 'Платформа не поддерживается'; - - @override - String get cache_music => 'Кэшировать музыку'; - - @override - String get open => 'Открыть'; - - @override - String get cache_folder => 'Папка кэша'; - - @override - String get export => 'Экспорт'; - - @override - String get clear_cache => 'Очистить кэш'; - - @override - String get clear_cache_confirmation => 'Вы хотите очистить кэш?'; - - @override - String get export_cache_files => 'Экспортировать кэшированные файлы'; - - @override - String found_n_files(Object count) { - return 'Найдено $count файлов'; - } - - @override - String get export_cache_confirmation => - 'Вы хотите экспортировать эти файлы в'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return 'Экспортировано $filesExported из $files файлов'; - } - - @override - String get undo => 'Отменить'; - - @override - String get download_all => 'Скачать все'; - - @override - String get add_all_to_playlist => 'Добавить все в плейлист'; - - @override - String get add_all_to_queue => 'Добавить все в очередь'; - - @override - String get play_all_next => 'Воспроизвести все следующее'; - - @override - String get pause => 'Пауза'; - - @override - String get view_all => 'Просмотреть все'; - - @override - String get no_tracks_added_yet => - 'Похоже, вы ещё не добавили ни одного трека'; - - @override - String get no_tracks => 'Похоже, здесь нет треков'; - - @override - String get no_tracks_listened_yet => 'Похоже, вы ещё ничего не слушали'; - - @override - String get not_following_artists => 'Вы не подписаны на художников'; - - @override - String get no_favorite_albums_yet => - 'Похоже, вы ещё не добавили ни одного альбома в избранное'; - - @override - String get no_logs_found => 'Логи не найдены'; - - @override - String get youtube_engine => 'YouTube Движок'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine не установлен'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine не установлен в вашей системе.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Убедитесь, что он доступен в переменной PATH или\nустановите абсолютный путь к исполнимому файлу $engine ниже'; - } - - @override - String get youtube_engine_unix_issue_message => - 'В macOS/Linux/Unix-подобных ОС, установка пути в .zshrc/.bashrc/.bash_profile и т.д. не будет работать.\nВы должны установить путь в файле конфигурации оболочки'; - - @override - String get download => 'Скачать'; - - @override - String get file_not_found => 'Файл не найден'; - - @override - String get custom => 'Пользовательский'; - - @override - String get add_custom_url => 'Добавить пользовательский URL'; - - @override - String get edit_port => 'Редактировать порт'; - - @override - String get port_helper_msg => - 'По умолчанию -1, что означает случайное число. Если у вас настроен брандмауэр, рекомендуется установить это.'; - - @override - String connect_request(Object client) { - return 'Разрешить $client подключение?'; - } - - @override - String get connection_request_denied => - 'Подключение отклонено. Пользователь отказал в доступе.'; - - @override - String get an_error_occurred => 'Произошла ошибка'; - - @override - String get copy_to_clipboard => 'Скопировать в буфер обмена'; - - @override - String get view_logs => 'Просмотреть журналы'; - - @override - String get retry => 'Повторить'; - - @override - String get no_default_metadata_provider_selected => - 'Вы не выбрали поставщика метаданных по умолчанию'; - - @override - String get manage_metadata_providers => 'Управление поставщиками метаданных'; - - @override - String get open_link_in_browser => 'Открыть ссылку в браузере?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Вы хотите открыть следующую ссылку'; - - @override - String get unsafe_url_warning => - 'Открытие ссылок из ненадежных источников может быть небезопасным. Будьте осторожны!\nВы также можете скопировать ссылку в буфер обмена.'; - - @override - String get copy_link => 'Копировать ссылку'; - - @override - String get building_your_timeline => - 'Создание вашей временной шкалы на основе ваших прослушиваний...'; - - @override - String get official => 'Официальный'; - - @override - String author_name(Object author) { - return 'Автор: $author'; - } - - @override - String get third_party => 'Сторонний'; - - @override - String get plugin_requires_authentication => 'Плагин требует аутентификации'; - - @override - String get update_available => 'Доступно обновление'; - - @override - String get supports_scrobbling => 'Поддерживает скробблинг'; - - @override - String get plugin_scrobbling_info => - 'Этот плагин скробблит вашу музыку для создания вашей истории прослушиваний.'; - - @override - String get default_metadata_source => 'Источник метаданных по умолчанию'; - - @override - String get set_default_metadata_source => - 'Задать источник метаданных по умолчанию'; - - @override - String get default_audio_source => 'Источник аудио по умолчанию'; - - @override - String get set_default_audio_source => 'Задать источник аудио по умолчанию'; - - @override - String get set_default => 'Установить по умолчанию'; - - @override - String get support => 'Поддержка'; - - @override - String get support_plugin_development => 'Поддержать разработку плагина'; - - @override - String can_access_name_api(Object name) { - return '- Может получить доступ к API **$name**'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Вы хотите установить этот плагин?'; - - @override - String get third_party_plugin_warning => - 'Этот плагин из стороннего репозитория. Пожалуйста, убедитесь, что вы доверяете источнику перед установкой.'; - - @override - String get author => 'Автор'; - - @override - String get this_plugin_can_do_following => - 'Этот плагин может выполнять следующее'; - - @override - String get install => 'Установить'; - - @override - String get install_a_metadata_provider => 'Установить поставщика метаданных'; - - @override - String get no_tracks_playing => - 'В настоящее время не воспроизводится ни один трек'; - - @override - String get synced_lyrics_not_available => - 'Синхронизированные тексты недоступны для этой песни. Пожалуйста, используйте вкладку'; - - @override - String get plain_lyrics => 'Простые тексты'; - - @override - String get tab_instead => 'вместо этого.'; - - @override - String get disclaimer => 'Отказ от ответственности'; - - @override - String get third_party_plugin_dmca_notice => - 'Команда Spotube не несет никакой ответственности (в том числе юридической) за какие-либо \"сторонние\" плагины.\nПожалуйста, используйте их на свой страх и риск. О любых ошибках/проблемах сообщайте в репозиторий плагина.\n\nЕсли какой-либо \"сторонний\" плагин нарушает ToS/DMCA какого-либо сервиса/юридического лица, пожалуйста, попросите автора плагина \"стороннего\" или хостинговую платформу, например, GitHub/Codeberg, принять меры. Перечисленные выше (помеченные как \"сторонние\") являются общедоступными/поддерживаемыми сообществом плагинами. Мы не курируем их, поэтому не можем принимать по ним никаких мер.\n\n'; - - @override - String get input_does_not_match_format => - 'Введенные данные не соответствуют требуемому формату'; - - @override - String get plugins => 'Плагины'; - - @override - String get paste_plugin_download_url => - 'Вставьте URL-адрес для загрузки или URL-адрес репозитория GitHub/Codeberg или прямую ссылку на файл .smplug'; - - @override - String get download_and_install_plugin_from_url => - 'Загрузить и установить плагин по URL-адресу'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Не удалось добавить плагин: $error'; - } - - @override - String get upload_plugin_from_file => 'Загрузить плагин из файла'; - - @override - String get installed => 'Установлено'; - - @override - String get available_plugins => 'Доступные плагины'; - - @override - String get configure_plugins => - 'Настройте собственные плагины провайдеров метаданных и источников аудио'; - - @override - String get audio_scrobblers => 'Аудио скробблеры'; - - @override - String get scrobbling => 'Скробблинг'; - - @override - String get source => 'Источник: '; - - @override - String get uncompressed => 'Несжатый'; - - @override - String get dab_music_source_description => - 'Для аудиофилов. Предоставляет высококачественные/lossless аудиопотоки. Точное совпадение треков по ISRC.'; -} diff --git a/lib/l10n/generated/app_localizations_ta.dart b/lib/l10n/generated/app_localizations_ta.dart deleted file mode 100644 index 062a99dc..00000000 --- a/lib/l10n/generated/app_localizations_ta.dart +++ /dev/null @@ -1,1579 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Tamil (`ta`). -class AppLocalizationsTa extends AppLocalizations { - AppLocalizationsTa([String locale = 'ta']) : super(locale); - - @override - String get guest => 'விருந்தினர்'; - - @override - String get browse => 'உலாவு'; - - @override - String get search => 'தேடுக'; - - @override - String get library => 'நூலகம்'; - - @override - String get lyrics => 'பாடல் வரிகள்'; - - @override - String get settings => 'அமைப்புகள்'; - - @override - String get genre_categories_filter => 'வகைகள் அல்லது பாணிகளை வடிகட்டுக...'; - - @override - String get genre => 'பாணி'; - - @override - String get personalized => 'தனிப்பயனாக்கப்பட்ட'; - - @override - String get featured => 'சிறப்பிடம் பெற்ற'; - - @override - String get new_releases => 'புதிய வெளியீடுகள்'; - - @override - String get songs => 'பாடல்கள்'; - - @override - String playing_track(Object track) { - return '$track இயங்குகிறது'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'இது தற்போதைய வரிசையை அழிக்கும். $track_length பாடல்கள் நீக்கப்படும்\nதொடர விரும்புகிறீர்களா?'; - } - - @override - String get load_more => 'மேலும் ஏற்றுக'; - - @override - String get playlists => 'பாடல் பட்டியல்கள்'; - - @override - String get artists => 'கலைஞர்கள்'; - - @override - String get albums => 'ஆல்பங்கள்'; - - @override - String get tracks => 'பாடல்கள்'; - - @override - String get downloads => 'பதிவிறக்கங்கள்'; - - @override - String get filter_playlists => 'உங்கள் பாடல் பட்டியல்களை வடிகட்டுக...'; - - @override - String get liked_tracks => 'விரும்பிய பாடல்கள்'; - - @override - String get liked_tracks_description => 'உங்கள் விரும்பிய பாடல்கள் அனைத்தும்'; - - @override - String get playlist => 'பாடல் பட்டியல்'; - - @override - String get create_a_playlist => 'பாடல் பட்டியலை உருவாக்குக'; - - @override - String get update_playlist => 'பாடல் பட்டியலைப் புதுப்பிக்க'; - - @override - String get create => 'உருவாக்கு'; - - @override - String get cancel => 'ரத்து செய்'; - - @override - String get update => 'புதுப்பி'; - - @override - String get playlist_name => 'பாடல் பட்டியல் பெயர்'; - - @override - String get name_of_playlist => 'பாடல் பட்டியலின் பெயர்'; - - @override - String get description => 'விளக்கம்'; - - @override - String get public => 'பொது'; - - @override - String get collaborative => 'கூட்டு'; - - @override - String get search_local_tracks => 'உள்ளூர் பாடல்களைத் தேடுக...'; - - @override - String get play => 'இயக்கு'; - - @override - String get delete => 'அழி'; - - @override - String get none => 'எதுவுமில்லை'; - - @override - String get sort_a_z => 'A-Z வரிசைப்படுத்து'; - - @override - String get sort_z_a => 'Z-A வரிசைப்படுத்து'; - - @override - String get sort_artist => 'கலைஞர் மூலம் வரிசைப்படுத்து'; - - @override - String get sort_album => 'ஆல்பம் மூலம் வரிசைப்படுத்து'; - - @override - String get sort_duration => 'கால அளவு மூலம் வரிசைப்படுத்து'; - - @override - String get sort_tracks => 'பாடல்களை வரிசைப்படுத்து'; - - @override - String currently_downloading(Object tracks_length) { - return 'தற்போது பதிவிறக்குகிறது ($tracks_length)'; - } - - @override - String get cancel_all => 'அனைத்தையும் ரத்து செய்'; - - @override - String get filter_artist => 'கலைஞர்களை வடிகட்டுக...'; - - @override - String followers(Object followers) { - return '$followers பின்தொடர்பவர்கள்'; - } - - @override - String get add_artist_to_blacklist => 'கலைஞரை தடைப்பட்டியலில் சேர்க்க'; - - @override - String get top_tracks => 'சிறந்த பாடல்கள்'; - - @override - String get fans_also_like => 'ரசிகர்கள் விரும்புவது'; - - @override - String get loading => 'ஏற்றுகிறது...'; - - @override - String get artist => 'கலைஞர்'; - - @override - String get blacklisted => 'தடைப்பட்டியலில் உள்ளது'; - - @override - String get following => 'பின்தொடர்கிறது'; - - @override - String get follow => 'பின்தொடர்'; - - @override - String get artist_url_copied => - 'கலைஞர் URL கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது'; - - @override - String added_to_queue(Object tracks) { - return '$tracks பாடல்கள் வரிசையில் சேர்க்கப்பட்டன'; - } - - @override - String get filter_albums => 'ஆல்பங்களை வடிகட்டுக...'; - - @override - String get synced => 'ஒத்திசைக்கப்பட்டது'; - - @override - String get plain => 'சாதாரண'; - - @override - String get shuffle => 'கலக்கு'; - - @override - String get search_tracks => 'பாடல்களைத் தேடுக...'; - - @override - String get released => 'வெளியிடப்பட்டது'; - - @override - String error(Object error) { - return 'பிழை $error'; - } - - @override - String get title => 'தலைப்பு'; - - @override - String get time => 'நேரம்'; - - @override - String get more_actions => 'மேலும் செயல்கள்'; - - @override - String download_count(Object count) { - return 'பதிவிறக்கு ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return '($count) பாடல் பட்டியலில் சேர்'; - } - - @override - String add_count_to_queue(Object count) { - return '($count) வரிசையில் சேர்'; - } - - @override - String play_count_next(Object count) { - return '($count) அடுத்து இயக்கு'; - } - - @override - String get album => 'ஆல்பம்'; - - @override - String copied_to_clipboard(Object data) { - return '$data கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது'; - } - - @override - String add_to_following_playlists(Object track) { - return '$track பின்வரும் பாடல் பட்டியல்களில் சேர்'; - } - - @override - String get add => 'சேர்'; - - @override - String added_track_to_queue(Object track) { - return '$track வரிசையில் சேர்க்கப்பட்டது'; - } - - @override - String get add_to_queue => 'வரிசையில் சேர்'; - - @override - String track_will_play_next(Object track) { - return '$track அடுத்து இயக்கப்படும்'; - } - - @override - String get play_next => 'அடுத்து இயக்கு'; - - @override - String removed_track_from_queue(Object track) { - return '$track வரிசையிலிருந்து நீக்கப்பட்டது'; - } - - @override - String get remove_from_queue => 'வரிசையிலிருந்து நீக்கு'; - - @override - String get remove_from_favorites => 'பிடித்தவையிலிருந்து நீக்கு'; - - @override - String get save_as_favorite => 'பிடித்தவையாக சேமி'; - - @override - String get add_to_playlist => 'பாடல் பட்டியலில் சேர்'; - - @override - String get remove_from_playlist => 'பாடல் பட்டியலிலிருந்து நீக்கு'; - - @override - String get add_to_blacklist => 'தடைப்பட்டியலில் சேர்'; - - @override - String get remove_from_blacklist => 'தடைப்பட்டியலிலிருந்து நீக்கு'; - - @override - String get share => 'பகிர்'; - - @override - String get mini_player => 'சிறிய இயக்கி'; - - @override - String get slide_to_seek => 'முன்னோக்கி அல்லது பின்னோக்கி செல்ல சறுக்கவும்'; - - @override - String get shuffle_playlist => 'பாடல் பட்டியலை கலக்கு'; - - @override - String get unshuffle_playlist => 'பாடல் பட்டியலை கலக்காதே'; - - @override - String get previous_track => 'முந்தைய பாடல்'; - - @override - String get next_track => 'அடுத்த பாடல்'; - - @override - String get pause_playback => 'இயக்கத்தை நிறுத்து'; - - @override - String get resume_playback => 'இயக்கத்தை தொடர்'; - - @override - String get loop_track => 'பாடலை சுழற்று'; - - @override - String get no_loop => 'சுழற்சி இல்லை'; - - @override - String get repeat_playlist => 'பாடல் பட்டியலை மீண்டும் இயக்கு'; - - @override - String get queue => 'வரிசை'; - - @override - String get alternative_track_sources => 'மாற்று பாடல் மூலங்கள்'; - - @override - String get download_track => 'பாடலைப் பதிவிறக்கு'; - - @override - String tracks_in_queue(Object tracks) { - return 'வரிசையில் $tracks பாடல்கள்'; - } - - @override - String get clear_all => 'அனைத்தையும் அழி'; - - @override - String get show_hide_ui_on_hover => 'மேலே வரும்போது UI ஐக் காட்டு/மறை'; - - @override - String get always_on_top => 'எப்போதும் மேலே'; - - @override - String get exit_mini_player => 'சிறிய இயக்கியிலிருந்து வெளியேறு'; - - @override - String get download_location => 'பதிவிறக்க இடம்'; - - @override - String get local_library => 'உள்ளூர் நூலகம்'; - - @override - String get add_library_location => 'நூலகத்தில் சேர்'; - - @override - String get remove_library_location => 'நூலகத்திலிருந்து நீக்கு'; - - @override - String get account => 'கணக்கு'; - - @override - String get logout => 'வெளியேறு'; - - @override - String get logout_of_this_account => 'இந்த கணக்கிலிருந்து வெளியேறு'; - - @override - String get language_region => 'மொழி & பிராந்தியம்'; - - @override - String get language => 'மொழி'; - - @override - String get system_default => 'கணினி இயல்புநிலை'; - - @override - String get market_place_region => 'சந்தை பிராந்தியம்'; - - @override - String get recommendation_country => 'பரிந்துரை நாடு'; - - @override - String get appearance => 'தோற்றம்'; - - @override - String get layout_mode => 'அமைப்பு முறை'; - - @override - String get override_layout_settings => 'தளவமைப்பு அமைப்புகளை மாற்றியமை'; - - @override - String get adaptive => 'தகவமைப்பு'; - - @override - String get compact => 'சுருக்கமான'; - - @override - String get extended => 'விரிவான'; - - @override - String get theme => 'தீம்'; - - @override - String get dark => 'இருள்'; - - @override - String get light => 'வெளிர்'; - - @override - String get system => 'கணினி வழி'; - - @override - String get accent_color => 'அழுத்த நிறம்'; - - @override - String get sync_album_color => 'ஆல்பம் நிறத்தை ஒத்திசை'; - - @override - String get sync_album_color_description => - 'ஆல்பம் படத்தின் முக்கிய நிறத்தை அழுத்த நிறமாகப் பயன்படுத்துகிறது'; - - @override - String get playback => 'பின்னணி'; - - @override - String get audio_quality => 'ஒலி தரம்'; - - @override - String get high => 'உயர்'; - - @override - String get low => 'குறைந்த'; - - @override - String get pre_download_play => 'முன்பதிவிறக்கம் மற்றும் இயக்கம்'; - - @override - String get pre_download_play_description => - 'ஒலியை ஸ்ட்ரீம் செய்வதற்குப் பதிலாக, பைட்டுகளைப் பதிவிறக்கி இயக்கவும் (அதிக பேண்ட்விட்த் பயனர்களுக்கு பரிந்துரைக்கப்படுகிறது)'; - - @override - String get skip_non_music => 'இசையல்லாத பகுதிகளைத் தவிர் (SponsorBlock)'; - - @override - String get blacklist_description => - 'தடைசெய்யப்பட்ட பாடல்கள் மற்றும் கலைஞர்கள்'; - - @override - String get wait_for_download_to_finish => - 'தற்போதைய பதிவிறக்கம் முடியும் வரை காத்திருக்கவும்'; - - @override - String get desktop => 'கணினி'; - - @override - String get close_behavior => 'மூடும் நடத்தை'; - - @override - String get close => 'மூடு'; - - @override - String get minimize_to_tray => 'ட்ரேயை குறைக்கவும்'; - - @override - String get show_tray_icon => 'ட்ரே ஐகானைக் காட்டு'; - - @override - String get about => 'பற்றி'; - - @override - String get u_love_spotube => - 'நீங்கள் Spotube ஐ நேசிக்கிறீர்கள் என்பது எங்களுக்குத் தெரியும்'; - - @override - String get check_for_updates => 'புதுப்பிப்புகளைச் சரிபார்'; - - @override - String get about_spotube => 'Spotube பற்றி'; - - @override - String get blacklist => 'தடைப்பட்டியல்'; - - @override - String get please_sponsor => 'தயவுசெய்து ஆதரவு/நன்கொடை அளியுங்கள்'; - - @override - String get spotube_description => - 'Spotube, ஒரு லேசான, பல தளங்களில் இயங்கும், அனைவருக்கும் இலவசமான spotify கிளையன்ட்'; - - @override - String get version => 'பதிப்பு'; - - @override - String get build_number => 'கட்டமைப்பு எண்'; - - @override - String get founder => 'நிறுவனர்'; - - @override - String get repository => 'களஞ்சியம்'; - - @override - String get bug_issues => 'பிழை_சிக்கல்கள்'; - - @override - String get made_with => 'வங்காளதேசத்திலிருந்து🇧🇩 ❤️ உருவாக்கப்பட்டது'; - - @override - String get kingkor_roy_tirtho => 'கிங்கர் ராய் திர்தோ'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year கிங்கர் ராய் திர்தோ'; - } - - @override - String get license => 'உரிமம்'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'கவலைப்பட வேண்டாம், உங்கள் சான்றுகள் எதுவும் சேகரிக்கப்படாது அல்லது யாருடனும் பகிரப்படாது'; - - @override - String get know_how_to_login => 'இதை எப்படி செய்வது என்று தெரியவில்லையா?'; - - @override - String get follow_step_by_step_guide => - 'படிப்படியான வழிகாட்டியைப் பின்பற்றவும்'; - - @override - String cookie_name_cookie(Object name) { - return '$name நட்புநிரல்'; - } - - @override - String get fill_in_all_fields => 'அனைத்து களங்களையும் நிரப்பவும்'; - - @override - String get submit => 'சமர்ப்பி'; - - @override - String get exit => 'வெளியேறு'; - - @override - String get previous => 'முந்தைய'; - - @override - String get next => 'அடுத்து'; - - @override - String get done => 'முடிந்தது'; - - @override - String get step_1 => 'முதல் படி'; - - @override - String get first_go_to => 'முதலில், செல்லவேண்டியது'; - - @override - String get something_went_wrong => 'ஏதோ தவறு நடந்துவிட்டது'; - - @override - String get piped_instance => 'Piped சேவையகம் நிகழ்வு'; - - @override - String get piped_description => - 'பாடல் பொருத்தத்திற்குப் பயன்படுத்த வேண்டிய Piped சேவையகம் நிகழ்வு'; - - @override - String get piped_warning => - 'அவற்றில் சில நன்றாக வேலை செய்யாமல் இருக்கலாம். எனவே உங்கள் சொந்த ஆபத்தில் பயன்படுத்தவும்'; - - @override - String get invidious_instance => 'Invidious சேவையக நிகழ்வு'; - - @override - String get invidious_description => - 'பாடல் பொருத்தத்திற்குப் பயன்படுத்த வேண்டிய Invidious சேவையக நிகழ்வு'; - - @override - String get invidious_warning => - 'அவற்றில் சில நன்றாக வேலை செய்யாமல் இருக்கலாம். எனவே உங்கள் சொந்த ஆபத்தில் பயன்படுத்தவும்'; - - @override - String get generate => 'உருவாக்கு'; - - @override - String track_exists(Object track) { - return 'பாடல் $track ஏற்கனவே உள்ளது'; - } - - @override - String get replace_downloaded_tracks => - 'பதிவிறக்கம் செய்யப்பட்ட அனைத்து பாடல்களையும் மாற்றவும்'; - - @override - String get skip_download_tracks => - 'பதிவிறக்கம் செய்யப்பட்ட அனைத்து பாடல்களையும் தவிர்க்கவும்'; - - @override - String get do_you_want_to_replace => - 'ஏற்கனவே உள்ள பாடலை மாற்ற விரும்புகிறீர்களா?'; - - @override - String get replace => 'மாற்று'; - - @override - String get skip => 'தவிர்'; - - @override - String select_up_to_count_type(Object count, Object type) { - return '$count $type வரை தேர்ந்தெடுக்கவும்'; - } - - @override - String get select_genres => 'வகைகளைத் தேர்ந்தெடுக்கவும்'; - - @override - String get add_genres => 'வகைகளைச் சேர்க்கவும்'; - - @override - String get country => 'நாடு'; - - @override - String get number_of_tracks_generate => - 'உருவாக்க வேண்டிய பாடல்களின் எண்ணிக்கை'; - - @override - String get acousticness => 'அகவுஸ்டிக்னெஸ்'; - - @override - String get danceability => 'நடனத்தன்மை'; - - @override - String get energy => 'ஆற்றல்'; - - @override - String get instrumentalness => 'கருவித்தன்மை'; - - @override - String get liveness => 'உயிர்ப்புத்தன்மை'; - - @override - String get loudness => 'ஒலி அளவு'; - - @override - String get speechiness => 'பேச்சுத்தன்மை'; - - @override - String get valence => 'உணர்வு'; - - @override - String get popularity => 'பிரபலம்'; - - @override - String get key => 'இசை குறிப்பு'; - - @override - String get duration => 'கால அளவு (வினாடிகள்)'; - - @override - String get tempo => 'வேகம் (BPM)'; - - @override - String get mode => 'முறை'; - - @override - String get time_signature => 'நேர கையொப்பம்'; - - @override - String get short => 'குறுகிய'; - - @override - String get medium => 'நடுத்தர'; - - @override - String get long => 'நீண்ட'; - - @override - String get min => 'குறைந்தபட்சம்'; - - @override - String get max => 'அதிகபட்சம்'; - - @override - String get target => 'இலக்கு'; - - @override - String get moderate => 'மிதமான'; - - @override - String get deselect_all => 'அனைத்தையும் தேர்வுநீக்கு'; - - @override - String get select_all => 'அனைத்தையும் தேர்ந்தெடு'; - - @override - String get are_you_sure => 'உறுதியாக இருக்கிறீர்களா?'; - - @override - String get generating_playlist => - 'உங்கள் தனிப்பயன்பாட்டிற்கான பாடல் பட்டியலை உருவாக்குகிறது...'; - - @override - String selected_count_tracks(Object count) { - return '$count பாடல்கள் தேர்ந்தெடுக்கப்பட்டன'; - } - - @override - String get download_warning => - 'நீங்கள் அனைத்து பாடல்களையும் மொத்தமாக பதிவிறக்கினால், நீங்கள் தெளிவாக இசையைத் திருடுகிறீர்கள் மற்றும் இசையின் படைப்பாற்றல் சமூகத்திற்கு சேதம் விளைவிக்கிறீர்கள். நீங்கள் இதை அறிந்திருக்கிறீர்கள் என்று நம்புகிறேன். எப்போதும், கலைஞரின் கடின உழைப்பை மதித்து ஆதரிக்க முயற்சி செய்யுங்கள்'; - - @override - String get download_ip_ban_warning => - 'மேலும், அதிகப்படியான பதிவிறக்க கோரிக்கைகள் காரணமாக உங்கள் IP YouTube இல் தடைசெய்யப்படலாம். IP தடை என்பது குறைந்தது 2-3 மாதங்களுக்கு அந்த IP சாதனத்திலிருந்து YouTube ஐப் பயன்படுத்த முடியாது (நீங்கள் உள்நுழைந்திருந்தாலும் கூட). இது ஒருபோதும் நடந்தால் Spotube பொறுப்பேற்காது'; - - @override - String get by_clicking_accept_terms => - '\'ஏற்றுக்கொள்\' என்பதைக் கிளிக் செய்வதன் மூலம் பின்வரும் விதிமுறைகளுக்கு நீங்கள் ஒப்புக்கொள்கிறீர்கள்:'; - - @override - String get download_agreement_1 => - 'நான் இசையைத் திருடுகிறேன் என்பது எனக்குத் தெரியும். நான் கெட்டவன்'; - - @override - String get download_agreement_2 => - 'நான் கலைஞரை முடிந்தவரை ஆதரிப்பேன், அவர்களின் கலைக்கு பணம் செலுத்த எனக்கு பணம் இல்லாததால் மட்டுமே இதைச் செய்கிறேன்'; - - @override - String get download_agreement_3 => - 'என் IP YouTube இல் தடைசெய்யப்படலாம் என்பதை நான் முழுமையாக அறிவேன், மேலும் என் தற்போதைய செயலால் ஏற்படும் எந்த விபத்துகளுக்கும் Spotube அல்லது அதன் உரிமையாளர்கள்/பங்களிப்பாளர்களை பொறுப்பாக்க மாட்டேன்'; - - @override - String get decline => 'மறு'; - - @override - String get accept => 'ஏற்றுக்கொள்'; - - @override - String get details => 'விவரங்கள்'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'சேனல்'; - - @override - String get likes => 'விருப்பங்கள்'; - - @override - String get dislikes => 'விருப்பமில்லாதவை'; - - @override - String get views => 'பார்வைகள்'; - - @override - String get streamUrl => 'ஸ்ட்ரீம் URL'; - - @override - String get stop => 'நிறுத்து'; - - @override - String get sort_newest => 'புதிதாக சேர்க்கப்பட்டவற்றை வரிசைப்படுத்து'; - - @override - String get sort_oldest => 'பழமையானவற்றை வரிசைப்படுத்து'; - - @override - String get sleep_timer => 'உறக்க நேரம்'; - - @override - String mins(Object minutes) { - return '$minutes நிமிடங்கள்'; - } - - @override - String hours(Object hours) { - return '$hours மணிநேரங்கள்'; - } - - @override - String hour(Object hours) { - return '$hours மணிநேரம்'; - } - - @override - String get custom_hours => 'தனிப்பயன் மணிநேரங்கள்'; - - @override - String get logs => 'பதிவுகள்'; - - @override - String get developers => 'உருவாக்குநர்கள்'; - - @override - String get not_logged_in => 'நீங்கள் உள்நுழையவில்லை'; - - @override - String get search_mode => 'தேடல் முறை'; - - @override - String get audio_source => 'ஒலி மூலம்'; - - @override - String get ok => 'சரி'; - - @override - String get failed_to_encrypt => 'குறியாக்கம் தோல்வியடைந்தது'; - - @override - String get encryption_failed_warning => - 'Spotube உங்கள் தரவை பாதுகாப்பாக சேமிக்க குறியாக்கத்தைப் பயன்படுத்துகிறது. ஆனால் அவ்வாறு செய்ய முடியவில்லை. எனவே இது பாதுகாப்பற்ற சேமிப்பகத்திற்கு மாறும்\nநீங்கள் லினக்ஸ் பயன்படுத்துகிறீர்கள் என்றால், எந்த ரகசிய சேவையும் (gnome-keyring, kde-wallet, keepassxc போன்றவை) நிறுவப்பட்டுள்ளதா என்பதை உறுதிப்படுத்தவும்'; - - @override - String get querying_info => 'தகவலைக் கேட்கிறது...'; - - @override - String get piped_api_down => 'Piped API செயலிழந்துள்ளது'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'Piped நிகழ்வு $pipedInstance தற்போது செயலிழந்துள்ளது\n\nநிகழ்வை மாற்றவும் அல்லது \'API வகை\'யை அதிகாரப்பூர்வ YouTube API க்கு மாற்றவும்\n\nமாற்றத்திற்குப் பிறகு பயன்பாட்டை மறுதொடக்கம் செய்வதை உறுதிப்படுத்தவும்'; - } - - @override - String get you_are_offline => 'நீங்கள் தற்போது ஆஃப்லைனில் உள்ளீர்கள்'; - - @override - String get connection_restored => 'உங்கள் இணைய இணைப்பு மீட்டெடுக்கப்பட்டது'; - - @override - String get use_system_title_bar => 'கணினி தலைப்புப் பட்டியைப் பயன்படுத்தவும்'; - - @override - String get crunching_results => 'முடிவுகளை செயலாக்குகிறது...'; - - @override - String get search_to_get_results => 'முடிவுகளைப் பெற தேடவும்'; - - @override - String get use_amoled_mode => 'கருமை நிற இருண்ட தீம்'; - - @override - String get pitch_dark_theme => 'AMOLED முறை'; - - @override - String get normalize_audio => 'ஒலியை சீரமை'; - - @override - String get change_cover => 'அட்டையை மாற்று'; - - @override - String get add_cover => 'அட்டையைச் சேர்'; - - @override - String get restore_defaults => 'இயல்புநிலைகளை மீட்டமை'; - - @override - String get download_music_format => 'இசை பதிவிறக்க வடிவம்'; - - @override - String get streaming_music_format => 'இசை ஸ்ட்ரீமிங் வடிவம்'; - - @override - String get download_music_quality => 'பதிவிறக்க தரம்'; - - @override - String get streaming_music_quality => 'ஸ்ட்ரீமிங் தரம்'; - - @override - String get login_with_lastfm => 'Last.fm உடன் உள்நுழைக'; - - @override - String get connect => 'இணை'; - - @override - String get disconnect_lastfm => 'Last.fm இலிருந்து துண்டி'; - - @override - String get disconnect => 'துண்டி'; - - @override - String get username => 'பயனர்பெயர்'; - - @override - String get password => 'கடவுச்சொல்'; - - @override - String get login => 'உள்நுழைக'; - - @override - String get login_with_your_lastfm => 'உங்கள் Last.fm கணக்குடன் உள்நுழைக'; - - @override - String get scrobble_to_lastfm => 'Last.fm க்கு ஸ்க்ரோபிள் செய்'; - - @override - String get go_to_album => 'ஆல்பத்திற்குச் செல்'; - - @override - String get discord_rich_presence => 'Discord செழுமையான தோற்றம்'; - - @override - String get browse_all => 'அனைத்தையும் உலாவு'; - - @override - String get genres => 'வகைகள்'; - - @override - String get explore_genres => 'வகைகளை ஆராயுங்கள்'; - - @override - String get friends => 'நண்பர்கள்'; - - @override - String get no_lyrics_available => - 'மன்னிக்கவும், இந்தப் பாடலுக்கான பாடல் வரிகளைக் கண்டுபிடிக்க முடியவில்லை'; - - @override - String get start_a_radio => 'வானொலியைத் தொடங்கு'; - - @override - String get how_to_start_radio => 'வானொலியை எவ்வாறு தொடங்க விரும்புகிறீர்கள்?'; - - @override - String get replace_queue_question => - 'தற்போதைய வரிசையை மாற்ற விரும்புகிறீர்களா அல்லது அதனுடன் சேர்க்க விரும்புகிறீர்களா?'; - - @override - String get endless_playback => 'முடிவற்ற இயக்கம்'; - - @override - String get delete_playlist => 'பாடல் பட்டியலை நீக்கு'; - - @override - String get delete_playlist_confirmation => - 'இந்த பாடல் பட்டியலை நீக்க விரும்புகிறீர்களா?'; - - @override - String get local_tracks => 'உள்ளூர் பாடல்கள்'; - - @override - String get local_tab => 'உள்ளூர்'; - - @override - String get song_link => 'பாடல் இணைப்பு'; - - @override - String get skip_this_nonsense => 'இந்த அர்த்தமற்றதைத் தவிர்'; - - @override - String get freedom_of_music => '\"இசையின் சுதந்திரம்\"'; - - @override - String get freedom_of_music_palm => '\"உங்கள் கைகளில் இசையின் சுதந்திரம்\"'; - - @override - String get get_started => 'தொடங்குவோம்'; - - @override - String get youtube_source_description => - 'பரிந்துரைக்கப்படுகிறது மற்றும் சிறப்பாக செயல்படுகிறது.'; - - @override - String get piped_source_description => - 'சுதந்திரமாக உணர்கிறீர்களா? YouTube போலவே ஆனால் மிகவும் சுதந்திரமானது.'; - - @override - String get jiosaavn_source_description => - 'தெற்காசியப் பிராந்தியத்திற்கு சிறந்தது.'; - - @override - String get invidious_source_description => - 'Piped ஐப் போன்றது ஆனால் அதிக கிடைக்கும் தன்மையுடன்.'; - - @override - String highest_quality(Object quality) { - return 'உயர்ந்த தரம்: $quality'; - } - - @override - String get select_audio_source => 'ஒலி மூலத்தைத் தேர்ந்தெடுக்கவும்'; - - @override - String get endless_playback_description => - 'வரிசையின் இறுதியில் புதிய பாடல்களை\nதானாகவே சேர்க்கவும்'; - - @override - String get choose_your_region => 'உங்கள் பிராந்தியத்தைத் தேர்ந்தெடுக்கவும்'; - - @override - String get choose_your_region_description => - 'இது உங்கள் இருப்பிடத்திற்கான சரியான உள்ளடக்கத்தை\nSpotube காட்ட உதவும்.'; - - @override - String get choose_your_language => 'உங்கள் மொழியைத் தேர்ந்தெடுக்கவும்'; - - @override - String get help_project_grow => 'இந்த திட்டம் வளர உதவுங்கள்'; - - @override - String get help_project_grow_description => - 'Spotube ஒரு திறந்த மூல திட்டம். திட்டத்திற்கு பங்களிப்பு செய்வதன் மூலம், பிழைகளைப் புகாரளிப்பதன் மூலம் அல்லது புதிய அம்சங்களைப் பரிந்துரைப்பதன் மூலம் இந்தத் திட்டம் வளர உதவலாம்.'; - - @override - String get contribute_on_github => 'GitHub இல் பங்களியுங்கள்'; - - @override - String get donate_on_open_collective => - 'Open Collective இல் நன்கொடை அளியுங்கள்'; - - @override - String get browse_anonymously => 'அநாமதேயமாக உலாவுக'; - - @override - String get enable_connect => 'இணைப்பை இயக்கு'; - - @override - String get enable_connect_description => - 'மற்ற சாதனங்களிலிருந்து Spotube ஐக் கட்டுப்படுத்தவும்'; - - @override - String get devices => 'சாதனங்கள்'; - - @override - String get select => 'தேர்ந்தெடு'; - - @override - String connect_client_alert(Object client) { - return 'நீங்கள் $client ஆல் கட்டுப்படுத்தப்படுகிறீர்கள்'; - } - - @override - String get this_device => 'இந்த சாதனம்'; - - @override - String get remote => 'தொலைநிலை'; - - @override - String get stats => 'புள்ளிவிவரங்கள்'; - - @override - String and_n_more(Object count) { - return 'மற்றும் $count கூடுதலாக'; - } - - @override - String get recently_played => 'சமீபத்தில் இயக்கியவை'; - - @override - String get browse_more => 'மேலும் உலாவு'; - - @override - String get no_title => 'தலைப்பு இல்லை'; - - @override - String get not_playing => 'இயக்கப்படவில்லை'; - - @override - String get epic_failure => 'மோசமான தோல்வி!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return '$tracks_length பாடல்கள் வரிசையில் சேர்க்கப்பட்டன'; - } - - @override - String get spotube_has_an_update => 'Spotube க்கு ஒரு புதுப்பிப்பு உள்ளது'; - - @override - String get download_now => 'இப்போது பதிவிறக்கு'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum வெளியிடப்பட்டுள்ளது'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version வெளியிடப்பட்டுள்ளது'; - } - - @override - String get read_the_latest => 'சமீபத்திய '; - - @override - String get release_notes => 'வெளியீட்டு குறிப்புகளைப் படிக்கவும்'; - - @override - String get pick_color_scheme => 'வண்ணத் திட்டத்தைத் தேர்ந்தெடுக்கவும்'; - - @override - String get save => 'சேமி'; - - @override - String get choose_the_device => 'சாதனத்தைத் தேர்ந்தெடுக்கவும்:'; - - @override - String get multiple_device_connected => - 'பல சாதனங்கள் இணைக்கப்பட்டுள்ளன.\nஇந்த செயல் நடைபெற வேண்டிய சாதனத்தைத் தேர்ந்தெடுக்கவும்'; - - @override - String get nothing_found => 'எதுவும் கிடைக்கவில்லை'; - - @override - String get the_box_is_empty => 'பெட்டி காலியாக உள்ளது'; - - @override - String get top_artists => 'சிறந்த கலைஞர்கள்'; - - @override - String get top_albums => 'சிறந்த ஆல்பங்கள்'; - - @override - String get this_week => 'இந்த வாரம்'; - - @override - String get this_month => 'இந்த மாதம்'; - - @override - String get last_6_months => 'கடந்த 6 மாதங்கள்'; - - @override - String get this_year => 'இந்த ஆண்டு'; - - @override - String get last_2_years => 'கடந்த 2 ஆண்டுகள்'; - - @override - String get all_time => 'எல்லா நேரமும்'; - - @override - String powered_by_provider(Object providerName) { - return '$providerName ஆல் இயக்கப்படுகிறது'; - } - - @override - String get email => 'மின்னஞ்சல்'; - - @override - String get profile_followers => 'பின்தொடர்பவர்கள்'; - - @override - String get birthday => 'பிறந்த நாள்'; - - @override - String get subscription => 'சந்தா'; - - @override - String get not_born => 'பிறக்கவில்லை'; - - @override - String get hacker => 'ஹேக்கர்'; - - @override - String get profile => 'சுயவிவரம்'; - - @override - String get no_name => 'பெயர் இல்லை'; - - @override - String get edit => 'திருத்து'; - - @override - String get user_profile => 'பயனர் சுயவிவரம்'; - - @override - String count_plays(Object count) { - return '$count முறை இசைக்கப்பட்டது'; - } - - @override - String get streaming_fees_hypothetical => 'ஸ்ட்ரீமிங் கட்டணங்கள் (கற்பனை)'; - - @override - String get minutes_listened => 'காலம் கேட்டது'; - - @override - String get streamed_songs => 'ஸ்ட்ரீமிங் செய்யப்பட்ட பாடல்கள்'; - - @override - String count_streams(Object count) { - return '$count ஸ்ட்ரீம்கள்'; - } - - @override - String get owned_by_you => 'உங்களால் கொண்டது'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return 'நகலெடுக்கப்பட்டது $shareUrl கிளிப்போர்டுக்காக'; - } - - @override - String get hipotetical_calculation => - '*இது சராசரி ஆன்லைன் இசை ஸ்ட்ரீமிங் தளத்தின் ஒரு ஸ்ட்ரீமிற்கான \$0.003 முதல் \$0.005 வரையிலான கட்டணத்தின் அடிப்படையில் கணக்கிடப்படுகிறது. இது ஒரு கற்பனையான கணக்கீடு ஆகும், இது பயனர்கள் வெவ்வேறு இசை ஸ்ட்ரீமிங் தளங்களில் தங்கள் பாடல்களைக் கேட்டால் கலைஞர்களுக்கு எவ்வளவு பணம் செலுத்தியிருப்பார்கள் என்பது குறித்த நுண்ணறிவை வழங்குகிறது.'; - - @override - String count_mins(Object minutes) { - return '$minutes நிமிடங்கள்'; - } - - @override - String get summary_minutes => 'நிமிடங்கள்'; - - @override - String get summary_listened_to_music => 'இசை கேட்டது'; - - @override - String get summary_songs => 'பாடல்கள்'; - - @override - String get summary_streamed_overall => 'மொத்தமாக ஸ்ட்ரீமிங்'; - - @override - String get summary_owed_to_artists => 'கலைஞர்களுக்கு\nஇந்த மாதம் சொந்தமானது'; - - @override - String get summary_artists => 'கலைஞர்கள்'; - - @override - String get summary_music_reached_you => 'இசை உங்களுக்கு வந்தது'; - - @override - String get summary_full_albums => 'முழு ஆல்பங்கள்'; - - @override - String get summary_got_your_love => 'உங்கள் அன்பை பெற்றுக்கொண்டேன்'; - - @override - String get summary_playlists => 'பாடல் பட்டியல்கள்'; - - @override - String get summary_were_on_repeat => 'மீண்டும் மீண்டும் இருந்தன'; - - @override - String total_money(Object money) { - return 'மொத்தம் $money'; - } - - @override - String get webview_not_found => 'வெப்வியூ கிடைக்கவில்லை'; - - @override - String get webview_not_found_description => - 'உங்கள் சாதனத்தில் எந்தவொரு வெப்வியூ இயக்கத்தை நிறுவவில்லை.\nஇது நிறுவப்பட்டிருந்தால், சுற்றுச்சூழல் பாதையில் PATH உள்ளது என்பதை உறுதிபடுத்தவும்\n\nநிறுவித்த பிறகு, செயலியை மறுதொடக்கம் செய்யவும்'; - - @override - String get unsupported_platform => 'அதிர்ஷ்டகாத உருப்படியை ஆதரிக்கவில்லை'; - - @override - String get cache_music => 'இசையை கேஷ் செய்'; - - @override - String get open => 'திறக்கவும்'; - - @override - String get cache_folder => 'கேஷ் அடைவு'; - - @override - String get export => 'ஏற்றுமதி'; - - @override - String get clear_cache => 'கேஷ் அழிக்கவும்'; - - @override - String get clear_cache_confirmation => 'கேஷைப் அழிக்க விரும்புகிறீர்களா?'; - - @override - String get export_cache_files => 'கேஷில் உள்ள கோப்புகளை ஏற்றுமதி செய்யவும்'; - - @override - String found_n_files(Object count) { - return '$count கோப்புகள் கிடைத்தன'; - } - - @override - String get export_cache_confirmation => - 'இந்த கோப்புகளை ஏற்றுமதி செய்ய விரும்புகிறீர்களா?'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return '$filesExported கோப்புகள் ஏற்றுமதி செய்யப்பட்டன, $files கோப்புகளில்'; - } - - @override - String get undo => 'செயல்தவிர்'; - - @override - String get download_all => 'அனைத்தையும் பதிவிறக்குக'; - - @override - String get add_all_to_playlist => 'அனைத்தையும் பாடல் பட்டியலில் சேர்க்கவும்'; - - @override - String get add_all_to_queue => 'அனைத்தையும் வரிசைப்படுத்து'; - - @override - String get play_all_next => 'அடுத்த உள்ள அனைத்தையும் இயக்கு'; - - @override - String get pause => 'நிறுத்து'; - - @override - String get view_all => 'அனைத்தையும் காண்க'; - - @override - String get no_tracks_added_yet => - 'உங்கள் பாடல்களை இன்னும் சேர்க்கவில்லை என்றால் தெரியாதே'; - - @override - String get no_tracks => 'இங்கு பாடல்கள் எதுவும் இல்லை'; - - @override - String get no_tracks_listened_yet => 'இன்னும் எதையும் கேள்வியில்லை'; - - @override - String get not_following_artists => 'நீங்கள் எந்த கலைஞரையும் பின்தொடரவில்லை'; - - @override - String get no_favorite_albums_yet => - 'நீங்கள் இன்னும் எந்த ஆல்பங்களையும் பிடித்தவையாகச் சேர்க்கவில்லை'; - - @override - String get no_logs_found => 'பதிவுகள் எதுவும் கிடைக்கவில்லை'; - - @override - String get youtube_engine => 'YouTube இயந்திரம்'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine நிறுவியதில்லை'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine உங்கள் கணினியில் நிறுவியதில்லை.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'PATH மாறியில் கிடைக்கிறதா என்பதை உறுதிப்படுத்தவும் அல்லது\n$engine செயல் செய்யக்கூடிய முறையை கீழே அமைக்கவும்'; - } - - @override - String get youtube_engine_unix_issue_message => - 'macOS/Linux/unix போல் OS இல், .zshrc/.bashrc/.bash_profile போன்றவை அமைப்பில் பாதையை PATH அமைப்பது இயலாது.\nநீங்கள்.shell configuration file இல் பாதையை அமைக்க வேண்டும்'; - - @override - String get download => 'பதிவிறக்கு'; - - @override - String get file_not_found => 'கோப்பு கிடைக்கவில்லை'; - - @override - String get custom => 'தனிப்பயன்'; - - @override - String get add_custom_url => 'தனிப்பயன் URL ஐச் சேர்க்கவும்'; - - @override - String get edit_port => 'போர்டு திருத்தவும்'; - - @override - String get port_helper_msg => - 'இயல்புநிலை -1 ஆகும், இது சீரற்ற எண்ணை குறிக்கிறது. நீங்கள் தீயணைப்பு அமைக்கப்பட்டிருந்தால், இதை அமைப்பது பரிந்துரைக்கப்படுகிறது.'; - - @override - String connect_request(Object client) { - return '$client க்கு இணைக்க அனுமதிக்கவா?'; - } - - @override - String get connection_request_denied => - 'இணைப்பு மறுக்கப்பட்டது. பயனர் அணுகலை மறுத்தார்.'; - - @override - String get an_error_occurred => 'ஒரு பிழை ஏற்பட்டது'; - - @override - String get copy_to_clipboard => 'கிளிப்போர்டுக்கு நகலெடுக்கவும்'; - - @override - String get view_logs => 'பதிவுகளைப் பார்க்கவும்'; - - @override - String get retry => 'மீண்டும் முயற்சிக்கவும்'; - - @override - String get no_default_metadata_provider_selected => - 'நீங்கள் எந்த இயல்புநிலை மெட்டாடேட்டா வழங்குநரையும் அமைக்கவில்லை'; - - @override - String get manage_metadata_providers => - 'மெட்டாடேட்டா வழங்குநர்களை நிர்வகிக்கவும்'; - - @override - String get open_link_in_browser => 'இணைப்பை உலாவியில் திறக்கவா?'; - - @override - String get do_you_want_to_open_the_following_link => - 'பின்வரும் இணைப்பை நீங்கள் திறக்க விரும்புகிறீர்களா'; - - @override - String get unsafe_url_warning => - 'நம்பத்தகாத மூலங்களிலிருந்து இணைப்புகளைத் திறப்பது பாதுகாப்பற்றதாக இருக்கலாம். எச்சரிக்கையாக இருங்கள்!\nநீங்கள் இணைப்பை உங்கள் கிளிப்போர்டுக்கு நகலெடுக்கலாம்.'; - - @override - String get copy_link => 'இணைப்பை நகலெடுக்கவும்'; - - @override - String get building_your_timeline => - 'உங்கள் கேட்டலின் அடிப்படையில் உங்கள் காலவரிசையை உருவாக்குகிறது...'; - - @override - String get official => 'அதிகாரபூர்வமானது'; - - @override - String author_name(Object author) { - return 'ஆசிரியர்: $author'; - } - - @override - String get third_party => 'மூன்றாம் தரப்பு'; - - @override - String get plugin_requires_authentication => - 'பிளகின் அங்கீகாரத்தைக் கோருகிறது'; - - @override - String get update_available => 'புதுப்பிப்பு உள்ளது'; - - @override - String get supports_scrobbling => 'ஸ்க்ரோப்ளிங்கை ஆதரிக்கிறது'; - - @override - String get plugin_scrobbling_info => - 'இந்த பிளகின் உங்கள் கேட்பதின் வரலாற்றை உருவாக்க உங்கள் இசையை ஸ்க்ரோப்ள் செய்கிறது.'; - - @override - String get default_metadata_source => 'இயல்புநிலை மெட்டாடேட்டா மூலம்'; - - @override - String get set_default_metadata_source => - 'இயல்புநிலை மெட்டாடேட்டா மூலத்தை அமை'; - - @override - String get default_audio_source => 'இயல்புநிலை ஆடியோ மூலம்'; - - @override - String get set_default_audio_source => 'இயல்புநிலை ஆடியோ மூலத்தை அமை'; - - @override - String get set_default => 'இயல்புநிலையாக அமைக்கவும்'; - - @override - String get support => 'ஆதரவு'; - - @override - String get support_plugin_development => 'பிளகின் வளர்ச்சிக்கு ஆதரவு'; - - @override - String can_access_name_api(Object name) { - return '- **$name** API ஐ அணுக முடியும்'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'இந்த பிளகினை நீங்கள் நிறுவ விரும்புகிறீர்களா?'; - - @override - String get third_party_plugin_warning => - 'இந்த பிளகின் மூன்றாம் தரப்பு களஞ்சியத்திலிருந்து வருகிறது. நிறுவும் முன் மூலத்தை நீங்கள் நம்புகிறீர்கள் என்பதை உறுதிப்படுத்தவும்.'; - - @override - String get author => 'ஆசிரியர்'; - - @override - String get this_plugin_can_do_following => - 'இந்த பிளகின் பின்வருவனவற்றைச் செய்ய முடியும்'; - - @override - String get install => 'நிறுவவும்'; - - @override - String get install_a_metadata_provider => 'மெட்டாடேட்டா வழங்குநரை நிறுவவும்'; - - @override - String get no_tracks_playing => 'தற்போது எந்த பாடலும் இயங்கவில்லை'; - - @override - String get synced_lyrics_not_available => - 'இந்த பாடலுக்கு ஒத்திசைக்கப்பட்ட வரிகள் கிடைக்கவில்லை. தயவுசெய்து'; - - @override - String get plain_lyrics => 'சாதாரண வரிகள்'; - - @override - String get tab_instead => 'தாவலை அதற்கு பதிலாக பயன்படுத்தவும்.'; - - @override - String get disclaimer => 'துறப்பு'; - - @override - String get third_party_plugin_dmca_notice => - 'ஸ்பாட்யூப் குழு எந்த \"மூன்றாம் தரப்பு\" பிளகின்களுக்கும் எந்தப் பொறுப்பையும் (சட்டரீதியான உட்பட) ஏற்காது.\nதயவுசெய்து உங்கள் சொந்த ஆபத்தில் அவற்றைப் பயன்படுத்தவும். ஏதேனும் பிழைகள்/சிக்கல்களுக்கு, பிளகின் களஞ்சியத்தில் அவற்றைப் புகாரளிக்கவும்.\n\nஏதேனும் ஒரு \"மூன்றாம் தரப்பு\" பிளகின் ஒரு சேவை/சட்ட நிறுவனத்தின் ToS/DMCA ஐ மீறினால், தயவுசெய்து \"மூன்றாம் தரப்பு\" பிளகின் ஆசிரியரையோ அல்லது ஹோஸ்டிங் தளத்தையோ, எ.கா. GitHub/Codeberg, நடவடிக்கை எடுக்கக் கோரவும். மேலே பட்டியலிடப்பட்ட (\"மூன்றாம் தரப்பு\" என பெயரிடப்பட்ட) அனைத்து பொதுவான/சமூகத்தால் பராமரிக்கப்படும் பிளகின்கள். நாங்கள் அவற்றை க்யூரேட் செய்யவில்லை, எனவே அவற்றின் மீது எந்த நடவடிக்கையும் எடுக்க முடியாது.\n\n'; - - @override - String get input_does_not_match_format => - 'உள்ளீடு தேவையான வடிவத்துடன் பொருந்தவில்லை'; - - @override - String get plugins => 'செருகுநிரல்கள்'; - - @override - String get paste_plugin_download_url => - 'பதிவிறக்க url அல்லது GitHub/Codeberg repo url அல்லது .smplug கோப்பிற்கான நேரடி இணைப்பை ஒட்டவும்'; - - @override - String get download_and_install_plugin_from_url => - 'url இலிருந்து பிளகினைப் பதிவிறக்கி நிறுவவும்'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'பிளகினைச் சேர்க்கத் தவறிவிட்டது: $error'; - } - - @override - String get upload_plugin_from_file => 'கோப்பிலிருந்து பிளகினைப் பதிவேற்றவும்'; - - @override - String get installed => 'நிறுவப்பட்டது'; - - @override - String get available_plugins => 'கிடைக்கக்கூடிய பிளகின்கள்'; - - @override - String get configure_plugins => - 'உங்கள் சொந்த மெட்டாடேட்டா வழங்குநர் மற்றும் ஆடியோ மூல செருகுநிரல்களை அமைக்கவும்'; - - @override - String get audio_scrobblers => 'ஆடியோ ஸ்க்ரோப்ளர்கள்'; - - @override - String get scrobbling => 'ஸ்க்ரோப்ளிங்'; - - @override - String get source => 'மூலம்: '; - - @override - String get uncompressed => 'அழுத்தப்படாத'; - - @override - String get dab_music_source_description => - 'ஆடியோஃபைல்களுக்காக. உயர்தர/லாஸ்லெஸ் ஆடியோ ஸ்ட்ரீம்களை வழங்குகிறது. ISRC அடிப்படையில் துல்லியமான பாடல் பொருத்தம்.'; -} diff --git a/lib/l10n/generated/app_localizations_th.dart b/lib/l10n/generated/app_localizations_th.dart deleted file mode 100644 index 16584ab8..00000000 --- a/lib/l10n/generated/app_localizations_th.dart +++ /dev/null @@ -1,1561 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Thai (`th`). -class AppLocalizationsTh extends AppLocalizations { - AppLocalizationsTh([String locale = 'th']) : super(locale); - - @override - String get guest => 'ผู้มาเยือน'; - - @override - String get browse => 'เรียกดู'; - - @override - String get search => 'ค้นหา'; - - @override - String get library => 'คลัง'; - - @override - String get lyrics => 'เนื้อเพลง'; - - @override - String get settings => 'ตั้งค่า'; - - @override - String get genre_categories_filter => 'กรองประเภทหรือแนวเพลง...'; - - @override - String get genre => 'ประเภท'; - - @override - String get personalized => 'ปรับแต่ง'; - - @override - String get featured => 'เด่น'; - - @override - String get new_releases => 'เพิ่งปล่อยใหม่'; - - @override - String get songs => 'เพลง'; - - @override - String playing_track(Object track) { - return 'กำลังเล่น $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'การดำเนินการนี้จะล้างคิวปัจจุบัน $track_length แทร็ก จะถูกลบออก\nคุณต้องการดำเนินการต่อหรือไม่?'; - } - - @override - String get load_more => 'โหลดเพิ่มเติม'; - - @override - String get playlists => 'เพลย์ลิสต์'; - - @override - String get artists => 'ศิลปิน'; - - @override - String get albums => 'อัลบั้ม'; - - @override - String get tracks => 'แทร็ก'; - - @override - String get downloads => 'ดาวน์โหลด'; - - @override - String get filter_playlists => 'กรองเพลย์ลิสต์...'; - - @override - String get liked_tracks => 'เพลงที่ชอบ'; - - @override - String get liked_tracks_description => 'เพลงที่คุณชื่นชอบทั้งหมด'; - - @override - String get playlist => 'เพลย์ลิสต์'; - - @override - String get create_a_playlist => 'สร้างเพลย์ลิสต์'; - - @override - String get update_playlist => 'อัพเดทเพลย์ลิสต์'; - - @override - String get create => 'สร้าง'; - - @override - String get cancel => 'ยกเลิก'; - - @override - String get update => 'อัพเดท'; - - @override - String get playlist_name => 'ชื่อเพลย์ลิสต์'; - - @override - String get name_of_playlist => 'ชื่อของเพลย์ลิสต์'; - - @override - String get description => 'คำอธิบาย'; - - @override - String get public => 'สาธารณะ'; - - @override - String get collaborative => 'ร่วมมือกัน'; - - @override - String get search_local_tracks => 'ค้นหาเพลงในเครื่อง...'; - - @override - String get play => 'เล่น'; - - @override - String get delete => 'ลบ'; - - @override - String get none => 'ไม่มี'; - - @override - String get sort_a_z => 'เรียงตาม A-Z'; - - @override - String get sort_z_a => 'เรียงตาม Z-A'; - - @override - String get sort_artist => 'เรียงตามศิลปิน'; - - @override - String get sort_album => 'เรียงตามอัลบั้ม'; - - @override - String get sort_duration => 'เรียงตามความยาว'; - - @override - String get sort_tracks => 'เรียงตามเพลง'; - - @override - String currently_downloading(Object tracks_length) { - return 'กำลังดาวน์โหลด ($tracks_length)'; - } - - @override - String get cancel_all => 'ยกเลิกทั้งหมด'; - - @override - String get filter_artist => 'กรองศิลปิน...'; - - @override - String followers(Object followers) { - return '$followers ผู้ติดตาม'; - } - - @override - String get add_artist_to_blacklist => 'เพิ่มศิลปินในบัญชีดำ'; - - @override - String get top_tracks => 'เพลงฮิต'; - - @override - String get fans_also_like => 'แฟนๆ ยังชอบ'; - - @override - String get loading => 'กำลังโหลด...'; - - @override - String get artist => 'ศิลปิน'; - - @override - String get blacklisted => 'อยู่ในบัญชีดำ'; - - @override - String get following => 'กำลังติดตาม'; - - @override - String get follow => 'ติดตาม'; - - @override - String get artist_url_copied => 'คัดลอก URL ศิลปินไปยังคลิปบอร์ด'; - - @override - String added_to_queue(Object tracks) { - return 'เพิ่ม $tracks เพลงลงในคิว'; - } - - @override - String get filter_albums => 'กรองอัลบั้ม...'; - - @override - String get synced => 'ซิงค์'; - - @override - String get plain => 'เรียบง่าย'; - - @override - String get shuffle => 'สุ่ม'; - - @override - String get search_tracks => 'ค้นหาเพลง...'; - - @override - String get released => 'เผยแพร่'; - - @override - String error(Object error) { - return 'ข้อผิดพลาด $error'; - } - - @override - String get title => 'ชื่อ'; - - @override - String get time => 'เวลา'; - - @override - String get more_actions => 'เพิ่มเติม'; - - @override - String download_count(Object count) { - return 'ดาวน์โหลด ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'เพิ่ม ($count) ลงในเพลย์ลิสต์'; - } - - @override - String add_count_to_queue(Object count) { - return 'เพิ่ม ($count) ลงในคิว'; - } - - @override - String play_count_next(Object count) { - return 'เล่น ($count) ต่อไป'; - } - - @override - String get album => 'อัลบั้ม'; - - @override - String copied_to_clipboard(Object data) { - return 'คัดลอก $data ไปยังคลิปบอร์ด'; - } - - @override - String add_to_following_playlists(Object track) { - return 'เพิ่ม $track ลงในเพลย์ลิสต์'; - } - - @override - String get add => 'เพิ่ม'; - - @override - String added_track_to_queue(Object track) { - return 'เพิ่ม $track ลงในคิว'; - } - - @override - String get add_to_queue => 'เพิ่มลงในคิว'; - - @override - String track_will_play_next(Object track) { - return '$track จะเล่นต่อไป'; - } - - @override - String get play_next => 'เล่นต่อไป'; - - @override - String removed_track_from_queue(Object track) { - return 'ลบ $track ออกจากคิว'; - } - - @override - String get remove_from_queue => 'ลบออกจากคิว'; - - @override - String get remove_from_favorites => 'ลบออกจากรายการโปรด'; - - @override - String get save_as_favorite => 'บันทึกเป็นรายการโปรด'; - - @override - String get add_to_playlist => 'เพิ่มลงในเพลย์ลิสต์'; - - @override - String get remove_from_playlist => 'ลบออกจากเพลย์ลิสต์'; - - @override - String get add_to_blacklist => 'เพิ่มลงในบัญชีดำ'; - - @override - String get remove_from_blacklist => 'ลบออกจากบัญชีดำ'; - - @override - String get share => 'แชร์'; - - @override - String get mini_player => 'มินิเพลเยอร์'; - - @override - String get slide_to_seek => 'เลื่อนเพื่อไปข้างหน้าหรือถอยหลัง'; - - @override - String get shuffle_playlist => 'สุ่มเพลย์ลิสต์'; - - @override - String get unshuffle_playlist => 'ยกเลิกการสุ่มเพลย์ลิสต์'; - - @override - String get previous_track => 'แทร็กก่อนหน้า'; - - @override - String get next_track => 'แทร็กถัดไป'; - - @override - String get pause_playback => 'หยุดการเล่น'; - - @override - String get resume_playback => 'เล่นต่อ'; - - @override - String get loop_track => 'วนเพลง'; - - @override - String get no_loop => 'ไม่มีการวนซ้ำ'; - - @override - String get repeat_playlist => 'ซ้ำเพลย์ลิสต์'; - - @override - String get queue => 'คิว'; - - @override - String get alternative_track_sources => 'แหล่งแทร็กอื่น'; - - @override - String get download_track => 'ดาวน์โหลดแทร็ก'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks แทร็กในคิว'; - } - - @override - String get clear_all => 'ล้างทั้งหมด'; - - @override - String get show_hide_ui_on_hover => 'แสดง/ซ่อน UI เมื่อโฮเวอร์'; - - @override - String get always_on_top => 'อยู่ด้านบนเสมอ'; - - @override - String get exit_mini_player => 'ออกจากมินิเพลย์เยอร์'; - - @override - String get download_location => 'ตำแหน่งดาวน์โหลด'; - - @override - String get local_library => 'ห้องสมุดท้องถิ่น'; - - @override - String get add_library_location => 'เพิ่มในห้องสมุด'; - - @override - String get remove_library_location => 'ลบออกจากห้องสมุด'; - - @override - String get account => 'บัญชี'; - - @override - String get logout => 'ออกจากระบบ'; - - @override - String get logout_of_this_account => 'ออกจากระบบบัญชีนี้'; - - @override - String get language_region => 'ภาษาและภูมิภาค'; - - @override - String get language => 'ภาษา'; - - @override - String get system_default => 'ค่าเริ่มต้นของระบบ'; - - @override - String get market_place_region => 'ภูมิภาค Marketplace'; - - @override - String get recommendation_country => 'ประเทศที่แนะนำ'; - - @override - String get appearance => 'ลักษณะที่ปรากฏ'; - - @override - String get layout_mode => 'โหมดเค้าโครง'; - - @override - String get override_layout_settings => - 'แทนที่การตั้งค่าโหมดเค้าโครงแบบตอบสนอง'; - - @override - String get adaptive => 'ปรับเปลี่ยน'; - - @override - String get compact => 'กระชับ'; - - @override - String get extended => 'ขยาย'; - - @override - String get theme => 'ธีม'; - - @override - String get dark => 'มืด'; - - @override - String get light => 'สว่าง'; - - @override - String get system => 'ระบบ'; - - @override - String get accent_color => 'สีเน้น'; - - @override - String get sync_album_color => 'ซิงค์สีอัลบั้ม'; - - @override - String get sync_album_color_description => - 'ใช้สีเด่นของอาร์ตอัลบั้มเป็นสีเน้น'; - - @override - String get playback => 'การเล่น'; - - @override - String get audio_quality => 'คุณภาพเสียง'; - - @override - String get high => 'สูง'; - - @override - String get low => 'ต่ำ'; - - @override - String get pre_download_play => 'ดาวน์โหลดล่วงหน้าและเล่น'; - - @override - String get pre_download_play_description => - 'แทนที่จะสตรีมเสียง ดาวน์โหลดข้อมูลและเล่นแทน (แนะนำสำหรับผู้ใช้แบนด์วิดธ์สูง)'; - - @override - String get skip_non_music => 'ข้ามส่วนที่ไม่ใช่เพลง (SponsorBlock)'; - - @override - String get blacklist_description => 'แทร็กและศิลปินที่บล็อก'; - - @override - String get wait_for_download_to_finish => - 'โปรดรอให้การดาวน์โหลดปัจจุบันเสร็จสิ้น'; - - @override - String get desktop => 'เดสก์ท็อป'; - - @override - String get close_behavior => 'ปิดพฤติกรรม'; - - @override - String get close => 'ปิด'; - - @override - String get minimize_to_tray => 'ลดขนาดลงถาด'; - - @override - String get show_tray_icon => 'แสดงไอคอนถาดระบบ'; - - @override - String get about => 'เกี่ยวกับ'; - - @override - String get u_love_spotube => 'เรารู้ว่าคุณรัก Spotube'; - - @override - String get check_for_updates => 'ตรวจสอบการปรับปรุง'; - - @override - String get about_spotube => 'เกี่ยวกับ Spotube'; - - @override - String get blacklist => 'แบล็กลิสต์'; - - @override - String get please_sponsor => 'กรุณาสนับสนุน/บริจาค'; - - @override - String get spotube_description => - 'Spotube โปรแกรมเล่น Spotify ฟรีสำหรับทุกคน น้ำหนักเบา รองรับหลายแพลตฟอร์ม'; - - @override - String get version => 'รุ่น'; - - @override - String get build_number => 'หมายเลขบิลด์'; - - @override - String get founder => 'ผู้ก่อตั้ง'; - - @override - String get repository => 'ที่เก็บ'; - - @override - String get bug_issues => 'ข้อผิดพลาด+ปัญหา'; - - @override - String get made_with => 'ทำด้วย❤️ใน บังคลาเทศ🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'ใบอนุญาต'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'ไม่ต้องกังวล ข้อมูลรับรองใดๆ ของคุณจะไม่ถูกเก็บรวบรวมหรือแชร์กับใคร'; - - @override - String get know_how_to_login => 'ไม่รู้จักวิธีดำเนินการนี้ใช่ไหม'; - - @override - String get follow_step_by_step_guide => 'ทำตามคู่มือทีละขั้น'; - - @override - String cookie_name_cookie(Object name) { - return 'คุกกี้ $name'; - } - - @override - String get fill_in_all_fields => 'กรุณากรอกข้อมูลทุกช่อง'; - - @override - String get submit => 'ยื่น'; - - @override - String get exit => 'ออก'; - - @override - String get previous => 'ย้อนกลับ'; - - @override - String get next => 'ถัดไป'; - - @override - String get done => 'เสร็จ'; - - @override - String get step_1 => 'ขั้นที่ 1'; - - @override - String get first_go_to => 'ก่อนอื่น ไปที่'; - - @override - String get something_went_wrong => 'มีอะไรผิดพลาด'; - - @override - String get piped_instance => 'อินสแตนซ์เซิร์ฟเวอร์แบบ Pipe'; - - @override - String get piped_description => - 'อินสแตนซ์เซิร์ฟเวอร์แบบ Pipe ที่ใช้สำหรับการจับคู่แทร็ก'; - - @override - String get piped_warning => - 'บางอย่างอาจใช้งานไม่ได้ผล คุณจึงต้องรับความเสี่ยงเอง'; - - @override - String get invidious_instance => 'อินสแตนซ์เซิร์ฟเวอร์ Invidious'; - - @override - String get invidious_description => - 'อินสแตนซ์เซิร์ฟเวอร์ Invidious ที่ใช้สำหรับการจับคู่เพลง'; - - @override - String get invidious_warning => - 'บางอันอาจใช้งานไม่ดี ใช้ด้วยความเสี่ยงของคุณเอง'; - - @override - String get generate => 'สร้าง'; - - @override - String track_exists(Object track) { - return 'แทร็ก $track มีอยู่แล้ว'; - } - - @override - String get replace_downloaded_tracks => 'แทนที่แทร็กที่ดาวน์โหลดทั้งหมด'; - - @override - String get skip_download_tracks => 'ข้ามการดาวน์โหลดแทร็กที่ดาวน์โหลดทั้งหมด'; - - @override - String get do_you_want_to_replace => 'คุณต้องการแทนที่แทร็กที่มีอยู่หรือไม่'; - - @override - String get replace => 'แทนที่'; - - @override - String get skip => 'ข้าม'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'เลือกสูงสุด $count $type'; - } - - @override - String get select_genres => 'เลือกประเภท'; - - @override - String get add_genres => 'เพิ่มประเภท'; - - @override - String get country => 'ประเทศ'; - - @override - String get number_of_tracks_generate => 'จำนวนแทร็กที่จะสร้าง'; - - @override - String get acousticness => 'อะคูสติก'; - - @override - String get danceability => 'ความสามารถในการเต้น'; - - @override - String get energy => 'พลัง'; - - @override - String get instrumentalness => 'บรรเลง'; - - @override - String get liveness => 'ความสด'; - - @override - String get loudness => 'ความดัง'; - - @override - String get speechiness => 'การพูด'; - - @override - String get valence => 'ความสุข'; - - @override - String get popularity => 'ความนิยม'; - - @override - String get key => 'คีย์'; - - @override - String get duration => 'ระยะเวลา (วินาที)'; - - @override - String get tempo => 'ความเร็ว (BPM)'; - - @override - String get mode => 'โหมด'; - - @override - String get time_signature => 'ลายเซ็นเวลา'; - - @override - String get short => 'สั้น'; - - @override - String get medium => 'กลาง'; - - @override - String get long => 'ยาว'; - - @override - String get min => 'ต่ำสุด'; - - @override - String get max => 'สูงสุด'; - - @override - String get target => 'เป้าหมาย'; - - @override - String get moderate => 'ปานกลาง'; - - @override - String get deselect_all => 'ยกเลิกการเลือกทั้งหมด'; - - @override - String get select_all => 'เลือกทั้งหมด'; - - @override - String get are_you_sure => 'คุณแน่ใจไหม?'; - - @override - String get generating_playlist => 'กำลังสร้างเพลย์ลิสต์ที่คุณกำหนดเอง...'; - - @override - String selected_count_tracks(Object count) { - return 'เลือก $count แทร็ก'; - } - - @override - String get download_warning => - 'ถ้าคุณดาวน์โหลดเพลงทั้งหมดเป็นจำนวนมาก คุณกำลังละเมิดลิขสิทธิ์เพลงและสร้างความเสียหายให้กับสังคมดนตรี สร้างสรรค์ หวังว่าคุณจะรับรู้เรื่องนี้ เสมอ พยายามเคารพและสนับสนุนผลงานหนักของศิลปิน'; - - @override - String get download_ip_ban_warning => - 'นอกเหนือจากนั้น IP ของคุณอาจถูกบล็อกบน YouTube เนื่องจากคำขอดาวน์โหลดมากเกินกว่าปกติ การบล็อก IP หมายความว่าคุณไม่สามารถใช้ YouTube (แม้ว่าคุณจะล็อกอินอยู่) เป็นเวลาอย่างน้อย 2-3 เดือนจากอุปกรณ์ IP นั้น และ Spotube จะไม่รับผิดชอบใด ๆ หากสิ่งนี้เกิดขึ้น'; - - @override - String get by_clicking_accept_terms => - 'คลิก \'ยอมรับ\' คุณยินยอมตามเงื่อนไขต่อไปนี้:'; - - @override - String get download_agreement_1 => - 'ฉันรู้ว่าฉันกำลังละเมิดลิขสิทธิ์เพลง ฉันเลว'; - - @override - String get download_agreement_2 => - 'ฉันจะสนับสนุนศิลปินทุกที่ที่ฉันทำได้และฉันทำสิ่งนี้เพียงเพราะฉันไม่มีเงินซื้อผลงานศิลปะของพวกเขา'; - - @override - String get download_agreement_3 => - 'ฉันรับทราบอย่างสมบูรณ์ว่า IP ของฉันอาจถูกบล็อกบน YouTube และฉันจะไม่ถือ Spotube หรือเจ้าของ/ผู้มีส่วนร่วมใด ๆ รับผิดชอบต่ออุบัติเหตุใด ๆ ที่เกิดจากการกระทำปัจจุบันของฉัน'; - - @override - String get decline => 'ปฏิเสธ'; - - @override - String get accept => 'ยอมรับ'; - - @override - String get details => 'รายละเอียด'; - - @override - String get youtube => 'youtube'; - - @override - String get channel => 'ช่อง'; - - @override - String get likes => 'ถูกใจ'; - - @override - String get dislikes => 'ไม่ชอบ'; - - @override - String get views => 'วิว'; - - @override - String get streamUrl => 'สตรีม URL'; - - @override - String get stop => 'หยุด'; - - @override - String get sort_newest => 'เรียงตามการเพิ่มใหม่ล่าสุด'; - - @override - String get sort_oldest => 'เรียงตามการเพิ่มเก่าสุด'; - - @override - String get sleep_timer => 'ตั้งเวลาปิด'; - - @override - String mins(Object minutes) { - return '$minutes นาที'; - } - - @override - String hours(Object hours) { - return '$hours ชั่วโมง'; - } - - @override - String hour(Object hours) { - return '$hours ชั่วโมง'; - } - - @override - String get custom_hours => 'ชั่วโมงที่กำหนดเอง'; - - @override - String get logs => 'บันทึก'; - - @override - String get developers => 'นักพัฒนา'; - - @override - String get not_logged_in => 'คุณไม่ได้เข้าสู่ระบบ'; - - @override - String get search_mode => 'โหมดการค้นหา'; - - @override - String get audio_source => 'แหล่งที่มาของเสียง'; - - @override - String get ok => 'ตกลง'; - - @override - String get failed_to_encrypt => 'เข้ารหัสล้มเหลว'; - - @override - String get encryption_failed_warning => - 'Spotube ใช้การเข้ารหัสเพื่อเก็บข้อมูลของคุณอย่างปลอดภัย แต่ไม่สามารถทำได้ ดังนั้นจะเปลี่ยนเป็นการจัดเก็บที่ไม่ปลอดภัย\nหากคุณใช้ Linux โปรดตรวจสอบว่าคุณได้ติดตั้งบริการลับ (gnome-keyring, kde-wallet, keepassxc เป็นต้น)'; - - @override - String get querying_info => 'กำลังดึงข้อมูล...'; - - @override - String get piped_api_down => 'Piped API ไม่ทำงาน'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'Piped instance $pipedInstance ไม่ทำงานขณะนี้\n\nเปลี่ยนอินสแตนซ์หรือเปลี่ยน \'ประเภท API\' เป็น YouTube API อย่างเป็นทางการ\n\nอย่าลืมรีสตาร์ทแอปหลังจากเปลี่ยน'; - } - - @override - String get you_are_offline => 'คุณออฟไลน์อยู่'; - - @override - String get connection_restored => - 'การเชื่อมต่ออินเทอร์เน็ตของคุณได้รับการกู้คืน'; - - @override - String get use_system_title_bar => 'ใช้แถบชื่อระบบ'; - - @override - String get crunching_results => 'กำลังประมวลผล...'; - - @override - String get search_to_get_results => 'ค้นหาเพื่อดูผลลัพธ์'; - - @override - String get use_amoled_mode => 'ธีมมืดสนิท'; - - @override - String get pitch_dark_theme => 'โหมด AMOLED'; - - @override - String get normalize_audio => 'ปรับระดับเสียง'; - - @override - String get change_cover => 'เปลี่ยนปก'; - - @override - String get add_cover => 'เพิ่มปก'; - - @override - String get restore_defaults => 'คืนค่าเริ่มต้น'; - - @override - String get download_music_format => 'รูปแบบการดาวน์โหลดเพลง'; - - @override - String get streaming_music_format => 'รูปแบบการสตรีมเพลง'; - - @override - String get download_music_quality => 'คุณภาพการดาวน์โหลด'; - - @override - String get streaming_music_quality => 'คุณภาพการสตรีม'; - - @override - String get login_with_lastfm => 'เข้าสู่ระบบด้วย Last.fm'; - - @override - String get connect => 'เชื่อมต่อ'; - - @override - String get disconnect_lastfm => 'ตัดการเชื่อมต่อ Last.fm'; - - @override - String get disconnect => 'ตัดการเชื่อมต่อ'; - - @override - String get username => 'ชื่อผู้ใช้'; - - @override - String get password => 'รหัสผ่าน'; - - @override - String get login => 'เข้าสู่ระบบ'; - - @override - String get login_with_your_lastfm => 'เข้าสู่ระบบด้วย Last.fm'; - - @override - String get scrobble_to_lastfm => 'Scrobble ไปเป็น Last.fm'; - - @override - String get go_to_album => 'ไปที่อัลบั้ม'; - - @override - String get discord_rich_presence => 'Discord Rich Presence'; - - @override - String get browse_all => 'เรียกดูทั้งหมด'; - - @override - String get genres => 'ประเภท'; - - @override - String get explore_genres => 'สำรวจประเภท'; - - @override - String get friends => 'เพื่อน'; - - @override - String get no_lyrics_available => 'ขออภัย ไม่พบเนื้อเพลงสำหรับเพลงนี้'; - - @override - String get start_a_radio => 'เปิดวิทยุ'; - - @override - String get how_to_start_radio => 'หากต้องการเปิดวิทยุฟังยังไง?'; - - @override - String get replace_queue_question => - 'คุณต้องการแทนที่คิวปัจจุบันหรือเพิ่มเข้าไปหรือไม่'; - - @override - String get endless_playback => 'เล่นซ้ำ'; - - @override - String get delete_playlist => 'ลบเพลย์ลิสต์'; - - @override - String get delete_playlist_confirmation => - 'คุณแน่ใจที่จะลบเพลย์ลิสต์นี้หรือไม่'; - - @override - String get local_tracks => 'เพลงในเครื่อง'; - - @override - String get local_tab => 'ท้องถิ่น'; - - @override - String get song_link => 'ลิงค์เพลง'; - - @override - String get skip_this_nonsense => 'ข้ามสิ่งไร้สาระนี้'; - - @override - String get freedom_of_music => '“เสรีภาพแห่งเสียงเพลง”'; - - @override - String get freedom_of_music_palm => '“เสรีภาพแห่งเสียงเพลง ในมือของคุณ”'; - - @override - String get get_started => 'เริ่มต้น'; - - @override - String get youtube_source_description => 'แนะนำและใช้งานได้ดีที่สุด'; - - @override - String get piped_source_description => - 'รู้สึกอิสระ? เหมือน YouTube แต่ฟรีกว่าเยอะ'; - - @override - String get jiosaavn_source_description => 'ดีที่สุดสำหรับภูมิภาคเอเชียใต้'; - - @override - String get invidious_source_description => - 'คล้ายกับ Piped แต่มีความพร้อมใช้งานสูงกว่า'; - - @override - String highest_quality(Object quality) { - return 'คุณภาพสูงสุด: $quality'; - } - - @override - String get select_audio_source => 'เลือกแหล่งเสียง'; - - @override - String get endless_playback_description => 'เพิ่มเพลงใหม่ลงในคิวโดยอัตโนมัติ'; - - @override - String get choose_your_region => 'เลือกภูมิภาคของคุณ'; - - @override - String get choose_your_region_description => - 'สิ่งนี้จะช่วยให้ Spotube แสดงเนื้อหาที่เหมาะสมสำหรับคุณ'; - - @override - String get choose_your_language => 'เลือกภาษาของคุณ'; - - @override - String get help_project_grow => 'ช่วยให้โครงการนี้เติบโต'; - - @override - String get help_project_grow_description => - 'Spotube เป็นโครงการโอเพนซอร์ส คุณสามารถช่วยให้โครงการนี้เติบโตได้โดยการมีส่วนร่วมในโครงการ รายงานข้อบกพร่อง หรือเสนอคุณสมบัติใหม่'; - - @override - String get contribute_on_github => 'มีส่วนร่วมบน GitHub'; - - @override - String get donate_on_open_collective => 'บริจาคบน Open Collective'; - - @override - String get browse_anonymously => 'เรียกดูแบบไม่ระบุตัวตน'; - - @override - String get enable_connect => 'เปิดใช้งานการเชื่อมต่อ'; - - @override - String get enable_connect_description => 'ควบคุม Spotube จากอุปกรณ์อื่น'; - - @override - String get devices => 'อุปกรณ์'; - - @override - String get select => 'เลือก'; - - @override - String connect_client_alert(Object client) { - return 'คุณกำลังถูกควบคุมโดย $client'; - } - - @override - String get this_device => 'อุปกรณ์นี้'; - - @override - String get remote => 'ระยะไกล'; - - @override - String get stats => 'สถิติ'; - - @override - String and_n_more(Object count) { - return 'และ $count อีก'; - } - - @override - String get recently_played => 'เพลงที่เพิ่งเล่น'; - - @override - String get browse_more => 'ดูเพิ่มเติม'; - - @override - String get no_title => 'ไม่มีชื่อ'; - - @override - String get not_playing => 'ไม่เล่น'; - - @override - String get epic_failure => 'ล้มเหลวอย่างยิ่ง!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'เพิ่ม $tracks_length เพลงในคิว'; - } - - @override - String get spotube_has_an_update => 'Spotube มีการอัปเดต'; - - @override - String get download_now => 'ดาวน์โหลดตอนนี้'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum ได้รับการปล่อยออกมา'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version ได้รับการปล่อยออกมา'; - } - - @override - String get read_the_latest => 'อ่านข่าวสารล่าสุด '; - - @override - String get release_notes => 'บันทึกการปล่อย'; - - @override - String get pick_color_scheme => 'เลือกธีมสี'; - - @override - String get save => 'บันทึก'; - - @override - String get choose_the_device => 'เลือกอุปกรณ์:'; - - @override - String get multiple_device_connected => - 'มีอุปกรณ์เชื่อมต่อหลายเครื่อง\nเลือกอุปกรณ์ที่คุณต้องการให้การดำเนินการนี้เกิดขึ้น'; - - @override - String get nothing_found => 'ไม่พบข้อมูล'; - - @override - String get the_box_is_empty => 'กล่องว่างเปล่า'; - - @override - String get top_artists => 'ศิลปินยอดนิยม'; - - @override - String get top_albums => 'อัลบั้มยอดนิยม'; - - @override - String get this_week => 'สัปดาห์นี้'; - - @override - String get this_month => 'เดือนนี้'; - - @override - String get last_6_months => '6 เดือนที่ผ่านมา'; - - @override - String get this_year => 'ปีนี้'; - - @override - String get last_2_years => '2 ปีที่ผ่านมา'; - - @override - String get all_time => 'ตลอดกาล'; - - @override - String powered_by_provider(Object providerName) { - return 'ขับเคลื่อนโดย $providerName'; - } - - @override - String get email => 'อีเมล'; - - @override - String get profile_followers => 'ผู้ติดตาม'; - - @override - String get birthday => 'วันเกิด'; - - @override - String get subscription => 'การสมัครสมาชิก'; - - @override - String get not_born => 'ยังไม่เกิด'; - - @override - String get hacker => 'แฮ็กเกอร์'; - - @override - String get profile => 'โปรไฟล์'; - - @override - String get no_name => 'ไม่มีชื่อ'; - - @override - String get edit => 'แก้ไข'; - - @override - String get user_profile => 'โปรไฟล์ผู้ใช้'; - - @override - String count_plays(Object count) { - return '$count การเล่น'; - } - - @override - String get streaming_fees_hypothetical => - '*คำนวณจากการจ่ายเงินต่อการสตรีมของ Spotify\nระหว่าง \$0.003 ถึง \$0.005 นี่เป็นการคำนวณสมมุติ\nเพื่อให้ข้อมูลแก่ผู้ใช้เกี่ยวกับจำนวนเงินที่พวกเขา\nอาจจะจ่ายให้กับศิลปินหากพวกเขาฟังเพลงของพวกเขาใน Spotify'; - - @override - String get minutes_listened => 'เวลาที่ฟัง'; - - @override - String get streamed_songs => 'เพลงที่สตรีม'; - - @override - String count_streams(Object count) { - return '$count สตรีม'; - } - - @override - String get owned_by_you => 'เป็นเจ้าของโดยคุณ'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl คัดลอกไปที่คลิปบอร์ดแล้ว'; - } - - @override - String get hipotetical_calculation => - '*การคำนวณนี้อิงจากค่าเฉลี่ยการจ่ายเงินต่อสตรีมของแพลตฟอร์มสตรีมมิ่งเพลงออนไลน์ที่ \$0.003 ถึง \$0.005 นี่เป็นการคำนวณสมมติฐานเพื่อให้ผู้ใช้เข้าใจว่าพวกเขาจะต้องจ่ายเงินให้ศิลปินเท่าไหร่หากพวกเขาฟังเพลงบนแพลตฟอร์มสตรีมมิ่งเพลงที่แตกต่างกัน'; - - @override - String count_mins(Object minutes) { - return '$minutes นาที'; - } - - @override - String get summary_minutes => 'นาที'; - - @override - String get summary_listened_to_music => 'ฟังเพลง'; - - @override - String get summary_songs => 'เพลง'; - - @override - String get summary_streamed_overall => 'สตรีมทั้งหมด'; - - @override - String get summary_owed_to_artists => 'ค้างชำระให้ศิลปิน\nในเดือนนี้'; - - @override - String get summary_artists => 'ศิลปิน'; - - @override - String get summary_music_reached_you => 'เพลงมาถึงคุณ'; - - @override - String get summary_full_albums => 'อัลบั้มเต็ม'; - - @override - String get summary_got_your_love => 'ได้รับความรักของคุณ'; - - @override - String get summary_playlists => 'เพลย์ลิสต์'; - - @override - String get summary_were_on_repeat => 'อยู่ในโหมดซ้ำ'; - - @override - String total_money(Object money) { - return 'รวม $money'; - } - - @override - String get webview_not_found => 'ไม่พบ Webview'; - - @override - String get webview_not_found_description => - 'ไม่พบ runtime ของ Webview บนอุปกรณ์ของคุณ\nหากติดตั้งแล้วตรวจสอบให้แน่ใจว่าอยู่ใน environment PATH\n\nหลังจากติดตั้งแล้ว ให้รีสตาร์ทแอป'; - - @override - String get unsupported_platform => 'แพลตฟอร์มไม่รองรับ'; - - @override - String get cache_music => 'แคชเพลง'; - - @override - String get open => 'เปิด'; - - @override - String get cache_folder => 'โฟลเดอร์แคช'; - - @override - String get export => 'ส่งออก'; - - @override - String get clear_cache => 'ล้างแคช'; - - @override - String get clear_cache_confirmation => 'คุณต้องการล้างแคชหรือไม่?'; - - @override - String get export_cache_files => 'ส่งออกไฟล์แคช'; - - @override - String found_n_files(Object count) { - return 'พบ $count ไฟล์'; - } - - @override - String get export_cache_confirmation => 'คุณต้องการส่งออกไฟล์เหล่านี้ไปยัง'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return 'ส่งออก $filesExported จาก $files ไฟล์'; - } - - @override - String get undo => 'ย้อนกลับ'; - - @override - String get download_all => 'ดาวน์โหลดทั้งหมด'; - - @override - String get add_all_to_playlist => 'เพิ่มทั้งหมดในเพลย์ลิสต์'; - - @override - String get add_all_to_queue => 'เพิ่มทั้งหมดในคิว'; - - @override - String get play_all_next => 'เล่นทั้งหมดถัดไป'; - - @override - String get pause => 'หยุดชั่วคราว'; - - @override - String get view_all => 'ดูทั้งหมด'; - - @override - String get no_tracks_added_yet => 'ดูเหมือนคุณยังไม่ได้เพิ่มเพลงใด ๆ'; - - @override - String get no_tracks => 'ดูเหมือนจะไม่มีเพลงที่นี่'; - - @override - String get no_tracks_listened_yet => 'ดูเหมือนคุณยังไม่ได้ฟังอะไรเลย'; - - @override - String get not_following_artists => 'คุณไม่ได้ติดตามศิลปินใด ๆ'; - - @override - String get no_favorite_albums_yet => - 'ดูเหมือนคุณยังไม่ได้เพิ่มอัลบัมใด ๆ ในรายการโปรด'; - - @override - String get no_logs_found => 'ไม่พบบันทึก'; - - @override - String get youtube_engine => 'เครื่องมือ YouTube'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine ยังไม่ได้ติดตั้ง'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine ยังไม่ได้ติดตั้งในระบบของคุณ'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'ตรวจสอบให้แน่ใจว่ามันมีอยู่ในตัวแปร PATH หรือ\nตั้งค่าพาธที่แท้จริงของไฟล์ที่สามารถทำงานได้ $engine ด้านล่าง'; - } - - @override - String get youtube_engine_unix_issue_message => - 'ใน macOS/Linux/Unix อย่าง OS การตั้งค่าพาธใน .zshrc/.bashrc/.bash_profile เป็นต้น จะไม่ทำงาน\nคุณต้องตั้งค่าพาธในไฟล์การกำหนดค่า shell'; - - @override - String get download => 'ดาวน์โหลด'; - - @override - String get file_not_found => 'ไม่พบไฟล์'; - - @override - String get custom => 'กำหนดเอง'; - - @override - String get add_custom_url => 'เพิ่ม URL แบบกำหนดเอง'; - - @override - String get edit_port => 'แก้ไขพอร์ต'; - - @override - String get port_helper_msg => - 'ค่าเริ่มต้นคือ -1 ซึ่งหมายถึงหมายเลขสุ่ม หากคุณได้กำหนดค่าไฟร์วอลล์แล้ว แนะนำให้ตั้งค่านี้'; - - @override - String connect_request(Object client) { - return 'อนุญาตให้ $client เชื่อมต่อหรือไม่?'; - } - - @override - String get connection_request_denied => - 'การเชื่อมต่อล้มเหลว ผู้ใช้ปฏิเสธการเข้าถึง'; - - @override - String get an_error_occurred => 'เกิดข้อผิดพลาด'; - - @override - String get copy_to_clipboard => 'คัดลอกไปยังคลิปบอร์ด'; - - @override - String get view_logs => 'ดูบันทึก'; - - @override - String get retry => 'ลองใหม่'; - - @override - String get no_default_metadata_provider_selected => - 'คุณไม่ได้ตั้งค่าผู้ให้บริการเมตาดาต้าเริ่มต้น'; - - @override - String get manage_metadata_providers => 'จัดการผู้ให้บริการเมตาดาต้า'; - - @override - String get open_link_in_browser => 'เปิดลิงก์ในเบราว์เซอร์หรือไม่?'; - - @override - String get do_you_want_to_open_the_following_link => - 'คุณต้องการเปิดลิงก์ต่อไปนี้หรือไม่'; - - @override - String get unsafe_url_warning => - 'การเปิดลิงก์จากแหล่งที่ไม่น่าเชื่อถืออาจไม่ปลอดภัย โปรดระมัดระวัง!\nคุณยังสามารถคัดลอกลิงก์ไปยังคลิปบอร์ดของคุณได้'; - - @override - String get copy_link => 'คัดลอกลิงก์'; - - @override - String get building_your_timeline => - 'กำลังสร้างไทม์ไลน์ของคุณตามการฟังของคุณ...'; - - @override - String get official => 'อย่างเป็นทางการ'; - - @override - String author_name(Object author) { - return 'ผู้เขียน: $author'; - } - - @override - String get third_party => 'บุคคลที่สาม'; - - @override - String get plugin_requires_authentication => - 'ปลั๊กอินต้องมีการรับรองความถูกต้อง'; - - @override - String get update_available => 'มีการอัปเดต'; - - @override - String get supports_scrobbling => 'รองรับการ scrobbling'; - - @override - String get plugin_scrobbling_info => - 'ปลั๊กอินนี้จะ scrobble เพลงของคุณเพื่อสร้างประวัติการฟังของคุณ'; - - @override - String get default_metadata_source => 'แหล่งเมตาดาต้าพื้นฐาน'; - - @override - String get set_default_metadata_source => 'ตั้งค่าแหล่งเมตาดาต้าพื้นฐาน'; - - @override - String get default_audio_source => 'แหล่งเสียงพื้นฐาน'; - - @override - String get set_default_audio_source => 'ตั้งค่าแหล่งเสียงพื้นฐาน'; - - @override - String get set_default => 'ตั้งค่าเริ่มต้น'; - - @override - String get support => 'สนับสนุน'; - - @override - String get support_plugin_development => 'สนับสนุนการพัฒนาปลั๊กอิน'; - - @override - String can_access_name_api(Object name) { - return '- สามารถเข้าถึง API **$name**'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'คุณต้องการติดตั้งปลั๊กอินนี้หรือไม่?'; - - @override - String get third_party_plugin_warning => - 'ปลั๊กอินนี้มาจากที่เก็บของบุคคลที่สาม โปรดตรวจสอบให้แน่ใจว่าคุณเชื่อถือแหล่งที่มาก่อนทำการติดตั้ง'; - - @override - String get author => 'ผู้เขียน'; - - @override - String get this_plugin_can_do_following => 'ปลั๊กอินนี้สามารถทำสิ่งต่อไปนี้'; - - @override - String get install => 'ติดตั้ง'; - - @override - String get install_a_metadata_provider => 'ติดตั้งผู้ให้บริการเมตาดาต้า'; - - @override - String get no_tracks_playing => 'ขณะนี้ไม่มีเพลงที่กำลังเล่นอยู่'; - - @override - String get synced_lyrics_not_available => - 'ไม่มีเนื้อเพลงที่ซิงค์สำหรับเพลงนี้ กรุณาใช้แท็บ'; - - @override - String get plain_lyrics => 'เนื้อเพลงธรรมดา'; - - @override - String get tab_instead => 'แทน'; - - @override - String get disclaimer => 'ข้อสงวนสิทธิ์'; - - @override - String get third_party_plugin_dmca_notice => - 'ทีม Spotube ไม่รับผิดชอบใดๆ (รวมถึงทางกฎหมาย) สำหรับปลั๊กอิน \"บุคคลที่สาม\" ใดๆ\nโปรดใช้งานด้วยความเสี่ยงของคุณเอง สำหรับข้อบกพร่อง/ปัญหาใดๆ โปรดรายงานไปยังที่เก็บปลั๊กอิน\n\nหากปลั๊กอิน \"บุคคลที่สาม\" ใดๆ ละเมิด ToS/DMCA ของบริการ/นิติบุคคลใดๆ โปรดขอให้ผู้เขียนปลั๊กอิน \"บุคคลที่สาม\" หรือแพลตฟอร์มโฮสติ้ง เช่น GitHub/Codeberg ดำเนินการ ที่ระบุไว้ข้างต้น (ที่ติดป้าย \"บุคคลที่สาม\") เป็นปลั๊กอินสาธารณะ/ที่ดูแลโดยชุมชนทั้งหมด เราไม่ได้จัดการดูแล ดังนั้นเราจึงไม่สามารถดำเนินการใดๆ กับพวกเขาได้\n\n'; - - @override - String get input_does_not_match_format => 'อินพุตไม่ตรงกับรูปแบบที่ต้องการ'; - - @override - String get plugins => 'ปลั๊กอิน'; - - @override - String get paste_plugin_download_url => - 'วาง url ดาวน์โหลดหรือ url ที่เก็บ GitHub/Codeberg หรือลิงก์โดยตรงไปยังไฟล์ .smplug'; - - @override - String get download_and_install_plugin_from_url => - 'ดาวน์โหลดและติดตั้งปลั๊กอินจาก url'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'ไม่สามารถเพิ่มปลั๊กอินได้: $error'; - } - - @override - String get upload_plugin_from_file => 'อัปโหลดปลั๊กอินจากไฟล์'; - - @override - String get installed => 'ติดตั้งแล้ว'; - - @override - String get available_plugins => 'ปลั๊กอินที่มีอยู่'; - - @override - String get configure_plugins => - 'กำหนดค่าปลั๊กอินผู้ให้บริการเมตาดาต้าและแหล่งเสียงของคุณเอง'; - - @override - String get audio_scrobblers => 'เครื่อง scrobbler เสียง'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'แหล่งที่มา: '; - - @override - String get uncompressed => 'ไม่บีบอัด'; - - @override - String get dab_music_source_description => - 'สำหรับคนรักเสียงเพลง ให้สตรีมเสียงคุณภาพสูง/ไร้การสูญเสียการบีบอัด การจับคู่แทร็กแม่นยำตาม ISRC'; -} diff --git a/lib/l10n/generated/app_localizations_tl.dart b/lib/l10n/generated/app_localizations_tl.dart deleted file mode 100644 index 5febc92d..00000000 --- a/lib/l10n/generated/app_localizations_tl.dart +++ /dev/null @@ -1,1581 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Tagalog (`tl`). -class AppLocalizationsTl extends AppLocalizations { - AppLocalizationsTl([String locale = 'tl']) : super(locale); - - @override - String get guest => 'Bisita'; - - @override - String get browse => 'Mag-browse'; - - @override - String get search => 'Maghanap'; - - @override - String get library => 'Silid-aklatan'; - - @override - String get lyrics => 'Mga Liriko'; - - @override - String get settings => 'Mga Setting'; - - @override - String get genre_categories_filter => 'I-filter ang mga kategorya o genre...'; - - @override - String get genre => 'Genre'; - - @override - String get personalized => 'Naka-personalize'; - - @override - String get featured => 'Tampok'; - - @override - String get new_releases => 'Mga Bagong Paglabas'; - - @override - String get songs => 'Mga Kanta'; - - @override - String playing_track(Object track) { - return 'Tumutugtog ang $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Ito ay magbubura ng kasalukuyang pila. $track_length na mga track ang tatanggalin\nGusto mo bang magpatuloy?'; - } - - @override - String get load_more => 'Mag-load pa'; - - @override - String get playlists => 'Mga Playlist'; - - @override - String get artists => 'Mga Artista'; - - @override - String get albums => 'Mga Album'; - - @override - String get tracks => 'Mga Track'; - - @override - String get downloads => 'Mga Download'; - - @override - String get filter_playlists => 'I-filter ang iyong mga playlist...'; - - @override - String get liked_tracks => 'Mga Nagustuhang Track'; - - @override - String get liked_tracks_description => - 'Lahat ng mga track na iyong nagustuhan'; - - @override - String get playlist => 'Playlist'; - - @override - String get create_a_playlist => 'Gumawa ng playlist'; - - @override - String get update_playlist => 'I-update ang playlist'; - - @override - String get create => 'Lumikha'; - - @override - String get cancel => 'Ikansela'; - - @override - String get update => 'I-update'; - - @override - String get playlist_name => 'Pangalan ng Playlist'; - - @override - String get name_of_playlist => 'Pangalan ng playlist'; - - @override - String get description => 'Paglalarawan'; - - @override - String get public => 'Pampubliko'; - - @override - String get collaborative => 'Pakikipagtulungan'; - - @override - String get search_local_tracks => 'Maghanap ng mga lokal na track...'; - - @override - String get play => 'I-play'; - - @override - String get delete => 'Burahin'; - - @override - String get none => 'Wala'; - - @override - String get sort_a_z => 'Ayusin ayon sa A-Z'; - - @override - String get sort_z_a => 'Ayusin ayon sa Z-A'; - - @override - String get sort_artist => 'Ayusin ayon sa Artista'; - - @override - String get sort_album => 'Ayusin ayon sa Album'; - - @override - String get sort_duration => 'Ayusin ayon sa Tagal'; - - @override - String get sort_tracks => 'Ayusin ang mga Track'; - - @override - String currently_downloading(Object tracks_length) { - return 'Kasalukuyang Nagda-download ($tracks_length)'; - } - - @override - String get cancel_all => 'Kanselahin Lahat'; - - @override - String get filter_artist => 'I-filter ang mga artista...'; - - @override - String followers(Object followers) { - return '$followers na mga Tagasunod'; - } - - @override - String get add_artist_to_blacklist => 'Idagdag ang artista sa blacklist'; - - @override - String get top_tracks => 'Mga Nangungunang Track'; - - @override - String get fans_also_like => 'Gusto rin ng mga tagahanga'; - - @override - String get loading => 'Naglo-load...'; - - @override - String get artist => 'Artista'; - - @override - String get blacklisted => 'Naka-blacklist'; - - @override - String get following => 'Sinusundan'; - - @override - String get follow => 'Sundan'; - - @override - String get artist_url_copied => 'Na-copy sa clipboard ang URL ng artista'; - - @override - String added_to_queue(Object tracks) { - return 'Idinagdag ang $tracks na mga track sa pila'; - } - - @override - String get filter_albums => 'I-filter ang mga album...'; - - @override - String get synced => 'Naka-sync'; - - @override - String get plain => 'Simpleng'; - - @override - String get shuffle => 'I-shuffle'; - - @override - String get search_tracks => 'Maghanap ng mga track...'; - - @override - String get released => 'Inilabas'; - - @override - String error(Object error) { - return 'Error $error'; - } - - @override - String get title => 'Pamagat'; - - @override - String get time => 'Oras'; - - @override - String get more_actions => 'Higit pang mga aksyon'; - - @override - String download_count(Object count) { - return 'I-download ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Idagdag ($count) sa Playlist'; - } - - @override - String add_count_to_queue(Object count) { - return 'Idagdag ($count) sa Pila'; - } - - @override - String play_count_next(Object count) { - return 'I-play ($count) kasunod'; - } - - @override - String get album => 'Album'; - - @override - String copied_to_clipboard(Object data) { - return 'Na-copy ang $data sa clipboard'; - } - - @override - String add_to_following_playlists(Object track) { - return 'Idagdag ang $track sa mga sumusunod na Playlist'; - } - - @override - String get add => 'Idagdag'; - - @override - String added_track_to_queue(Object track) { - return 'Idinagdag ang $track sa pila'; - } - - @override - String get add_to_queue => 'Idagdag sa pila'; - - @override - String track_will_play_next(Object track) { - return 'Ang $track ay tutugtog susunod'; - } - - @override - String get play_next => 'I-play susunod'; - - @override - String removed_track_from_queue(Object track) { - return 'Tinanggal ang $track mula sa pila'; - } - - @override - String get remove_from_queue => 'Alisin mula sa pila'; - - @override - String get remove_from_favorites => 'Alisin mula sa mga paborito'; - - @override - String get save_as_favorite => 'I-save bilang paborito'; - - @override - String get add_to_playlist => 'Idagdag sa playlist'; - - @override - String get remove_from_playlist => 'Alisin mula sa playlist'; - - @override - String get add_to_blacklist => 'Idagdag sa blacklist'; - - @override - String get remove_from_blacklist => 'Alisin mula sa blacklist'; - - @override - String get share => 'Ibahagi'; - - @override - String get mini_player => 'Mini Player'; - - @override - String get slide_to_seek => 'I-slide para mag-seek pasulong o pabalik'; - - @override - String get shuffle_playlist => 'I-shuffle ang playlist'; - - @override - String get unshuffle_playlist => 'I-unshuffle ang playlist'; - - @override - String get previous_track => 'Nakaraang track'; - - @override - String get next_track => 'Susunod na track'; - - @override - String get pause_playback => 'I-pause ang Playback'; - - @override - String get resume_playback => 'Ipagpatuloy ang Playback'; - - @override - String get loop_track => 'I-loop ang track'; - - @override - String get no_loop => 'Walang loop'; - - @override - String get repeat_playlist => 'Ulitin ang playlist'; - - @override - String get queue => 'Pila'; - - @override - String get alternative_track_sources => - 'Alternatibong mga pinagmulan ng track'; - - @override - String get download_track => 'I-download ang track'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks na mga track sa pila'; - } - - @override - String get clear_all => 'Burahin lahat'; - - @override - String get show_hide_ui_on_hover => 'Ipakita/Itago ang UI sa hover'; - - @override - String get always_on_top => 'Palaging nasa ibabaw'; - - @override - String get exit_mini_player => 'Lumabas sa Mini player'; - - @override - String get download_location => 'Lokasyon ng pag-download'; - - @override - String get local_library => 'Lokal na silid-aklatan'; - - @override - String get add_library_location => 'Idagdag sa silid-aklatan'; - - @override - String get remove_library_location => 'Alisin mula sa silid-aklatan'; - - @override - String get account => 'Account'; - - @override - String get logout => 'Mag-logout'; - - @override - String get logout_of_this_account => 'Mag-logout sa account na ito'; - - @override - String get language_region => 'Wika at Rehiyon'; - - @override - String get language => 'Wika'; - - @override - String get system_default => 'Default ng Sistema'; - - @override - String get market_place_region => 'Rehiyon ng Marketplace'; - - @override - String get recommendation_country => 'Bansang Inirerekomenda'; - - @override - String get appearance => 'Hitsura'; - - @override - String get layout_mode => 'Mode ng Layout'; - - @override - String get override_layout_settings => - 'I-override ang mga setting ng responsive layout mode'; - - @override - String get adaptive => 'Umaangkop'; - - @override - String get compact => 'Kompakto'; - - @override - String get extended => 'Pinalawig'; - - @override - String get theme => 'Tema'; - - @override - String get dark => 'Madilim'; - - @override - String get light => 'Maliwanag'; - - @override - String get system => 'Sistema'; - - @override - String get accent_color => 'Kulay ng Accent'; - - @override - String get sync_album_color => 'I-sync ang kulay ng album'; - - @override - String get sync_album_color_description => - 'Ginagamit ang pangunahing kulay ng album art bilang kulay ng accent'; - - @override - String get playback => 'Playback'; - - @override - String get audio_quality => 'Kalidad ng Audio'; - - @override - String get high => 'Mataas'; - - @override - String get low => 'Mababa'; - - @override - String get pre_download_play => 'Mag-pre-download at i-play'; - - @override - String get pre_download_play_description => - 'Sa halip na mag-stream ng audio, mag-download ng bytes at i-play sa halip (Inirerekomenda para sa mga gumagamit ng mataas na bandwidth)'; - - @override - String get skip_non_music => - 'Laktawan ang mga segment na hindi musika (SponsorBlock)'; - - @override - String get blacklist_description => 'Mga track at artista na nasa blacklist'; - - @override - String get wait_for_download_to_finish => - 'Mangyaring maghintay para matapos ang kasalukuyang pag-download'; - - @override - String get desktop => 'Desktop'; - - @override - String get close_behavior => 'Pag-uugali ng Pagsara'; - - @override - String get close => 'Isara'; - - @override - String get minimize_to_tray => 'I-minimize sa tray'; - - @override - String get show_tray_icon => 'Ipakita ang icon ng System tray'; - - @override - String get about => 'Tungkol sa'; - - @override - String get u_love_spotube => 'Alam naming gusto mo ang Spotube'; - - @override - String get check_for_updates => 'Maghanap ng mga update'; - - @override - String get about_spotube => 'Tungkol sa Spotube'; - - @override - String get blacklist => 'Blacklist'; - - @override - String get please_sponsor => 'Mangyaring Mag-sponsor/Mag-donate'; - - @override - String get spotube_description => - 'Spotube, isang magaan, cross-platform, libreng-para-sa-lahat na spotify client'; - - @override - String get version => 'Bersyon'; - - @override - String get build_number => 'Build Number'; - - @override - String get founder => 'Nagtatag'; - - @override - String get repository => 'Repository'; - - @override - String get bug_issues => 'Bug+Mga Isyu'; - - @override - String get made_with => 'Ginawa nang may ❤️ sa Bangladesh🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Lisensya'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Huwag mag-alala, ang alinman sa iyong mga kredensyal ay hindi kokolektahin o ibabahagi sa sinuman'; - - @override - String get know_how_to_login => 'Hindi mo alam kung paano gawin ito?'; - - @override - String get follow_step_by_step_guide => 'Sundin ang Hakbang-hakbang na gabay'; - - @override - String cookie_name_cookie(Object name) { - return '$name Cookie'; - } - - @override - String get fill_in_all_fields => 'Mangyaring punan ang lahat ng field'; - - @override - String get submit => 'Isumite'; - - @override - String get exit => 'Lumabas'; - - @override - String get previous => 'Nakaraan'; - - @override - String get next => 'Susunod'; - - @override - String get done => 'Tapos na'; - - @override - String get step_1 => 'Hakbang 1'; - - @override - String get first_go_to => 'Una, Pumunta sa'; - - @override - String get something_went_wrong => 'May nangyaring mali'; - - @override - String get piped_instance => 'Instance ng Piped Server'; - - @override - String get piped_description => - 'Ang instance ng Piped server na gagamitin para sa pagtutugma ng track'; - - @override - String get piped_warning => - 'Maaaring hindi gumagana nang mabuti ang ilan sa mga ito. Kaya gamitin sa sarili mong peligro'; - - @override - String get invidious_instance => 'Instance ng Invidious Server'; - - @override - String get invidious_description => - 'Ang instance ng Invidious server na gagamitin para sa pagtutugma ng track'; - - @override - String get invidious_warning => - 'Maaaring hindi gumagana nang mabuti ang ilan sa mga ito. Kaya gamitin sa sarili mong peligro'; - - @override - String get generate => 'Gumawa'; - - @override - String track_exists(Object track) { - return 'Ang Track na $track ay umiiral na'; - } - - @override - String get replace_downloaded_tracks => - 'Palitan ang lahat ng na-download na mga track'; - - @override - String get skip_download_tracks => - 'Laktawan ang pag-download ng lahat ng na-download na mga track'; - - @override - String get do_you_want_to_replace => - 'Gusto mo bang palitan ang umiiral na track??'; - - @override - String get replace => 'Palitan'; - - @override - String get skip => 'Laktawan'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Pumili ng hanggang $count $type'; - } - - @override - String get select_genres => 'Pumili ng mga Genre'; - - @override - String get add_genres => 'Magdagdag ng mga Genre'; - - @override - String get country => 'Bansa'; - - @override - String get number_of_tracks_generate => 'Bilang ng mga track na gagawin'; - - @override - String get acousticness => 'Acoustic-ness'; - - @override - String get danceability => 'Kakayahang Sayawin'; - - @override - String get energy => 'Enerhiya'; - - @override - String get instrumentalness => 'Instrumental-ness'; - - @override - String get liveness => 'Liveness'; - - @override - String get loudness => 'Lakas'; - - @override - String get speechiness => 'Pagsasalita'; - - @override - String get valence => 'Valence'; - - @override - String get popularity => 'Popularidad'; - - @override - String get key => 'Key'; - - @override - String get duration => 'Tagal (s)'; - - @override - String get tempo => 'Tempo (BPM)'; - - @override - String get mode => 'Mode'; - - @override - String get time_signature => 'Time Signature'; - - @override - String get short => 'Maikli'; - - @override - String get medium => 'Katamtaman'; - - @override - String get long => 'Mahaba'; - - @override - String get min => 'Min'; - - @override - String get max => 'Max'; - - @override - String get target => 'Target'; - - @override - String get moderate => 'Katamtaman'; - - @override - String get deselect_all => 'Alisin ang Pagkakapili sa Lahat'; - - @override - String get select_all => 'Piliin Lahat'; - - @override - String get are_you_sure => 'Sigurado ka ba?'; - - @override - String get generating_playlist => 'Gumagawa ng iyong custom na playlist...'; - - @override - String selected_count_tracks(Object count) { - return 'Napili ang $count na mga track'; - } - - @override - String get download_warning => - 'Kung nag-download ka ng lahat ng Track sa maramihan, malinaw na nagpa-pirate ka ng Musika at nagsasanhi ng pinsala sa creative society ng Musika. Sana ay alam mo ito. Palaging, subukang igalang at suportahan ang masipag na paggawa ng Artist'; - - @override - String get download_ip_ban_warning => - 'Sa nga pala, ang iyong IP ay maaaring ma-block sa YouTube dahil sa sobrang mga kahilingan sa pag-download kaysa sa karaniwan. Ang IP block ay nangangahulugang hindi mo magagamit ang YouTube (kahit na naka-log in ka) sa loob ng hindi bababa sa 2-3 buwan mula sa device na may IP na iyon. At hindi pinanghahawakan ng Spotube ang anumang responsibilidad kung mangyayari ito'; - - @override - String get by_clicking_accept_terms => - 'Sa pamamagitan ng pag-click sa \'tanggapin\', sumasang-ayon ka sa mga sumusunod na tuntunin:'; - - @override - String get download_agreement_1 => - 'Alam kong nagpa-pirate ako ng Musika. Masama ako'; - - @override - String get download_agreement_2 => - 'Susuportahan ko ang Artist saan man ako maaari at ginagawa ko lang ito dahil wala akong pera para bumili ng kanilang sining'; - - @override - String get download_agreement_3 => - 'Lubos kong nauunawaan na ang aking IP ay maaaring ma-block sa YouTube at hindi ko pinanghahawakan ang Spotube o ang kanyang mga may-ari/nag-ambag na responsable para sa anumang aksidente na sanhi ng aking kasalukuyang aksyon'; - - @override - String get decline => 'Tanggihan'; - - @override - String get accept => 'Tanggapin'; - - @override - String get details => 'Mga Detalye'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Channel'; - - @override - String get likes => 'Mga Like'; - - @override - String get dislikes => 'Mga Dislike'; - - @override - String get views => 'Mga View'; - - @override - String get streamUrl => 'Stream URL'; - - @override - String get stop => 'Ihinto'; - - @override - String get sort_newest => 'Ayusin ayon sa pinakabagong idinagdag'; - - @override - String get sort_oldest => 'Ayusin ayon sa pinakalumang idinagdag'; - - @override - String get sleep_timer => 'Sleep Timer'; - - @override - String mins(Object minutes) { - return '$minutes Minuto'; - } - - @override - String hours(Object hours) { - return '$hours Oras'; - } - - @override - String hour(Object hours) { - return '$hours Oras'; - } - - @override - String get custom_hours => 'Custom na Oras'; - - @override - String get logs => 'Mga Log'; - - @override - String get developers => 'Mga Developer'; - - @override - String get not_logged_in => 'Hindi ka naka-log in'; - - @override - String get search_mode => 'Mode ng Paghahanap'; - - @override - String get audio_source => 'Pinagmulan ng Audio'; - - @override - String get ok => 'Ok'; - - @override - String get failed_to_encrypt => 'Nabigong i-encrypt'; - - @override - String get encryption_failed_warning => - 'Gumagamit ng encryption ang Spotube para ligtas na i-store ang iyong data. Ngunit nabigo. Kaya babalik ito sa hindi secure na storage\nKung gumagamit ka ng linux, mangyaring tiyakin na mayroon kang anumang secret-service na naka-install (gnome-keyring, kde-wallet, keepassxc atbp)'; - - @override - String get querying_info => 'Kinukuha ang impormasyon...'; - - @override - String get piped_api_down => 'Ang Piped API ay hindi gumagana'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'Ang instance ng Piped na $pipedInstance ay kasalukuyang hindi gumagana\n\nMaaari mong baguhin ang instance o baguhin ang \'Uri ng API\' sa opisyal na YouTube API\n\nSiguraduhing i-restart ang app pagkatapos ng pagbabago'; - } - - @override - String get you_are_offline => 'Kasalukuyan kang offline'; - - @override - String get connection_restored => - 'Naibalik na ang iyong koneksyon sa internet'; - - @override - String get use_system_title_bar => 'Gamitin ang title bar ng system'; - - @override - String get crunching_results => 'Pinaproseso ang mga resulta...'; - - @override - String get search_to_get_results => 'Maghanap para makakuha ng mga resulta'; - - @override - String get use_amoled_mode => 'Matingkad na itim na madilim na tema'; - - @override - String get pitch_dark_theme => 'AMOLED Mode'; - - @override - String get normalize_audio => 'I-normalize ang audio'; - - @override - String get change_cover => 'Baguhin ang cover'; - - @override - String get add_cover => 'Magdagdag ng cover'; - - @override - String get restore_defaults => 'Ibalik ang mga default'; - - @override - String get download_music_format => 'I-download na format ng musika'; - - @override - String get streaming_music_format => 'Format ng streaming ng musika'; - - @override - String get download_music_quality => 'Kalidad ng i-download na musika'; - - @override - String get streaming_music_quality => 'Kalidad ng streaming ng musika'; - - @override - String get login_with_lastfm => 'Mag-login gamit ang Last.fm'; - - @override - String get connect => 'Kumonekta'; - - @override - String get disconnect_lastfm => 'Idiskonekta ang Last.fm'; - - @override - String get disconnect => 'Idiskonekta'; - - @override - String get username => 'Username'; - - @override - String get password => 'Password'; - - @override - String get login => 'Mag-login'; - - @override - String get login_with_your_lastfm => - 'Mag-login gamit ang iyong Last.fm account'; - - @override - String get scrobble_to_lastfm => 'I-scrobble sa Last.fm'; - - @override - String get go_to_album => 'Pumunta sa Album'; - - @override - String get discord_rich_presence => 'Discord Rich Presence'; - - @override - String get browse_all => 'I-browse Lahat'; - - @override - String get genres => 'Mga Genre'; - - @override - String get explore_genres => 'Tuklasin ang mga Genre'; - - @override - String get friends => 'Mga Kaibigan'; - - @override - String get no_lyrics_available => - 'Paumanhin, hindi mahanap ang lyrics para sa track na ito'; - - @override - String get start_a_radio => 'Magsimula ng Radio'; - - @override - String get how_to_start_radio => 'Paano mo gustong simulan ang radio?'; - - @override - String get replace_queue_question => - 'Gusto mo bang palitan ang kasalukuyang pila o idagdag dito?'; - - @override - String get endless_playback => 'Walang Hanggang Playback'; - - @override - String get delete_playlist => 'Burahin ang Playlist'; - - @override - String get delete_playlist_confirmation => - 'Sigurado ka bang gusto mong burahin ang playlist na ito?'; - - @override - String get local_tracks => 'Mga Lokal na Track'; - - @override - String get local_tab => 'Lokal'; - - @override - String get song_link => 'Link ng Kanta'; - - @override - String get skip_this_nonsense => 'Laktawan ang kalokohan na ito'; - - @override - String get freedom_of_music => '\"Kalayaan ng Musika\"'; - - @override - String get freedom_of_music_palm => '\"Kalayaan ng Musika sa iyong palad\"'; - - @override - String get get_started => 'Magsimula na tayo'; - - @override - String get youtube_source_description => - 'Inirerekomenda at pinakamahusay na gumagana.'; - - @override - String get piped_source_description => - 'Gusto ng kalayaan? Kapareho ng YouTube ngunit mas malaya.'; - - @override - String get jiosaavn_source_description => - 'Pinakamahusay para sa rehiyon ng South Asia.'; - - @override - String get invidious_source_description => - 'Katulad ng Piped ngunit may mas mataas na availability.'; - - @override - String highest_quality(Object quality) { - return 'Pinakamataas na Kalidad: $quality'; - } - - @override - String get select_audio_source => 'Pumili ng Pinagmulan ng Audio'; - - @override - String get endless_playback_description => - 'Awtomatikong magdagdag ng mga bagong kanta\nsa dulo ng pila'; - - @override - String get choose_your_region => 'Piliin ang iyong rehiyon'; - - @override - String get choose_your_region_description => - 'Ito ay tutulong sa Spotube na ipakita sa iyo ang tamang content\npara sa iyong lokasyon.'; - - @override - String get choose_your_language => 'Piliin ang iyong wika'; - - @override - String get help_project_grow => 'Tulungan ang proyektong ito na lumago'; - - @override - String get help_project_grow_description => - 'Ang Spotube ay isang open-source na proyekto. Maaari mong tulungan ang proyektong ito na lumago sa pamamagitan ng pag-contribute sa proyekto, pag-ulat ng mga bug, o pagmungkahi ng mga bagong feature.'; - - @override - String get contribute_on_github => 'Mag-contribute sa GitHub'; - - @override - String get donate_on_open_collective => 'Mag-donate sa Open Collective'; - - @override - String get browse_anonymously => 'Mag-browse nang Anonymous'; - - @override - String get enable_connect => 'I-enable ang Connect'; - - @override - String get enable_connect_description => - 'Kontrolin ang Spotube mula sa ibang mga device'; - - @override - String get devices => 'Mga Device'; - - @override - String get select => 'Pumili'; - - @override - String connect_client_alert(Object client) { - return 'Ikaw ay kontrolado ng $client'; - } - - @override - String get this_device => 'Ang Device na ito'; - - @override - String get remote => 'Remote'; - - @override - String get stats => 'Mga Stat'; - - @override - String and_n_more(Object count) { - return 'at $count pa'; - } - - @override - String get recently_played => 'Kamakailan Lang na Ni-play'; - - @override - String get browse_more => 'Mag-browse pa'; - - @override - String get no_title => 'Walang Pamagat'; - - @override - String get not_playing => 'Hindi tumutugtog'; - - @override - String get epic_failure => 'Epic na pagkabigo!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'Nagdagdag ng $tracks_length na mga track sa pila'; - } - - @override - String get spotube_has_an_update => 'Ang Spotube ay may update'; - - @override - String get download_now => 'I-download Ngayon'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Ang Spotube Nightly $nightlyBuildNum ay inilabas na'; - } - - @override - String release_version(Object version) { - return 'Ang Spotube v$version ay inilabas na'; - } - - @override - String get read_the_latest => 'Basahin ang pinakabagong '; - - @override - String get release_notes => 'release notes'; - - @override - String get pick_color_scheme => 'Pumili ng color scheme'; - - @override - String get save => 'I-save'; - - @override - String get choose_the_device => 'Piliin ang device:'; - - @override - String get multiple_device_connected => - 'Mayroong maraming device na nakakonekta.\nPiliin ang device kung saan mo gustong maganap ang aksyon na ito'; - - @override - String get nothing_found => 'Walang nahanap'; - - @override - String get the_box_is_empty => 'Ang kahon ay walang laman'; - - @override - String get top_artists => 'Nangungunang mga Artista'; - - @override - String get top_albums => 'Nangungunang mga Album'; - - @override - String get this_week => 'Ngayong linggo'; - - @override - String get this_month => 'Ngayong buwan'; - - @override - String get last_6_months => 'Nakaraang 6 na buwan'; - - @override - String get this_year => 'Ngayong taon'; - - @override - String get last_2_years => 'Nakaraang 2 taon'; - - @override - String get all_time => 'Lahat ng panahon'; - - @override - String powered_by_provider(Object providerName) { - return 'Pinapagana ng $providerName'; - } - - @override - String get email => 'Email'; - - @override - String get profile_followers => 'Mga Tagasunod'; - - @override - String get birthday => 'Kaarawan'; - - @override - String get subscription => 'Subscription'; - - @override - String get not_born => 'Hindi pa ipinanganak'; - - @override - String get hacker => 'Hacker'; - - @override - String get profile => 'Profile'; - - @override - String get no_name => 'Walang Pangalan'; - - @override - String get edit => 'I-edit'; - - @override - String get user_profile => 'Profile ng User'; - - @override - String count_plays(Object count) { - return '$count na mga play'; - } - - @override - String get streaming_fees_hypothetical => - 'Mga bayarin sa streaming (hypothetical)'; - - @override - String get minutes_listened => 'Mga minutong pinapakinggan'; - - @override - String get streamed_songs => 'Mga na-stream na kanta'; - - @override - String count_streams(Object count) { - return '$count na mga stream'; - } - - @override - String get owned_by_you => 'Pag-aari mo'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return 'Na-kopya ang $shareUrl sa clipboard'; - } - - @override - String get hipotetical_calculation => - '*Ito ay kinakalkula batay sa average na payout ng online music streaming platform na \$0.003 hanggang \$0.005 kada stream. Ito ay isang hypothetical na kalkulasyon upang bigyan ang user ng insight kung magkano ang babayaran nila sa mga artist kung sakaling makinig sila ng kanilang kanta sa iba\'t ibang music streaming platform.'; - - @override - String count_mins(Object minutes) { - return '$minutes minuto'; - } - - @override - String get summary_minutes => 'minuto'; - - @override - String get summary_listened_to_music => 'Nakinig sa musika'; - - @override - String get summary_songs => 'mga kanta'; - - @override - String get summary_streamed_overall => 'Na-stream sa kabuuan'; - - @override - String get summary_owed_to_artists => 'Utang sa mga artista\nngayong buwan'; - - @override - String get summary_artists => 'artista'; - - @override - String get summary_music_reached_you => 'Umabot sa iyo ang musika'; - - @override - String get summary_full_albums => 'buong album'; - - @override - String get summary_got_your_love => 'Nakuha ang iyong pagmamahal'; - - @override - String get summary_playlists => 'mga playlist'; - - @override - String get summary_were_on_repeat => 'Pinu-playlst muli'; - - @override - String total_money(Object money) { - return 'Kabuuang $money'; - } - - @override - String get webview_not_found => 'Hindi nahanap ang Webview'; - - @override - String get webview_not_found_description => - 'Walang webview runtime na naka-install sa iyong device.\nKung naka-install ito, siguraduhing nasa Environment PATH\n\nPagkatapos mag-install, i-restart ang app'; - - @override - String get unsupported_platform => 'Hindi suportadong platform'; - - @override - String get cache_music => 'I-cache ang musika'; - - @override - String get open => 'Buksan'; - - @override - String get cache_folder => 'Folder ng cache'; - - @override - String get export => 'I-export'; - - @override - String get clear_cache => 'Burahin ang cache'; - - @override - String get clear_cache_confirmation => 'Gusto mo bang burahin ang cache?'; - - @override - String get export_cache_files => 'I-export ang mga Naka-cache na File'; - - @override - String found_n_files(Object count) { - return 'Nahanap ang $count na mga file'; - } - - @override - String get export_cache_confirmation => - 'Gusto mo bang i-export ang mga file na ito sa'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return 'Na-export ang $filesExported mula sa $files na mga file'; - } - - @override - String get undo => 'I-undo'; - - @override - String get download_all => 'I-download lahat'; - - @override - String get add_all_to_playlist => 'Idagdag lahat sa playlist'; - - @override - String get add_all_to_queue => 'Idagdag lahat sa pila'; - - @override - String get play_all_next => 'I-play lahat susunod'; - - @override - String get pause => 'Pause'; - - @override - String get view_all => 'Tingnan lahat'; - - @override - String get no_tracks_added_yet => - 'Mukhang wala ka pang idinaragdag na mga track'; - - @override - String get no_tracks => 'Mukhang walang mga track dito'; - - @override - String get no_tracks_listened_yet => 'Mukhang wala ka pang pinakikinggan'; - - @override - String get not_following_artists => - 'Hindi ka sumusunod sa anumang mga artista'; - - @override - String get no_favorite_albums_yet => - 'Mukhang wala ka pang idinagdag na anumang mga album sa iyong mga paborito'; - - @override - String get no_logs_found => 'Walang nahanap na mga log'; - - @override - String get youtube_engine => 'YouTube Engine'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return 'Hindi naka-install ang $engine'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return 'Hindi naka-install ang $engine sa iyong sistema.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Siguraduhing available ito sa PATH variable o\ni-set ang absolute path sa $engine executable sa ibaba'; - } - - @override - String get youtube_engine_unix_issue_message => - 'Sa macOS/Linux/unix tulad ng OS, ang pag-set ng path sa .zshrc/.bashrc/.bash_profile atbp. ay hindi gagana.\nKailangan mong i-set ang path sa configuration file ng shell'; - - @override - String get download => 'I-download'; - - @override - String get file_not_found => 'Hindi nahanap ang file'; - - @override - String get custom => 'Custom'; - - @override - String get add_custom_url => 'Magdagdag ng custom URL'; - - @override - String get edit_port => 'I-edit ang port'; - - @override - String get port_helper_msg => - 'Ang default ay -1 na nagpapahiwatig ng random na numero. Kung na-configure mo ang firewall, inirerekomenda na itakda ito.'; - - @override - String connect_request(Object client) { - return 'Payagan ang $client na kumonekta?'; - } - - @override - String get connection_request_denied => - 'Tanggihan ang koneksyon. Tinanggihan ng gumagamit ang pag-access.'; - - @override - String get an_error_occurred => 'May naganap na error'; - - @override - String get copy_to_clipboard => 'Kopyahin sa clipboard'; - - @override - String get view_logs => 'Tingnan ang mga log'; - - @override - String get retry => 'Subukang muli'; - - @override - String get no_default_metadata_provider_selected => - 'Wala kang nakatakdang default na metadata provider'; - - @override - String get manage_metadata_providers => - 'Pamahalaan ang mga metadata provider'; - - @override - String get open_link_in_browser => 'Buksan ang Link sa Browser?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Gusto mo bang buksan ang sumusunod na link'; - - @override - String get unsafe_url_warning => - 'Maaaring hindi ligtas ang pagbukas ng mga link mula sa hindi pinagkakatiwalaang pinagmulan. Mag-ingat!\nMaaari mo ring kopyahin ang link sa iyong clipboard.'; - - @override - String get copy_link => 'Kopyahin ang Link'; - - @override - String get building_your_timeline => - 'Binubuo ang iyong timeline batay sa iyong mga pinakinggan...'; - - @override - String get official => 'Opisyal'; - - @override - String author_name(Object author) { - return 'May-akda: $author'; - } - - @override - String get third_party => 'Third-party'; - - @override - String get plugin_requires_authentication => - 'Nangangailangan ng authentication ang plugin'; - - @override - String get update_available => 'May available na update'; - - @override - String get supports_scrobbling => 'Sinusuportahan ang scrobbling'; - - @override - String get plugin_scrobbling_info => - 'Sinis-scrobble ng plugin na ito ang iyong musika upang mabuo ang iyong kasaysayan ng pakikinig.'; - - @override - String get default_metadata_source => 'Default na pinagmulan ng metadata'; - - @override - String get set_default_metadata_source => - 'Itakda ang default na pinagmulan ng metadata'; - - @override - String get default_audio_source => 'Default na pinagmulan ng audio'; - - @override - String get set_default_audio_source => - 'Itakda ang default na pinagmulan ng audio'; - - @override - String get set_default => 'Itakda bilang default'; - - @override - String get support => 'Suporta'; - - @override - String get support_plugin_development => 'Suportahan ang pagbuo ng plugin'; - - @override - String can_access_name_api(Object name) { - return '- Maaaring i-access ang **$name** API'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Gusto mo bang i-install ang plugin na ito?'; - - @override - String get third_party_plugin_warning => - 'Ang plugin na ito ay mula sa third-party na repository. Mangyaring tiyakin na pinagkakatiwalaan mo ang pinagmulan bago mag-install.'; - - @override - String get author => 'May-akda'; - - @override - String get this_plugin_can_do_following => - 'Maaaring gawin ng plugin na ito ang sumusunod'; - - @override - String get install => 'I-install'; - - @override - String get install_a_metadata_provider => 'Mag-install ng Metadata Provider'; - - @override - String get no_tracks_playing => 'Walang Track na kasalukuyang tumutugtog'; - - @override - String get synced_lyrics_not_available => - 'Hindi available ang mga naka-sync na lyrics para sa kantang ito. Mangyaring gamitin ang'; - - @override - String get plain_lyrics => 'Simpleng Lyrics'; - - @override - String get tab_instead => 'na tab sa halip.'; - - @override - String get disclaimer => 'Disclaimer'; - - @override - String get third_party_plugin_dmca_notice => - 'Ang Spotube team ay walang hawak na anumang responsibilidad (kabilang ang legal) para sa anumang \"Third-party\" plugins.\nMangyaring gamitin ang mga ito sa iyong sariling peligro. Para sa anumang mga bug/isyu, mangyaring iulat ang mga ito sa repository ng plugin.\n\nKung ang anumang \"Third-party\" plugin ay lumalabag sa ToS/DMCA ng anumang serbisyo/legal na entity, mangyaring hilingin sa \"Third-party\" plugin author o sa hosting platform e.g. GitHub/Codeberg na gumawa ng aksyon. Ang nakalista sa itaas (\"Third-party\" na may label) ay lahat ng pampubliko/komunidad na pinananatiling mga plugin. Hindi namin sila kinukurusado, kaya hindi kami makakagawa ng anumang aksyon sa kanila.\n\n'; - - @override - String get input_does_not_match_format => - 'Ang input ay hindi tumutugma sa kinakailangang format'; - - @override - String get plugins => 'Mga plugin'; - - @override - String get paste_plugin_download_url => - 'I-paste ang download url o GitHub/Codeberg repo url o direktang link sa .smplug file'; - - @override - String get download_and_install_plugin_from_url => - 'I-download at i-install ang plugin mula sa url'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Nabigo ang pagdagdag ng plugin: $error'; - } - - @override - String get upload_plugin_from_file => 'I-upload ang plugin mula sa file'; - - @override - String get installed => 'Naka-install'; - - @override - String get available_plugins => 'Mga available na plugin'; - - @override - String get configure_plugins => - 'I-configure ang sarili mong metadata provider at mga audio source plugin'; - - @override - String get audio_scrobblers => 'Mga Audio Scrobbler'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Pinagmulan: '; - - @override - String get uncompressed => 'Hindi naka-compress'; - - @override - String get dab_music_source_description => - 'Para sa mga audiophile. Nagbibigay ng de-kalidad/walang loss na audio streams. Tumpak na pagtutugma ng track batay sa ISRC.'; -} diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart deleted file mode 100644 index c2280f47..00000000 --- a/lib/l10n/generated/app_localizations_tr.dart +++ /dev/null @@ -1,1573 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Turkish (`tr`). -class AppLocalizationsTr extends AppLocalizations { - AppLocalizationsTr([String locale = 'tr']) : super(locale); - - @override - String get guest => 'Misafir'; - - @override - String get browse => 'Göz at'; - - @override - String get search => 'Ara'; - - @override - String get library => 'Kütüphane'; - - @override - String get lyrics => 'Şarkı sözleri'; - - @override - String get settings => 'Ayarlar'; - - @override - String get genre_categories_filter => - 'Kategorileri veya türleri filtreleyin...'; - - @override - String get genre => 'Tür'; - - @override - String get personalized => 'Kişiselleştirilmiş'; - - @override - String get featured => 'Öne çıkanlar'; - - @override - String get new_releases => 'Yeni çıkanlar'; - - @override - String get songs => 'Şarkılar'; - - @override - String playing_track(Object track) { - return '$track oynatılıyor'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Bu, mevcut kuyruğu temizleyecektir. $track_length parça kaldırılacak\nDevam etmek istiyor musunuz?'; - } - - @override - String get load_more => 'Daha fazlasını yükle'; - - @override - String get playlists => 'Oynatma listeleri'; - - @override - String get artists => 'Sanatçılar'; - - @override - String get albums => 'Albümler'; - - @override - String get tracks => 'Parçalar'; - - @override - String get downloads => 'İndirilenler'; - - @override - String get filter_playlists => 'Oynatma listelerinizi filtreleyin...'; - - @override - String get liked_tracks => 'Beğenilen parçalar'; - - @override - String get liked_tracks_description => 'Beğendiğiniz tüm parçalar'; - - @override - String get playlist => 'Çalma Listesi'; - - @override - String get create_a_playlist => 'Bir oynatma listesi oluştur'; - - @override - String get update_playlist => 'Oynatma listesini güncelle'; - - @override - String get create => 'Oluştur'; - - @override - String get cancel => 'İptal'; - - @override - String get update => 'Güncelle'; - - @override - String get playlist_name => 'Oynatma listesi adı'; - - @override - String get name_of_playlist => 'Oynatma listesinin adı'; - - @override - String get description => 'Açıklama'; - - @override - String get public => 'Halka açık'; - - @override - String get collaborative => 'İşbirliği'; - - @override - String get search_local_tracks => 'Yerel parçaları ara...'; - - @override - String get play => 'Oynat'; - - @override - String get delete => 'Sil'; - - @override - String get none => 'Yok'; - - @override - String get sort_a_z => 'A - Z\'ye göre sırala'; - - @override - String get sort_z_a => 'Z - A\'ya göre sırala'; - - @override - String get sort_artist => 'Sanatçıya göre sırala'; - - @override - String get sort_album => 'Albüme göre sırala'; - - @override - String get sort_duration => 'Süreye göre sırala'; - - @override - String get sort_tracks => 'Parçaları sırala'; - - @override - String currently_downloading(Object tracks_length) { - return 'Şu anda indirilenler ($tracks_length)'; - } - - @override - String get cancel_all => 'Tümünü iptal et'; - - @override - String get filter_artist => 'Sanatçıları filtreleyin...'; - - @override - String followers(Object followers) { - return '$followers Takipçiler'; - } - - @override - String get add_artist_to_blacklist => 'Sanatçıyı kara listeye ekle'; - - @override - String get top_tracks => 'En iyi parçalar'; - - @override - String get fans_also_like => 'Hayranlar ayrıca şunları da beğendi'; - - @override - String get loading => 'Yükleniyor...'; - - @override - String get artist => 'Sanatçı'; - - @override - String get blacklisted => 'Kara listeye alındı'; - - @override - String get following => 'Takip ediliyor'; - - @override - String get follow => 'Takip et'; - - @override - String get artist_url_copied => 'Sanatçı bağlantısı panoya kopyalandı'; - - @override - String added_to_queue(Object tracks) { - return 'Kuyruğa $tracks parçası eklendi'; - } - - @override - String get filter_albums => 'Albümleri filtreleyin...'; - - @override - String get synced => 'Senkronize edildi'; - - @override - String get plain => 'Sade'; - - @override - String get shuffle => 'Karıştır'; - - @override - String get search_tracks => 'Parça ara...'; - - @override - String get released => 'Yayınlandı'; - - @override - String error(Object error) { - return 'Hata $error'; - } - - @override - String get title => 'Başlık'; - - @override - String get time => 'Zaman'; - - @override - String get more_actions => 'Daha fazla eylem'; - - @override - String download_count(Object count) { - return 'İndir ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Oynatma Listesine ekle ($count)'; - } - - @override - String add_count_to_queue(Object count) { - return 'Kuyruğa ekle ($count)'; - } - - @override - String play_count_next(Object count) { - return 'Sonrakini oynat ($count)'; - } - - @override - String get album => 'Albüm'; - - @override - String copied_to_clipboard(Object data) { - return '$data panoya kopyalandı'; - } - - @override - String add_to_following_playlists(Object track) { - return '$track parçasını aşağıdaki oynatma listelerine ekle'; - } - - @override - String get add => 'Ekle'; - - @override - String added_track_to_queue(Object track) { - return '$track kuyruğa eklendi'; - } - - @override - String get add_to_queue => 'Kuyruğa ekle'; - - @override - String track_will_play_next(Object track) { - return '$track bir sonraki çalacak'; - } - - @override - String get play_next => 'Sonrakini oynat'; - - @override - String removed_track_from_queue(Object track) { - return '$track kuyruktan kaldırıldı'; - } - - @override - String get remove_from_queue => 'Kuyruktan kaldır'; - - @override - String get remove_from_favorites => 'Favorilerden kaldır'; - - @override - String get save_as_favorite => 'Favori olarak kaydet'; - - @override - String get add_to_playlist => 'Oynatma listesine ekle'; - - @override - String get remove_from_playlist => 'Oynatma listesinden kaldır'; - - @override - String get add_to_blacklist => 'Kara listeye ekle'; - - @override - String get remove_from_blacklist => 'Kara listeden kaldır'; - - @override - String get share => 'Paylaş'; - - @override - String get mini_player => 'Mini oynatıcı'; - - @override - String get slide_to_seek => 'İleri veya geri arama yapmak için kaydırın'; - - @override - String get shuffle_playlist => 'Oynatma listesini karıştır'; - - @override - String get unshuffle_playlist => 'Oynatma listesinin karışıklığını kaldır'; - - @override - String get previous_track => 'Önceki parça'; - - @override - String get next_track => 'Sonraki parça'; - - @override - String get pause_playback => 'Oynatmayı duraklat'; - - @override - String get resume_playback => 'Oynatmayı sürdür'; - - @override - String get loop_track => 'Döngü parçası'; - - @override - String get no_loop => 'Dönüş Yok'; - - @override - String get repeat_playlist => 'Oynatma listesini tekrarla'; - - @override - String get queue => 'Kuyruk'; - - @override - String get alternative_track_sources => 'Alternatif parça kaynakları'; - - @override - String get download_track => 'Parçayı indir'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks parça kuyrukta'; - } - - @override - String get clear_all => 'Tümünü temizle'; - - @override - String get show_hide_ui_on_hover => - 'Fareyle üzerine gelindiğinde kullanıcı arayüzünü göster/gizle'; - - @override - String get always_on_top => 'Her zaman üstte'; - - @override - String get exit_mini_player => 'Mini oynatıcıdan çık'; - - @override - String get download_location => 'İndirme konumu'; - - @override - String get local_library => 'Yerel kütüphane'; - - @override - String get add_library_location => 'Kütüphaneye ekle'; - - @override - String get remove_library_location => 'Kütüphaneden çıkar'; - - @override - String get account => 'Hesap'; - - @override - String get logout => 'Çıkış yap'; - - @override - String get logout_of_this_account => 'Hesaptan çıkış yap'; - - @override - String get language_region => 'Dil ve bölge'; - - @override - String get language => 'Tercih edilen dil'; - - @override - String get system_default => 'Sistem varsayılanı'; - - @override - String get market_place_region => 'Tercih edilen bölge'; - - @override - String get recommendation_country => 'Tavsiye edilen ülke'; - - @override - String get appearance => 'Görünüm'; - - @override - String get layout_mode => 'Düzen modu'; - - @override - String get override_layout_settings => - 'Duyarlı düzen modu ayarlarını geçersiz kıl'; - - @override - String get adaptive => 'Uyarlanabilir'; - - @override - String get compact => 'Sıkıştırılmış'; - - @override - String get extended => 'Genişletilmiş'; - - @override - String get theme => 'Tema'; - - @override - String get dark => 'Koyu'; - - @override - String get light => 'Açık'; - - @override - String get system => 'Sistem'; - - @override - String get accent_color => 'Vurgu rengi'; - - @override - String get sync_album_color => 'Albüm rengini senkronize et'; - - @override - String get sync_album_color_description => - 'Vurgu rengi olarak albüm resminin baskın rengini kullanır'; - - @override - String get playback => 'Oynatma'; - - @override - String get audio_quality => 'Ses kalitesi'; - - @override - String get high => 'Yüksek'; - - @override - String get low => 'Düşük'; - - @override - String get pre_download_play => 'Önceden indir ve oynat'; - - @override - String get pre_download_play_description => - 'Ses akışı yerine baytları indir ve oynat (Daha yüksek bant genişliğine sahip kullanıcılar için önerilir)'; - - @override - String get skip_non_music => 'Müzik olmayan bölümleri atlat (SponsorBlock)'; - - @override - String get blacklist_description => - 'Kara listeye alınan parçalar ve sanatçılar'; - - @override - String get wait_for_download_to_finish => - 'Lütfen mevcut indirme işleminin tamamlanmasını bekleyin'; - - @override - String get desktop => 'Masaüstü'; - - @override - String get close_behavior => 'Kapatma davranışı'; - - @override - String get close => 'Kapat'; - - @override - String get minimize_to_tray => 'Tepsiye küçült'; - - @override - String get show_tray_icon => 'Sistem tepsisi simgesini göster'; - - @override - String get about => 'Hakkında'; - - @override - String get u_love_spotube => 'Spotube\'u sevdiğinizi biliyoruz'; - - @override - String get check_for_updates => 'Güncellemeleri kontrol et'; - - @override - String get about_spotube => 'Spotube hakkında'; - - @override - String get blacklist => 'Kara liste'; - - @override - String get please_sponsor => 'Sponsor Ol/Bağış Yap'; - - @override - String get spotube_description => - 'Spotube, hafif, platformlar arası uyumlu ve herkes için ücretsiz bir Spotify istemcisidir.'; - - @override - String get version => 'Sürüm'; - - @override - String get build_number => 'Derleme numarası'; - - @override - String get founder => 'Geliştirici'; - - @override - String get repository => 'Depo'; - - @override - String get bug_issues => 'Hata + Sorunlar'; - - @override - String get made_with => '❤️ ile Bangladeş\'te yapıldı'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Lisans'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Endişelenmeyin, kimlik bilgilerinizden hiçbiri toplanmayacak veya kimseyle paylaşılmayacak'; - - @override - String get know_how_to_login => 'Bunu nasıl yapacağınızı bilmiyor musunuz?'; - - @override - String get follow_step_by_step_guide => 'Adım adım kılavuzu takip edin'; - - @override - String cookie_name_cookie(Object name) { - return '$name çerezi'; - } - - @override - String get fill_in_all_fields => 'Lütfen tüm alanları doldurun'; - - @override - String get submit => 'Başvur'; - - @override - String get exit => 'Çık'; - - @override - String get previous => 'Önceki'; - - @override - String get next => 'Sonraki'; - - @override - String get done => 'Bitti'; - - @override - String get step_1 => '1. Adım'; - - @override - String get first_go_to => 'İlk olarak şuraya gidin:'; - - @override - String get something_went_wrong => 'Bir hata oluştu'; - - @override - String get piped_instance => 'Piped sunucu örneği'; - - @override - String get piped_description => - 'Parça eşleştirme için kullanılacak Piped sunucu örneği'; - - @override - String get piped_warning => - 'Bazıları iyi çalışmayabilir. Yani riski size ait olmak üzere kullanın'; - - @override - String get invidious_instance => 'Invidious Sunucu Örneği'; - - @override - String get invidious_description => - 'Parça eşleştirmesi için kullanılacak Invidious sunucu örneği'; - - @override - String get invidious_warning => - 'Bazıları iyi çalışmayabilir. Kendi riskinizde kullanın'; - - @override - String get generate => 'Oluştur'; - - @override - String track_exists(Object track) { - return '$track parçası zaten var'; - } - - @override - String get replace_downloaded_tracks => 'İndirilen tüm parçaları değiştir'; - - @override - String get skip_download_tracks => 'İndirilen tüm parçaları indirmeyi atla'; - - @override - String get do_you_want_to_replace => - 'Mevcut parçayı değiştirmek istiyor musunuz?'; - - @override - String get replace => 'Değiştir'; - - @override - String get skip => 'Atla'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'En fazla $count $type seçin'; - } - - @override - String get select_genres => 'Türleri seç'; - - @override - String get add_genres => 'Tür ekle'; - - @override - String get country => 'Ülke'; - - @override - String get number_of_tracks_generate => 'Oluşturulacak parça sayısı'; - - @override - String get acousticness => 'Akustiklik'; - - @override - String get danceability => 'Dans Edilebilirlik'; - - @override - String get energy => 'Enerji'; - - @override - String get instrumentalness => 'Araçsallık'; - - @override - String get liveness => 'Canlılık'; - - @override - String get loudness => 'Ses yüksekliği'; - - @override - String get speechiness => 'Konuşkanlık'; - - @override - String get valence => 'Değerlik'; - - @override - String get popularity => 'Popülerlik'; - - @override - String get key => 'Anahtar'; - - @override - String get duration => 'Süre (sn)'; - - @override - String get tempo => 'Tempo (BPM)'; - - @override - String get mode => 'Mod'; - - @override - String get time_signature => 'Zaman imzası'; - - @override - String get short => 'Kısa'; - - @override - String get medium => 'Orta'; - - @override - String get long => 'Uzun'; - - @override - String get min => 'Min'; - - @override - String get max => 'Maks'; - - @override - String get target => 'Hedef'; - - @override - String get moderate => 'Orta'; - - @override - String get deselect_all => 'Tüm seçimleri kaldır'; - - @override - String get select_all => 'Tümünü seç'; - - @override - String get are_you_sure => 'Emin misiniz?'; - - @override - String get generating_playlist => 'Özel oynatma listeniz oluşturuluyor...'; - - @override - String selected_count_tracks(Object count) { - return '$count parça seçildi'; - } - - @override - String get download_warning => - 'Tüm şarkıları toplu olarak indiriyorsanız, açıkça müzik korsanlığı yapıyorsunuz ve müzik dünyasının yaratıcı topluluğuna zarar veriyorsunuz demektir. Umuyorum bunun farkındasınızdır. Her zaman, sanatçıların emeğine saygı göstermeyi ve desteklemeyi deneyin.'; - - @override - String get download_ip_ban_warning => - 'Ayrıca, normalden fazla indirme istekleri nedeniyle YouTube\'da IP\'niz engellenebilir. IP engeli, en az 2-3 ay boyunca YouTube\'u (hatta oturum açmış olsanız bile) o IP cihazından kullanamayacağınız anlamına gelir. Ve eğer böyle bir durum yaşanırsa, Spotube bundan hiçbir sorumluluk kabul etmez.'; - - @override - String get by_clicking_accept_terms => - '\"Kabul et\" e tıklayarak aşağıdaki şartları kabul etmiş olursunuz:'; - - @override - String get download_agreement_1 => - 'Müzik korsanlığı yaptığımı biliyorum. Ben fakir biriyim.'; - - @override - String get download_agreement_2 => - 'Sanatçıyı elimden geldiğince destekleyeceğim ve bunu sadece sanatını satın alacak param olmadığı için yapıyorum'; - - @override - String get download_agreement_3 => - 'YouTube\'da IP\'min engellenebileceğinin tamamen farkındayım ve mevcut eylemlerimden kaynaklanan herhangi bir kaza için Spotube\'u veya sahiplerini/katkıda bulunanları sorumlu tutmuyorum.'; - - @override - String get decline => 'Reddet'; - - @override - String get accept => 'Kabul et'; - - @override - String get details => 'Detaylar'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Kanal'; - - @override - String get likes => 'Beğenenler'; - - @override - String get dislikes => 'Beğenmeyenler'; - - @override - String get views => 'İzlenmeler'; - - @override - String get streamUrl => 'Akış bağlantısı'; - - @override - String get stop => 'Durdur'; - - @override - String get sort_newest => 'En yeni eklenene göre sırala.'; - - @override - String get sort_oldest => 'En eski eklenene göre sırala'; - - @override - String get sleep_timer => 'Uyku Zamanlayıcısı'; - - @override - String mins(Object minutes) { - return '$minutes Dakika'; - } - - @override - String hours(Object hours) { - return '$hours Saatler'; - } - - @override - String hour(Object hours) { - return '$hours Saat'; - } - - @override - String get custom_hours => 'Özel Saatler'; - - @override - String get logs => 'Günlükler'; - - @override - String get developers => 'Geliştiriciler'; - - @override - String get not_logged_in => 'Giriş yapmadınız'; - - @override - String get search_mode => 'Arama modu'; - - @override - String get audio_source => 'Ses kaynağı'; - - @override - String get ok => 'Tamam'; - - @override - String get failed_to_encrypt => 'Şifreleme başarısız oldu'; - - @override - String get encryption_failed_warning => - 'Spotube, verilerinizi güvenli bir şekilde depolamak için şifreleme kullanır. Ancak bunu başaramadı. Bu nedenle, güvensiz depolamaya geri dönecektir\nLinux kullanıyorsanız, lütfen gnome-keyring, kde-wallet, keepassxc vb. herhangi bir gizli servisin yüklü olduğundan emin olun.'; - - @override - String get querying_info => 'Bilgi sorgulanıyor...'; - - @override - String get piped_api_down => 'Piped API kapalı'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'Piped örneği $pipedInstance şu anda kapalı\n\nÖrneği değiştirin veya \'API türünü\' resmi YouTube API\'si olarak değiştirin\n\nDeğişiklikten sonra uygulamayı yeniden başlattığınızdan emin olun'; - } - - @override - String get you_are_offline => 'Şu anda çevrimdışısınız'; - - @override - String get connection_restored => 'İnternet bağlantınız geri yüklendi'; - - @override - String get use_system_title_bar => 'Sistem başlık çubuğunu kullan'; - - @override - String get crunching_results => 'Sonuçlar...'; - - @override - String get search_to_get_results => 'Sonuç almak için arayın'; - - @override - String get use_amoled_mode => 'AMOLED modu kullan'; - - @override - String get pitch_dark_theme => 'Zifiri karanlık koyu tema'; - - @override - String get normalize_audio => 'Sesi normalleştir'; - - @override - String get change_cover => 'Kapağı değiştir'; - - @override - String get add_cover => 'Kapak ekle'; - - @override - String get restore_defaults => 'Varsayılanları geri yükle'; - - @override - String get download_music_format => 'Müzik indirme formatı'; - - @override - String get streaming_music_format => 'Müzik akış formatı'; - - @override - String get download_music_quality => 'İndirilen müzik kalitesi'; - - @override - String get streaming_music_quality => 'Yayınlanan müzik kalitesi'; - - @override - String get login_with_lastfm => 'Last.fm ile giriş yap'; - - @override - String get connect => 'Bağlan'; - - @override - String get disconnect_lastfm => 'Last.fm bağlantısını kes'; - - @override - String get disconnect => 'Bağlantıyı kes'; - - @override - String get username => 'Kullanıcı adı'; - - @override - String get password => 'Şifre'; - - @override - String get login => 'Giriş yap'; - - @override - String get login_with_your_lastfm => 'Last.fm hesabınızla giriş yapın'; - - @override - String get scrobble_to_lastfm => 'Last.fm için Scrobble'; - - @override - String get go_to_album => 'Albüme git'; - - @override - String get discord_rich_presence => 'Discord zengin varlığı'; - - @override - String get browse_all => 'Tümüne göz at'; - - @override - String get genres => 'Müzik türleri'; - - @override - String get explore_genres => 'Türleri keşfet'; - - @override - String get friends => 'Arkadaşlar'; - - @override - String get no_lyrics_available => 'Üzgünüz, bu parçanın sözleri bulunamıyor'; - - @override - String get start_a_radio => 'Radyo başlat'; - - @override - String get how_to_start_radio => 'Radyoyu nasıl başlatmak istersiniz?'; - - @override - String get replace_queue_question => - 'Mevcut kuyruğu değiştirmek mi yoksa eklemek mi istersiniz?'; - - @override - String get endless_playback => 'Sonsuz olarak oynat'; - - @override - String get delete_playlist => 'Oynatma listesini sil'; - - @override - String get delete_playlist_confirmation => - 'Bu oynatma listesini silmek istediğinizden emin misiniz?'; - - @override - String get local_tracks => 'Yerel parçalar'; - - @override - String get local_tab => 'Yerel'; - - @override - String get song_link => 'Şarkı bağlantısı'; - - @override - String get skip_this_nonsense => 'Bu saçmalığı atla'; - - @override - String get freedom_of_music => '“Müzik özgürlüğü”'; - - @override - String get freedom_of_music_palm => '“Müzik özgürlüğü avucunuzun içinde”'; - - @override - String get get_started => 'Haydi başlayalım'; - - @override - String get youtube_source_description => - 'Tavsiye edilir ve en iyi şekilde çalışır.'; - - @override - String get piped_source_description => - 'Özgür hissediyor musunuz? YouTube ile aynı, ama çok daha özgür.'; - - @override - String get jiosaavn_source_description => 'Güney Asya bölgesi için en iyisi.'; - - @override - String get invidious_source_description => - 'Piped\'a benzer, ancak daha yüksek kullanılabilirliğe sahip.'; - - @override - String highest_quality(Object quality) { - return 'En yüksek kalite: $quality'; - } - - @override - String get select_audio_source => 'Ses kaynağını seçin'; - - @override - String get endless_playback_description => - 'Yeni şarkıları otomatik olarak\nkuyruğun sonuna ekle'; - - @override - String get choose_your_region => 'Bölgenizi seçin'; - - @override - String get choose_your_region_description => - 'Bu, Spotube\'un konumunuza uygun içerikleri göstermesine yardımcı olacaktır.'; - - @override - String get choose_your_language => 'Dilinizi seçin'; - - @override - String get help_project_grow => 'Bu projenin büyümesine yardımcı olun'; - - @override - String get help_project_grow_description => - 'Spotube açık kaynaklı bir projedir. Projeye katkıda bulunarak, hataları bildirerek veya yeni özellikler önererek bu projenin büyümesine yardımcı olabilirsiniz.'; - - @override - String get contribute_on_github => 'GitHub\'da katkıda bulun'; - - @override - String get donate_on_open_collective => 'Open Collective\'de bağış yap'; - - @override - String get browse_anonymously => 'Anonim olarak giriş yap'; - - @override - String get enable_connect => 'Bağlanmayı etkinleştir'; - - @override - String get enable_connect_description => - 'Spotube\'u diğer cihazlardan kontrol edin'; - - @override - String get devices => 'Cihazlar'; - - @override - String get select => 'Seç'; - - @override - String connect_client_alert(Object client) { - return '$client tarafından kontrol ediliyorsun.'; - } - - @override - String get this_device => 'Bu cihaz'; - - @override - String get remote => 'Yönet'; - - @override - String get stats => 'İstatistikler'; - - @override - String and_n_more(Object count) { - return 've $count daha'; - } - - @override - String get recently_played => 'Son Çalınanlar'; - - @override - String get browse_more => 'Daha Fazla Göz At'; - - @override - String get no_title => 'Başlık Yok'; - - @override - String get not_playing => 'Çalmıyor'; - - @override - String get epic_failure => 'Efsanevi başarısızlık!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return '$tracks_length şarkı sıraya eklendi'; - } - - @override - String get spotube_has_an_update => 'Spotube bir güncelleme aldı'; - - @override - String get download_now => 'Şimdi İndir'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum yayımlandı'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version yayımlandı'; - } - - @override - String get read_the_latest => 'Son haberleri oku'; - - @override - String get release_notes => 'sürüm notları'; - - @override - String get pick_color_scheme => 'Renk şeması seç'; - - @override - String get save => 'Kaydet'; - - @override - String get choose_the_device => 'Cihazı seçin:'; - - @override - String get multiple_device_connected => - 'Birden fazla cihaz bağlı.\nBu işlemi gerçekleştirmek istediğiniz cihazı seçin'; - - @override - String get nothing_found => 'Hiçbir şey bulunamadı'; - - @override - String get the_box_is_empty => 'Kutu boş'; - - @override - String get top_artists => 'En İyi Sanatçılar'; - - @override - String get top_albums => 'En İyi Albümler'; - - @override - String get this_week => 'Bu hafta'; - - @override - String get this_month => 'Bu ay'; - - @override - String get last_6_months => 'Son 6 ay'; - - @override - String get this_year => 'Bu yıl'; - - @override - String get last_2_years => 'Son 2 yıl'; - - @override - String get all_time => 'Tüm zamanlar'; - - @override - String powered_by_provider(Object providerName) { - return '$providerName tarafından desteklenmektedir'; - } - - @override - String get email => 'E-posta'; - - @override - String get profile_followers => 'Takipçiler'; - - @override - String get birthday => 'Doğum Günü'; - - @override - String get subscription => 'Abonelik'; - - @override - String get not_born => 'Henüz doğmadı'; - - @override - String get hacker => 'Hacker'; - - @override - String get profile => 'Profil'; - - @override - String get no_name => 'İsim Yok'; - - @override - String get edit => 'Düzenle'; - - @override - String get user_profile => 'Kullanıcı Profili'; - - @override - String count_plays(Object count) { - return '$count çalma'; - } - - @override - String get streaming_fees_hypothetical => - '*Spotify\'ın akış başına ödeme miktarına\n\$0.003 ile \$0.005 arasında hesaplanmıştır. Bu, kullanıcıya\nSpotify\'da şarkılarını dinlerse sanatçılara ne kadar ödeme\nyapmış olabileceğini göstermek için hipotetik bir hesaplamadır.'; - - @override - String get minutes_listened => 'Dinlenilen Dakikalar'; - - @override - String get streamed_songs => 'Yayınlanan Şarkılar'; - - @override - String count_streams(Object count) { - return '$count yayın'; - } - - @override - String get owned_by_you => 'Sahip olduğunuz'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl panoya kopyalandı'; - } - - @override - String get hipotetical_calculation => - '*Bu, çevrimiçi müzik akışı platformlarının ortalama akış başına \$0,003 ile \$0,005 arasındaki ödemesine göre hesaplanmıştır. Bu, kullanıcının farklı müzik akışı platformlarında şarkılarını dinleselerdi sanatçılara ne kadar ödeme yapacaklarına dair fikir vermek için yapılan varsayımsal bir hesaplamadır.'; - - @override - String count_mins(Object minutes) { - return '$minutes dk'; - } - - @override - String get summary_minutes => 'dakika'; - - @override - String get summary_listened_to_music => 'Dinlenen müzik'; - - @override - String get summary_songs => 'şarkılar'; - - @override - String get summary_streamed_overall => 'Genel olarak akış'; - - @override - String get summary_owed_to_artists => 'Sanatçılara borç\nbu ay'; - - @override - String get summary_artists => 'sanatçının'; - - @override - String get summary_music_reached_you => 'Müzik sana ulaştı'; - - @override - String get summary_full_albums => 'tam albümler'; - - @override - String get summary_got_your_love => 'Sevgini aldı'; - - @override - String get summary_playlists => 'çalma listeleri'; - - @override - String get summary_were_on_repeat => 'Tekrarda vardı'; - - @override - String total_money(Object money) { - return 'Toplam $money'; - } - - @override - String get webview_not_found => 'Webview bulunamadı'; - - @override - String get webview_not_found_description => - 'Cihazınızda herhangi bir Webview çalışma zamanı yüklü değil.\nEğer kuruluysa, ortam YOLUNDA olduğundan emin olun\n\nKurulumdan sonra uygulamayı yeniden başlatın'; - - @override - String get unsupported_platform => 'Desteklenmeyen platform'; - - @override - String get cache_music => 'Müziği önbellekle'; - - @override - String get open => 'Aç'; - - @override - String get cache_folder => 'Önbellek klasörü'; - - @override - String get export => 'Dışa aktar'; - - @override - String get clear_cache => 'Önbelleği temizle'; - - @override - String get clear_cache_confirmation => - 'Önbelleği temizlemek istiyor musunuz?'; - - @override - String get export_cache_files => 'Önbelleğe Alınmış Dosyaları Dışa Aktar'; - - @override - String found_n_files(Object count) { - return '$count dosya bulundu'; - } - - @override - String get export_cache_confirmation => - 'Bu dosyaları dışa aktarmak istiyor musunuz'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return '$filesExported / $files dosya dışa aktarıldı'; - } - - @override - String get undo => 'Geri Al'; - - @override - String get download_all => 'Tümünü İndir'; - - @override - String get add_all_to_playlist => 'Hepsini çalma listesine ekle'; - - @override - String get add_all_to_queue => 'Hepsini kuyruğa ekle'; - - @override - String get play_all_next => 'Hepsini bir sonraki çal'; - - @override - String get pause => 'Duraklat'; - - @override - String get view_all => 'Tümünü Gör'; - - @override - String get no_tracks_added_yet => - 'Henüz hiçbir şarkı eklemediniz gibi görünüyor'; - - @override - String get no_tracks => 'Burada hiç şarkı yok gibi görünüyor'; - - @override - String get no_tracks_listened_yet => - 'Henüz hiçbir şey dinlemediniz gibi görünüyor'; - - @override - String get not_following_artists => 'Hiçbir sanatçıyı takip etmiyorsunuz'; - - @override - String get no_favorite_albums_yet => - 'Henüz favorilerinize herhangi bir albüm eklemediniz gibi görünüyor'; - - @override - String get no_logs_found => 'Log bulunamadı'; - - @override - String get youtube_engine => 'YouTube Motoru'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine Yüklü değil'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine sisteminizde yüklü değil.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'PATH değişkeninde kullanılabilir olduğundan emin olun veya\n$engine çalıştırılabilir dosyasının mutlak yolunu aşağıda ayarlayın'; - } - - @override - String get youtube_engine_unix_issue_message => - 'macOS/Linux/Unix benzeri işletim sistemlerinde, .zshrc/.bashrc/.bash_profile gibi dosyalarda yol ayarlamak işe yaramaz.\nYolunuzu kabuk yapılandırma dosyasına ayarlamanız gerekir'; - - @override - String get download => 'İndir'; - - @override - String get file_not_found => 'Dosya bulunamadı'; - - @override - String get custom => 'Özel'; - - @override - String get add_custom_url => 'Özel URL ekle'; - - @override - String get edit_port => 'Portu düzenle'; - - @override - String get port_helper_msg => - 'Varsayılan -1\'dir, bu da rastgele bir sayıyı gösterir. Bir güvenlik duvarınız varsa, bunu ayarlamanız önerilir.'; - - @override - String connect_request(Object client) { - return '$client bağlantısına izin verilsin mi?'; - } - - @override - String get connection_request_denied => - 'Bağlantı reddedildi. Kullanıcı erişimi reddetti.'; - - @override - String get an_error_occurred => 'Bir hata oluştu'; - - @override - String get copy_to_clipboard => 'Panoya kopyala'; - - @override - String get view_logs => 'Günlükleri görüntüle'; - - @override - String get retry => 'Tekrar dene'; - - @override - String get no_default_metadata_provider_selected => - 'Varsayılan bir meta veri sağlayıcısı ayarlanmadı'; - - @override - String get manage_metadata_providers => 'Meta veri sağlayıcılarını yönet'; - - @override - String get open_link_in_browser => 'Bağlantıyı Tarayıcıda Aç?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Aşağıdaki bağlantıyı açmak istiyor musunuz'; - - @override - String get unsafe_url_warning => - 'Güvenilmeyen kaynaklardan bağlantı açmak güvensiz olabilir. Dikkatli olun!\nBağlantıyı panonuza da kopyalayabilirsiniz.'; - - @override - String get copy_link => 'Bağlantıyı Kopyala'; - - @override - String get building_your_timeline => - 'Dinlemelerinize göre zaman çizelgeniz oluşturuluyor...'; - - @override - String get official => 'Resmi'; - - @override - String author_name(Object author) { - return 'Yazar: $author'; - } - - @override - String get third_party => 'Üçüncü taraf'; - - @override - String get plugin_requires_authentication => - 'Eklenti kimlik doğrulama gerektirir'; - - @override - String get update_available => 'Güncelleme mevcut'; - - @override - String get supports_scrobbling => 'Scrobbling\'i destekler'; - - @override - String get plugin_scrobbling_info => - 'Bu eklenti, dinleme geçmişinizi oluşturmak için müziğinizi scrobble eder.'; - - @override - String get default_metadata_source => 'Varsayılan meta veri kaynağı'; - - @override - String get set_default_metadata_source => - 'Varsayılan meta veri kaynağını ayarla'; - - @override - String get default_audio_source => 'Varsayılan ses kaynağı'; - - @override - String get set_default_audio_source => 'Varsayılan ses kaynağını ayarla'; - - @override - String get set_default => 'Varsayılan olarak ayarla'; - - @override - String get support => 'Destek'; - - @override - String get support_plugin_development => 'Eklenti geliştirmeyi destekle'; - - @override - String can_access_name_api(Object name) { - return '- **$name** API\'ye erişebilir'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Bu eklentiyi yüklemek istiyor musunuz?'; - - @override - String get third_party_plugin_warning => - 'Bu eklenti üçüncü taraf bir depodan gelmektedir. Lütfen yüklemeden önce kaynağa güvendiğinizden emin olun.'; - - @override - String get author => 'Yazar'; - - @override - String get this_plugin_can_do_following => - 'Bu eklenti aşağıdakileri yapabilir'; - - @override - String get install => 'Yükle'; - - @override - String get install_a_metadata_provider => 'Bir Meta Veri Sağlayıcısı Yükle'; - - @override - String get no_tracks_playing => 'Şu anda çalınan bir Parça yok'; - - @override - String get synced_lyrics_not_available => - 'Bu şarkı için senkronize şarkı sözleri mevcut değil. Lütfen'; - - @override - String get plain_lyrics => 'Düz Şarkı Sözleri'; - - @override - String get tab_instead => 'sekmesini kullanın.'; - - @override - String get disclaimer => 'Sorumluluk Reddi'; - - @override - String get third_party_plugin_dmca_notice => - 'Spotube ekibi, herhangi bir \"Üçüncü taraf\" eklentisi için herhangi bir sorumluluk (yasal olanlar dahil) kabul etmez.\nLütfen bunları kendi riskinizde kullanın. Herhangi bir hata/sorun için lütfen bunları eklenti deposuna bildirin.\n\nHerhangi bir \"Üçüncü taraf\" eklentisi bir hizmetin/yasal varlığın ToS/DMCA\'sını ihlal ediyorsa, lütfen \"Üçüncü taraf\" eklenti yazarından veya barındırma platformundan, örneğin GitHub/Codeberg\'den harekete geçmesini isteyin. Yukarıda listelenen (\"Üçüncü taraf\" olarak etiketlenen) eklentilerin tümü genel/topluluk tarafından sürdürülen eklentilerdir. Biz bunları küratörlüğünü yapmıyoruz, bu yüzden onlar üzerinde herhangi bir işlem yapamayız.\n\n'; - - @override - String get input_does_not_match_format => 'Girdi, gerekli biçimle eşleşmiyor'; - - @override - String get plugins => 'Eklentiler'; - - @override - String get paste_plugin_download_url => - 'İndirme url\'sini veya GitHub/Codeberg repo url\'sini veya .smplug dosyasına doğrudan bağlantıyı yapıştırın'; - - @override - String get download_and_install_plugin_from_url => - 'url\'den eklentiyi indir ve yükle'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Eklenti eklenemedi: $error'; - } - - @override - String get upload_plugin_from_file => 'Dosyadan eklenti yükle'; - - @override - String get installed => 'Yüklü'; - - @override - String get available_plugins => 'Mevcut eklentiler'; - - @override - String get configure_plugins => - 'Kendi meta veri sağlayıcı ve ses kaynağı eklentilerinizi yapılandırın'; - - @override - String get audio_scrobblers => 'Ses Scrobbler\'lar'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Kaynak: '; - - @override - String get uncompressed => 'Sıkıştırılmamış'; - - @override - String get dab_music_source_description => - 'Audiophile\'ler için. Yüksek kaliteli/kayıpsız ses akışları sağlar. Doğru ISRC tabanlı parça eşleştirme.'; -} diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart deleted file mode 100644 index c2bed426..00000000 --- a/lib/l10n/generated/app_localizations_uk.dart +++ /dev/null @@ -1,1570 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Ukrainian (`uk`). -class AppLocalizationsUk extends AppLocalizations { - AppLocalizationsUk([String locale = 'uk']) : super(locale); - - @override - String get guest => 'Гість'; - - @override - String get browse => 'Огляд'; - - @override - String get search => 'Пошук'; - - @override - String get library => 'Медіатека'; - - @override - String get lyrics => 'Тексти пісень'; - - @override - String get settings => 'Налаштування'; - - @override - String get genre_categories_filter => 'Фільтрувати категорії або жанри...'; - - @override - String get genre => 'Жанр'; - - @override - String get personalized => 'Персоналізовані'; - - @override - String get featured => 'Рекомендовані'; - - @override - String get new_releases => 'Нові релізи'; - - @override - String get songs => 'Пісні'; - - @override - String playing_track(Object track) { - return 'Відтворюється $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Це очистить поточну чергу. Буде видалено $track_length треків\nПродовжити?'; - } - - @override - String get load_more => 'Завантажити більше'; - - @override - String get playlists => 'Плейлисти'; - - @override - String get artists => 'Виконавці'; - - @override - String get albums => 'Альбоми'; - - @override - String get tracks => 'Треки'; - - @override - String get downloads => 'Завантаження'; - - @override - String get filter_playlists => 'Фільтрувати плейлисти...'; - - @override - String get liked_tracks => 'Сподобалися треки'; - - @override - String get liked_tracks_description => 'Усі ваші сподобалися треки'; - - @override - String get playlist => 'Плейлист'; - - @override - String get create_a_playlist => 'Створити плейлист'; - - @override - String get update_playlist => 'Оновити плейлист'; - - @override - String get create => 'Створити'; - - @override - String get cancel => 'Скасувати'; - - @override - String get update => 'Оновити'; - - @override - String get playlist_name => 'Назва плейлиста'; - - @override - String get name_of_playlist => 'Назва плейлиста'; - - @override - String get description => 'Опис'; - - @override - String get public => 'Публічний'; - - @override - String get collaborative => 'Спільний'; - - @override - String get search_local_tracks => 'Пошук локальних треків...'; - - @override - String get play => 'Відтворити'; - - @override - String get delete => 'Видалити'; - - @override - String get none => 'Немає'; - - @override - String get sort_a_z => 'Сортувати за алфавітом A-Я'; - - @override - String get sort_z_a => 'Сортувати за алфавітом Я-А'; - - @override - String get sort_artist => 'Сортувати за виконавцем'; - - @override - String get sort_album => 'Сортувати за альбомом'; - - @override - String get sort_duration => 'Сортувати за тривалістю'; - - @override - String get sort_tracks => 'Сортувати треки'; - - @override - String currently_downloading(Object tracks_length) { - return 'Завантажується ($tracks_length)'; - } - - @override - String get cancel_all => 'Скасувати все'; - - @override - String get filter_artist => 'Фільтрувати виконавців...'; - - @override - String followers(Object followers) { - return '$followers підписників'; - } - - @override - String get add_artist_to_blacklist => 'Додати виконавця до чорного списку'; - - @override - String get top_tracks => 'Топ треки'; - - @override - String get fans_also_like => 'Шанувальникам також подобається'; - - @override - String get loading => 'Завантаження...'; - - @override - String get artist => 'Виконавець'; - - @override - String get blacklisted => 'У чорному списку'; - - @override - String get following => 'Стежу'; - - @override - String get follow => 'Стежити'; - - @override - String get artist_url_copied => 'URL виконавця скопійовано до буфера обміну'; - - @override - String added_to_queue(Object tracks) { - return 'Додано $tracks треків до черги'; - } - - @override - String get filter_albums => 'Фільтрувати альбоми...'; - - @override - String get synced => 'Синхронізовано'; - - @override - String get plain => 'Звичайний'; - - @override - String get shuffle => 'Випадковий порядок'; - - @override - String get search_tracks => 'Пошук треків...'; - - @override - String get released => 'Випущено'; - - @override - String error(Object error) { - return 'Помилка $error'; - } - - @override - String get title => 'Назва'; - - @override - String get time => 'Час'; - - @override - String get more_actions => 'Більше дій'; - - @override - String download_count(Object count) { - return 'Завантажено ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Додати ($count) до плейлиста'; - } - - @override - String add_count_to_queue(Object count) { - return 'Додати ($count) до черги'; - } - - @override - String play_count_next(Object count) { - return 'Відтворити ($count) наступними'; - } - - @override - String get album => 'Альбом'; - - @override - String copied_to_clipboard(Object data) { - return 'Скопійовано $data до буфера обміну'; - } - - @override - String add_to_following_playlists(Object track) { - return 'Додати $track до наступних плейлистів'; - } - - @override - String get add => 'Додати'; - - @override - String added_track_to_queue(Object track) { - return 'Додано $track до черги'; - } - - @override - String get add_to_queue => 'Додати до черги'; - - @override - String track_will_play_next(Object track) { - return '$track буде відтворено наступним'; - } - - @override - String get play_next => 'Відтворити наступним'; - - @override - String removed_track_from_queue(Object track) { - return 'Видалено $track з черги'; - } - - @override - String get remove_from_queue => 'Видалити з черги'; - - @override - String get remove_from_favorites => 'Видалити з обраних'; - - @override - String get save_as_favorite => 'Зберегти як обране'; - - @override - String get add_to_playlist => 'Додати до плейлиста'; - - @override - String get remove_from_playlist => 'Видалити з плейлиста'; - - @override - String get add_to_blacklist => 'Додати до чорного списку'; - - @override - String get remove_from_blacklist => 'Видалити з чорного списку'; - - @override - String get share => 'Поділитися'; - - @override - String get mini_player => 'Міні-плеєр'; - - @override - String get slide_to_seek => - 'Проведіть пальцем, щоб перемотати вперед або назад'; - - @override - String get shuffle_playlist => 'Випадковий порядок відтворення плейлиста'; - - @override - String get unshuffle_playlist => - 'Відключити випадковий порядок відтворення плейлиста'; - - @override - String get previous_track => 'Попередній трек'; - - @override - String get next_track => 'Наступний трек'; - - @override - String get pause_playback => 'Призупинити відтворення'; - - @override - String get resume_playback => 'Відновити відтворення'; - - @override - String get loop_track => 'Повторювати трек'; - - @override - String get no_loop => 'Без повтору'; - - @override - String get repeat_playlist => 'Повторювати плейлист'; - - @override - String get queue => 'Черга'; - - @override - String get alternative_track_sources => 'Альтернативні джерела треків'; - - @override - String get download_track => 'Завантажити трек'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks треків у черзі'; - } - - @override - String get clear_all => 'Очистити все'; - - @override - String get show_hide_ui_on_hover => - 'Показувати/приховувати інтерфейс при наведенні курсору'; - - @override - String get always_on_top => 'Завжди зверху'; - - @override - String get exit_mini_player => 'Вийти з міні-плеєра'; - - @override - String get download_location => 'Шлях завантаження'; - - @override - String get local_library => 'Місцева бібліотека'; - - @override - String get add_library_location => 'Додати до бібліотеки'; - - @override - String get remove_library_location => 'Видалити з бібліотеки'; - - @override - String get account => 'Обліковий запис'; - - @override - String get logout => 'Вийти'; - - @override - String get logout_of_this_account => 'Вийти з цього облікового запису'; - - @override - String get language_region => 'Мова та регіон'; - - @override - String get language => 'Мова'; - - @override - String get system_default => 'Системна мова'; - - @override - String get market_place_region => 'Регіон маркетплейсу'; - - @override - String get recommendation_country => 'Країна рекомендацій'; - - @override - String get appearance => 'Зовнішній вигляд'; - - @override - String get layout_mode => 'Режим макета'; - - @override - String get override_layout_settings => - 'Перезаписати налаштування адаптивного режиму макета'; - - @override - String get adaptive => 'Адаптивний'; - - @override - String get compact => 'Компактний'; - - @override - String get extended => 'Розширений'; - - @override - String get theme => 'Тема'; - - @override - String get dark => 'Темна'; - - @override - String get light => 'Світла'; - - @override - String get system => 'Системна'; - - @override - String get accent_color => 'Колір акценту'; - - @override - String get sync_album_color => 'Синхронізувати колір альбому'; - - @override - String get sync_album_color_description => - 'Використовує домінуючий колір обкладинки альбому як колір акценту'; - - @override - String get playback => 'Відтворення'; - - @override - String get audio_quality => 'Якість аудіо'; - - @override - String get high => 'Висока'; - - @override - String get low => 'Низька'; - - @override - String get pre_download_play => 'Попереднє завантаження та відтворення'; - - @override - String get pre_download_play_description => - 'Замість потокового відтворення аудіо завантажте байти та відтворіть їх (рекомендовано для користувачів з високою пропускною здатністю)'; - - @override - String get skip_non_music => 'Пропустити не музичні сегменти'; - - @override - String get blacklist_description => 'Треки та виконавці в чорному списку'; - - @override - String get wait_for_download_to_finish => - 'Зачекайте, поки завершиться поточна загрузка'; - - @override - String get desktop => 'Робочий стіл'; - - @override - String get close_behavior => 'Поведінка при закритті'; - - @override - String get close => 'Закрити'; - - @override - String get minimize_to_tray => 'Згорнути в трей'; - - @override - String get show_tray_icon => 'Показувати значок у системному треї'; - - @override - String get about => 'Про'; - - @override - String get u_love_spotube => 'Ми знаємо, що ви любите Spotube'; - - @override - String get check_for_updates => 'Перевірити наявність оновлень'; - - @override - String get about_spotube => 'Про Spotube'; - - @override - String get blacklist => 'Чорний список'; - - @override - String get please_sponsor => 'Будь ласка, станьте спонсором/зробіть пожертву'; - - @override - String get spotube_description => - 'Spotube, легкий, кросплатформовий, безкоштовний клієнт Spotify'; - - @override - String get version => 'Версія'; - - @override - String get build_number => 'Номер збірки'; - - @override - String get founder => 'Засновник'; - - @override - String get repository => 'Репозиторій'; - - @override - String get bug_issues => 'Помилки та проблеми'; - - @override - String get made_with => 'Зроблено з ❤️ в Бангладеш 🇧🇩'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Ліцензія'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Не хвилюйтеся, жодні ваші облікові дані не будуть зібрані або передані кому-небудь'; - - @override - String get know_how_to_login => 'Не знаєте, як це зробити?'; - - @override - String get follow_step_by_step_guide => 'Дотримуйтесь покрокової інструкції'; - - @override - String cookie_name_cookie(Object name) { - return 'Кукі-файл $name'; - } - - @override - String get fill_in_all_fields => 'Будь ласка, заповніть усі поля'; - - @override - String get submit => 'Надіслати'; - - @override - String get exit => 'Вийти'; - - @override - String get previous => 'Попередній'; - - @override - String get next => 'Наступний'; - - @override - String get done => 'Готово'; - - @override - String get step_1 => 'Крок 1'; - - @override - String get first_go_to => 'Спочатку перейдіть на'; - - @override - String get something_went_wrong => 'Щось пішло не так'; - - @override - String get piped_instance => 'Примірник сервера Piped'; - - @override - String get piped_description => - 'Примірник сервера Piped, який використовуватиметься для зіставлення треків'; - - @override - String get piped_warning => - 'Деякі з них можуть працювати неправильно. Тому використовуйте на свій страх і ризик'; - - @override - String get invidious_instance => 'Екземпляр сервера Invidious'; - - @override - String get invidious_description => - 'Екземпляр сервера Invidious для зіставлення треків'; - - @override - String get invidious_warning => - 'Деякі можуть працювати не дуже добре. Використовуйте на власний ризик'; - - @override - String get generate => 'Генерувати'; - - @override - String track_exists(Object track) { - return 'Трек $track вже існує'; - } - - @override - String get replace_downloaded_tracks => 'Замінити всі завантажені треки'; - - @override - String get skip_download_tracks => - 'Пропустити завантаження всіх завантажених треків'; - - @override - String get do_you_want_to_replace => 'Ви хочете замінити існуючий трек?'; - - @override - String get replace => 'Замінити'; - - @override - String get skip => 'Пропустити'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Виберіть до $count $type'; - } - - @override - String get select_genres => 'Виберіть жанри'; - - @override - String get add_genres => 'Додати жанри'; - - @override - String get country => 'Країна'; - - @override - String get number_of_tracks_generate => 'Кількість треків для створення'; - - @override - String get acousticness => 'Акустичність'; - - @override - String get danceability => 'Танцювальність'; - - @override - String get energy => 'Енергія'; - - @override - String get instrumentalness => 'Інструментальність'; - - @override - String get liveness => 'Живість'; - - @override - String get loudness => 'Гучність'; - - @override - String get speechiness => 'Розмовність'; - - @override - String get valence => 'Валентність'; - - @override - String get popularity => 'Популярність'; - - @override - String get key => 'Тональність'; - - @override - String get duration => 'Тривалість (с)'; - - @override - String get tempo => 'Темп (BPM)'; - - @override - String get mode => 'Режим'; - - @override - String get time_signature => 'Розмір'; - - @override - String get short => 'Короткий'; - - @override - String get medium => 'Середній'; - - @override - String get long => 'Довгий'; - - @override - String get min => 'Мін'; - - @override - String get max => 'Макс'; - - @override - String get target => 'Цільовий'; - - @override - String get moderate => 'Помірний'; - - @override - String get deselect_all => 'Зняти вибір з усіх'; - - @override - String get select_all => 'Вибрати всі'; - - @override - String get are_you_sure => 'Ви впевнені?'; - - @override - String get generating_playlist => - 'Створення вашого персонального плейлиста...'; - - @override - String selected_count_tracks(Object count) { - return 'Вибрано $count треків'; - } - - @override - String get download_warning => - 'Якщо ви завантажуєте всі треки масово, ви явно піратствуєте і завдаєте шкоди музичному творчому співтовариству. Сподіваюся, ви усвідомлюєте це. Завжди намагайтеся поважати і підтримувати важку працю артиста'; - - @override - String get download_ip_ban_warning => - 'До речі, ваш IP може бути заблокований на YouTube через надмірну кількість запитів на завантаження, ніж зазвичай. Блокування IP-адреси означає, що ви не зможете користуватися YouTube (навіть якщо ви увійшли в систему) протягом щонайменше 2-3 місяців з цього пристрою. І Spotube не несе жодної відповідальності, якщо це станеться'; - - @override - String get by_clicking_accept_terms => - 'Натискаючи \'прийняти\', ви погоджуєтеся з наступними умовами:'; - - @override - String get download_agreement_1 => 'Я знаю, що краду музику. Я поганий.'; - - @override - String get download_agreement_2 => - 'Я підтримаю автора, де тільки зможу, і роблю це лише тому, що не маю грошей, щоб купити його роботи.'; - - @override - String get download_agreement_3 => - 'Я повністю усвідомлюю, що мій IP може бути заблокований на YouTube, і я не покладаю на Spotube або його власників/контрибуторів відповідальність за будь-які нещасні випадки, спричинені моїми діями.'; - - @override - String get decline => 'Відхилити'; - - @override - String get accept => 'Прийняти'; - - @override - String get details => 'Деталі'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Канал'; - - @override - String get likes => 'Подобається'; - - @override - String get dislikes => 'Не подобається'; - - @override - String get views => 'Переглядів'; - - @override - String get streamUrl => 'Посилання на стрімінг'; - - @override - String get stop => 'Зупинити'; - - @override - String get sort_newest => 'Сортувати за датою додавання (новіші першими)'; - - @override - String get sort_oldest => 'Сортувати за датою додавання (старіші першими)'; - - @override - String get sleep_timer => 'Таймер сну'; - - @override - String mins(Object minutes) { - return '$minutes хвилин'; - } - - @override - String hours(Object hours) { - return '$hours годин'; - } - - @override - String hour(Object hours) { - return '$hours година'; - } - - @override - String get custom_hours => 'Кількість годин на замовлення'; - - @override - String get logs => 'Логи'; - - @override - String get developers => 'Розробники'; - - @override - String get not_logged_in => 'Ви не ввійшли в обліковий запис'; - - @override - String get search_mode => 'Режим пошуку'; - - @override - String get audio_source => 'Джерело аудіо'; - - @override - String get ok => 'Гаразд'; - - @override - String get failed_to_encrypt => 'Не вдалося зашифрувати'; - - @override - String get encryption_failed_warning => - 'Spotube використовує шифрування для безпечного зберігання ваших даних. Але не вдалося цього зробити. Тому він перейде до небезпечного зберігання\nЯкщо ви використовуєте Linux, переконайтеся, що у вас встановлено будь-який секретний сервіс (gnome-keyring, kde-wallet, keepassxc тощо)'; - - @override - String get querying_info => 'Запит інформації...'; - - @override - String get piped_api_down => 'API Piped не працює'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'Поточний екземпляр Piped $pipedInstance не працює\n\nЗмініть екземпляр або змініть \'Тип API\' на офіційний YouTube API\n\nОбов\'язково перезапустіть програму після зміни'; - } - - @override - String get you_are_offline => 'Ви зараз не в мережі'; - - @override - String get connection_restored => 'Ваше інтернет-з\'єднання відновлено'; - - @override - String get use_system_title_bar => 'Використовувати системний заголовок'; - - @override - String get crunching_results => 'Опрацювання результатів...'; - - @override - String get search_to_get_results => 'Почніть пошук, щоб отримати результати'; - - @override - String get use_amoled_mode => 'Режим AMOLED'; - - @override - String get pitch_dark_theme => 'Темна тема'; - - @override - String get normalize_audio => 'Нормалізувати звук'; - - @override - String get change_cover => 'Змінити обкладинку'; - - @override - String get add_cover => 'Додати обкладинку'; - - @override - String get restore_defaults => 'Відновити налаштування за замовчуванням'; - - @override - String get download_music_format => 'Формат завантаження музики'; - - @override - String get streaming_music_format => 'Формат потокової музики'; - - @override - String get download_music_quality => 'Якість завантаженої музики'; - - @override - String get streaming_music_quality => 'Якість потокової музики'; - - @override - String get login_with_lastfm => 'Увійти з Last.fm'; - - @override - String get connect => 'Підключити'; - - @override - String get disconnect_lastfm => 'Відключитися від Last.fm'; - - @override - String get disconnect => 'Відключити'; - - @override - String get username => 'Ім\'я користувача'; - - @override - String get password => 'Пароль'; - - @override - String get login => 'Увійти'; - - @override - String get login_with_your_lastfm => 'Увійти в свій обліковий запис Last.fm'; - - @override - String get scrobble_to_lastfm => 'Скробблінг на Last.fm'; - - @override - String get go_to_album => 'Перейти до альбому'; - - @override - String get discord_rich_presence => 'Багата присутність у Discord'; - - @override - String get browse_all => 'Переглянути все'; - - @override - String get genres => 'Жанри'; - - @override - String get explore_genres => 'Досліджувати жанри'; - - @override - String get friends => 'Друзі'; - - @override - String get no_lyrics_available => - 'Вибачте, не вдалося знайти текст для цього треку'; - - @override - String get start_a_radio => 'Запустити радіо'; - - @override - String get how_to_start_radio => 'Як ви хочете запустити радіо?'; - - @override - String get replace_queue_question => - 'Ви хочете замінити поточну чергу чи додати до неї?'; - - @override - String get endless_playback => 'Безкінечне відтворення'; - - @override - String get delete_playlist => 'Видалити плейлист'; - - @override - String get delete_playlist_confirmation => - 'Ви впевнені, що хочете видалити цей плейлист?'; - - @override - String get local_tracks => 'Місцеві треки'; - - @override - String get local_tab => 'Місцевий'; - - @override - String get song_link => 'Посилання на пісню'; - - @override - String get skip_this_nonsense => 'Пропустити цей бред'; - - @override - String get freedom_of_music => '“Свобода музики”'; - - @override - String get freedom_of_music_palm => '“Свобода музики у вашій долоні”'; - - @override - String get get_started => 'Давайте почнемо'; - - @override - String get youtube_source_description => - 'Рекомендовано та працює краще за все.'; - - @override - String get piped_source_description => - 'Чи почуваєте себе вільно? Те саме, що і на YouTube, але набагато безкоштовно.'; - - @override - String get jiosaavn_source_description => - 'Найкраще для регіону Південної Азії.'; - - @override - String get invidious_source_description => - 'Подібний до Piped, але з вищою доступністю.'; - - @override - String highest_quality(Object quality) { - return 'Найвища якість: $quality'; - } - - @override - String get select_audio_source => 'Виберіть джерело аудіо'; - - @override - String get endless_playback_description => - 'Автоматично додавати нові пісні\nв кінець черги'; - - @override - String get choose_your_region => 'Виберіть ваш регіон'; - - @override - String get choose_your_region_description => - 'Це допоможе Spotube показати вам правильний контент\nдля вашого місцезнаходження.'; - - @override - String get choose_your_language => 'Виберіть свою мову'; - - @override - String get help_project_grow => 'Допоможіть цьому проекту рости'; - - @override - String get help_project_grow_description => - 'Spotube - це проект з відкритим кодом. Ви можете допомогти цьому проекту зростати, вносячи свій внесок у проект, повідомляючи про помилки або пропонуючи нові функції.'; - - @override - String get contribute_on_github => 'Долучайтесь на GitHub'; - - @override - String get donate_on_open_collective => 'Пожертвуйте на Open Collective'; - - @override - String get browse_anonymously => 'Анонімно переглядати'; - - @override - String get enable_connect => 'Увімкнути підключення'; - - @override - String get enable_connect_description => 'Керуйте Spotube з інших пристроїв'; - - @override - String get devices => 'Пристрої'; - - @override - String get select => 'Вибрати'; - - @override - String connect_client_alert(Object client) { - return 'Вас керує $client'; - } - - @override - String get this_device => 'Цей пристрій'; - - @override - String get remote => 'Віддалений'; - - @override - String get stats => 'Статистика'; - - @override - String and_n_more(Object count) { - return 'і $count більше'; - } - - @override - String get recently_played => 'Нещодавно Відтворене'; - - @override - String get browse_more => 'Переглянути Більше'; - - @override - String get no_title => 'Без Назви'; - - @override - String get not_playing => 'Не Відтворюється'; - - @override - String get epic_failure => 'Епічний провал!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'Додано $tracks_length треків до черги'; - } - - @override - String get spotube_has_an_update => 'Spotube має оновлення'; - - @override - String get download_now => 'Завантажити Зараз'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum було випущено'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version було випущено'; - } - - @override - String get read_the_latest => 'Читати останні новини'; - - @override - String get release_notes => 'ноти про випуск'; - - @override - String get pick_color_scheme => 'Оберіть кольорову схему'; - - @override - String get save => 'Зберегти'; - - @override - String get choose_the_device => 'Виберіть пристрій:'; - - @override - String get multiple_device_connected => - 'Підключено кілька пристроїв.\nВиберіть пристрій, на якому ви хочете виконати цю дію'; - - @override - String get nothing_found => 'Нічого не знайдено'; - - @override - String get the_box_is_empty => 'Коробка порожня'; - - @override - String get top_artists => 'Топ Артисти'; - - @override - String get top_albums => 'Топ Альбоми'; - - @override - String get this_week => 'Цього тижня'; - - @override - String get this_month => 'Цього місяця'; - - @override - String get last_6_months => 'Останні 6 місяців'; - - @override - String get this_year => 'Цього року'; - - @override - String get last_2_years => 'Останні 2 роки'; - - @override - String get all_time => 'Усі часи'; - - @override - String powered_by_provider(Object providerName) { - return 'Забезпечено $providerName'; - } - - @override - String get email => 'Електронна пошта'; - - @override - String get profile_followers => 'Підписники'; - - @override - String get birthday => 'День народження'; - - @override - String get subscription => 'Підписка'; - - @override - String get not_born => 'Ще не народжений'; - - @override - String get hacker => 'Хакер'; - - @override - String get profile => 'Профіль'; - - @override - String get no_name => 'Без імені'; - - @override - String get edit => 'Редагувати'; - - @override - String get user_profile => 'Профіль користувача'; - - @override - String count_plays(Object count) { - return '$count відтворень'; - } - - @override - String get streaming_fees_hypothetical => - '*Розраховано на основі виплат Spotify за стримінг\nвід \$0.003 до \$0.005. Це гіпотетичний\nрозрахунок, щоб дати уявлення користувачу про те, скільки б він\nзаплатив артистам, якби слухав їхні пісні на Spotify.'; - - @override - String get minutes_listened => 'Хвилини прослуховування'; - - @override - String get streamed_songs => 'Стримлені пісні'; - - @override - String count_streams(Object count) { - return '$count стримів'; - } - - @override - String get owned_by_you => 'Ваша власність'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl скопійовано в буфер обміну'; - } - - @override - String get hipotetical_calculation => - '*Це розраховано на основі середньої виплати за стрім онлайн-платформ для потокового відтворення музики, що становить від \$0,003 до \$0,005. Це гіпотетичний розрахунок, щоб дати користувачеві уявлення про те, скільки б вони заплатили артистам, якщо б слухали їхні пісні на різних музичних стрімінгових платформах.'; - - @override - String count_mins(Object minutes) { - return '$minutes хв'; - } - - @override - String get summary_minutes => 'хвилини'; - - @override - String get summary_listened_to_music => 'Прослухана музика'; - - @override - String get summary_songs => 'пісні'; - - @override - String get summary_streamed_overall => 'Загалом стримів'; - - @override - String get summary_owed_to_artists => 'Заборгованість артистам\nцього місяця'; - - @override - String get summary_artists => 'артистів'; - - @override - String get summary_music_reached_you => 'Музика досягла вас'; - - @override - String get summary_full_albums => 'повні альбоми'; - - @override - String get summary_got_your_love => 'Отримав вашу любов'; - - @override - String get summary_playlists => 'плейлисти'; - - @override - String get summary_were_on_repeat => 'Були на повторі'; - - @override - String total_money(Object money) { - return 'Загалом $money'; - } - - @override - String get webview_not_found => 'Webview не знайдено'; - - @override - String get webview_not_found_description => - 'На вашому пристрої не встановлено виконуване середовище Webview.\nЯкщо воно встановлено, переконайтеся, що воно знаходиться в environment PATH\n\nПісля встановлення перезапустіть програму'; - - @override - String get unsupported_platform => 'Непідтримувана платформа'; - - @override - String get cache_music => 'Кешувати музику'; - - @override - String get open => 'Відкрити'; - - @override - String get cache_folder => 'Тека кешу'; - - @override - String get export => 'Експорт'; - - @override - String get clear_cache => 'Очистити кеш'; - - @override - String get clear_cache_confirmation => 'Ви хочете очистити кеш?'; - - @override - String get export_cache_files => 'Експортувати кешовані файли'; - - @override - String found_n_files(Object count) { - return 'Знайдено $count файлів'; - } - - @override - String get export_cache_confirmation => 'Ви хочете експортувати ці файли до'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return 'Експортовано $filesExported з $files файлів'; - } - - @override - String get undo => 'Скасувати'; - - @override - String get download_all => 'Завантажити все'; - - @override - String get add_all_to_playlist => 'Додати все до плейлиста'; - - @override - String get add_all_to_queue => 'Додати все в чергу'; - - @override - String get play_all_next => 'Відтворити все наступне'; - - @override - String get pause => 'Пауза'; - - @override - String get view_all => 'Переглянути все'; - - @override - String get no_tracks_added_yet => 'Здається, ви ще не додали жодної пісні'; - - @override - String get no_tracks => 'Здається, тут немає пісень'; - - @override - String get no_tracks_listened_yet => 'Здається, ви ще нічого не слухали'; - - @override - String get not_following_artists => 'Ви не підписані на жодного артиста'; - - @override - String get no_favorite_albums_yet => - 'Здається, ви ще не додали жодного альбому в улюблені'; - - @override - String get no_logs_found => 'Жодних журналів не знайдено'; - - @override - String get youtube_engine => 'YouTube Двигун'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine не встановлено'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine не встановлено на вашій системі.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Переконайтесь, що він доступний у змінній PATH або\nвстановіть абсолютний шлях до виконуваного файлу $engine нижче'; - } - - @override - String get youtube_engine_unix_issue_message => - 'У macOS/Linux/Unix-подібних ОС, встановлення шляху в .zshrc/.bashrc/.bash_profile тощо не працює.\nВам потрібно налаштувати шлях у файлі конфігурації оболонки'; - - @override - String get download => 'Завантажити'; - - @override - String get file_not_found => 'Файл не знайдено'; - - @override - String get custom => 'Користувацький'; - - @override - String get add_custom_url => 'Додати користувацький URL'; - - @override - String get edit_port => 'Редагувати порт'; - - @override - String get port_helper_msg => - 'За замовчуванням -1, що означає випадкове число. Якщо у вас налаштований брандмауер, рекомендується це налаштувати.'; - - @override - String connect_request(Object client) { - return 'Дозволити $client підключення?'; - } - - @override - String get connection_request_denied => - 'Підключення відхилено. Користувач відмовив у доступі.'; - - @override - String get an_error_occurred => 'Сталася помилка'; - - @override - String get copy_to_clipboard => 'Копіювати в буфер обміну'; - - @override - String get view_logs => 'Переглянути логи'; - - @override - String get retry => 'Повторити'; - - @override - String get no_default_metadata_provider_selected => - 'Ви не встановили провайдера метаданих за замовчуванням'; - - @override - String get manage_metadata_providers => 'Керувати провайдерами метаданих'; - - @override - String get open_link_in_browser => 'Відкрити посилання в браузері?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Ви хочете відкрити наступне посилання'; - - @override - String get unsafe_url_warning => - 'Відкриття посилань з ненадійних джерел може бути небезпечним. Будьте обережні!\nВи також можете скопіювати посилання в буфер обміну.'; - - @override - String get copy_link => 'Копіювати посилання'; - - @override - String get building_your_timeline => - 'Створення вашої часової шкали на основі ваших прослуховувань...'; - - @override - String get official => 'Офіційний'; - - @override - String author_name(Object author) { - return 'Автор: $author'; - } - - @override - String get third_party => 'Сторонній'; - - @override - String get plugin_requires_authentication => 'Плагін вимагає автентифікації'; - - @override - String get update_available => 'Доступне оновлення'; - - @override - String get supports_scrobbling => 'Підтримує скроблінг'; - - @override - String get plugin_scrobbling_info => - 'Цей плагін скроббить вашу музику, щоб створити вашу історію прослуховувань.'; - - @override - String get default_metadata_source => 'Джерело метаданих за замовчуванням'; - - @override - String get set_default_metadata_source => - 'Встановити джерело метаданих за замовчуванням'; - - @override - String get default_audio_source => 'Джерело аудіо за замовчуванням'; - - @override - String get set_default_audio_source => - 'Встановити джерело аудіо за замовчуванням'; - - @override - String get set_default => 'Встановити за замовчуванням'; - - @override - String get support => 'Підтримка'; - - @override - String get support_plugin_development => 'Підтримати розробку плагіна'; - - @override - String can_access_name_api(Object name) { - return '- Може отримати доступ до **$name** API'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Ви хочете встановити цей плагін?'; - - @override - String get third_party_plugin_warning => - 'Цей плагін із стороннього репозиторію. Будь ласка, переконайтеся, що ви довіряєте джерелу перед встановленням.'; - - @override - String get author => 'Автор'; - - @override - String get this_plugin_can_do_following => 'Цей плагін може робити наступне'; - - @override - String get install => 'Встановити'; - - @override - String get install_a_metadata_provider => 'Встановити провайдера метаданих'; - - @override - String get no_tracks_playing => 'Наразі не відтворюється жоден трек'; - - @override - String get synced_lyrics_not_available => - 'Синхронізовані тексти недоступні для цієї пісні. Будь ласка, використовуйте вкладку'; - - @override - String get plain_lyrics => 'Звичайні тексти'; - - @override - String get tab_instead => 'замість цього.'; - - @override - String get disclaimer => 'Відмова від відповідальності'; - - @override - String get third_party_plugin_dmca_notice => - 'Команда Spotube не несе жодної відповідальності (включно з юридичною) за будь-які плагіни \"третіх сторін\".\nБудь ласка, використовуйте їх на свій страх і ризик. Про будь-які помилки/проблеми повідомляйте в репозиторій плагіна.\n\nЯкщо якийсь плагін \"третьої сторони\" порушує ToS/DMCA будь-якої служби/юридичної особи, будь ласка, попросіть автора плагіна \"третьої сторони\" або хостингову платформу, наприклад, GitHub/Codeberg, вжити заходів. Усі перераховані вище (позначені як \"треті сторони\") є плагінами, які підтримуються публічно/спільнотою. Ми не куруємо їх, тому не можемо вжити жодних заходів щодо них.\n\n'; - - @override - String get input_does_not_match_format => - 'Введені дані не відповідають необхідному формату'; - - @override - String get plugins => 'Плагіни'; - - @override - String get paste_plugin_download_url => - 'Вставте URL-адресу для завантаження або URL-адресу репозиторію GitHub/Codeberg або пряме посилання на файл .smplug'; - - @override - String get download_and_install_plugin_from_url => - 'Завантажити та встановити плагін з URL-адреси'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Не вдалося додати плагін: $error'; - } - - @override - String get upload_plugin_from_file => 'Завантажити плагін з файлу'; - - @override - String get installed => 'Встановлено'; - - @override - String get available_plugins => 'Доступні плагіни'; - - @override - String get configure_plugins => - 'Налаштуйте власні плагіни метаданих і аудіоджерела'; - - @override - String get audio_scrobblers => 'Аудіо скробблери'; - - @override - String get scrobbling => 'Скроблінг'; - - @override - String get source => 'Джерело: '; - - @override - String get uncompressed => 'Без стиснення'; - - @override - String get dab_music_source_description => - 'Для аудіофілів. Забезпечує високоякісні/без втрат аудіопотоки. Точна відповідність треків на основі ISRC.'; -} diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart deleted file mode 100644 index 4d7a8945..00000000 --- a/lib/l10n/generated/app_localizations_vi.dart +++ /dev/null @@ -1,1574 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Vietnamese (`vi`). -class AppLocalizationsVi extends AppLocalizations { - AppLocalizationsVi([String locale = 'vi']) : super(locale); - - @override - String get guest => 'Khách'; - - @override - String get browse => 'Khám phá'; - - @override - String get search => 'Tìm kiếm'; - - @override - String get library => 'Thư viên'; - - @override - String get lyrics => 'Lời bài hát'; - - @override - String get settings => 'Cài đặt'; - - @override - String get genre_categories_filter => 'Lọc theo thể loại nhạc...'; - - @override - String get genre => 'Thể loại nhạc'; - - @override - String get personalized => 'Cá nhân hóa'; - - @override - String get featured => 'Nổi bật'; - - @override - String get new_releases => 'Bản phát hành mới'; - - @override - String get songs => 'Bài hát'; - - @override - String playing_track(Object track) { - return 'Đang phát $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return 'Điều này sẽ xóa hàng đợi hiện tại. $track_length bài hát sẽ bị xóa\nBạn có muốn tiếp tục không?'; - } - - @override - String get load_more => 'Tải thêm'; - - @override - String get playlists => 'Danh sách phát'; - - @override - String get artists => 'Nghệ sĩ'; - - @override - String get albums => 'Album'; - - @override - String get tracks => 'Bài hát'; - - @override - String get downloads => 'Tải về'; - - @override - String get filter_playlists => 'Lọc danh sách phát...'; - - @override - String get liked_tracks => 'Bài hát được thích'; - - @override - String get liked_tracks_description => 'Tất cả bài hát bạn đã thích'; - - @override - String get playlist => 'Danh sách phát'; - - @override - String get create_a_playlist => 'Tạo danh sách phát'; - - @override - String get update_playlist => 'Cập nhật danh sách phát'; - - @override - String get create => 'Tạo'; - - @override - String get cancel => 'Hủy'; - - @override - String get update => 'Cập nhật'; - - @override - String get playlist_name => 'Tên danh sách phát'; - - @override - String get name_of_playlist => 'Tên của danh sách phát'; - - @override - String get description => 'Mô tả'; - - @override - String get public => 'Công khai'; - - @override - String get collaborative => 'Hợp tác'; - - @override - String get search_local_tracks => 'Tìm kiếm bài hát trong máy...'; - - @override - String get play => 'Phát'; - - @override - String get delete => 'Xóa'; - - @override - String get none => 'Không có'; - - @override - String get sort_a_z => 'Sắp xếp theo A-Z'; - - @override - String get sort_z_a => 'Sắp xếp theo Z-A'; - - @override - String get sort_artist => 'Sắp xếp theo Nghệ sĩ'; - - @override - String get sort_album => 'Sắp xếp theo Album'; - - @override - String get sort_duration => 'Sắp xếp theo Thời lượng'; - - @override - String get sort_tracks => 'Sắp xếp các bài hát'; - - @override - String currently_downloading(Object tracks_length) { - return 'Đang tải về ($tracks_length bài hát)'; - } - - @override - String get cancel_all => 'Hủy tất cả'; - - @override - String get filter_artist => 'Lọc nghệ sĩ...'; - - @override - String followers(Object followers) { - return '$followers Người theo dõi'; - } - - @override - String get add_artist_to_blacklist => 'Thêm nghệ sĩ vào blacklist'; - - @override - String get top_tracks => 'Bài hát nổi bật'; - - @override - String get fans_also_like => 'Người hâm mộ cũng thích'; - - @override - String get loading => 'Đang tải...'; - - @override - String get artist => 'Nghệ sĩ'; - - @override - String get blacklisted => 'Đã đưa vào blacklist'; - - @override - String get following => 'Đang theo dõi'; - - @override - String get follow => 'Theo dõi'; - - @override - String get artist_url_copied => 'Đã sao chép URL nghệ sĩ'; - - @override - String added_to_queue(Object tracks) { - return 'Đã thêm $tracks bài hát vào hàng đợi'; - } - - @override - String get filter_albums => 'Lọc album...'; - - @override - String get synced => 'Đồng bộ'; - - @override - String get plain => 'Bình thường'; - - @override - String get shuffle => 'Trộn'; - - @override - String get search_tracks => 'Tìm kiếm bài hát...'; - - @override - String get released => 'Phát hành'; - - @override - String error(Object error) { - return 'Lỗi $error'; - } - - @override - String get title => 'Đề mục'; - - @override - String get time => 'Thời gian'; - - @override - String get more_actions => 'Thao tác khác'; - - @override - String download_count(Object count) { - return 'Tải xuống ($count)'; - } - - @override - String add_count_to_playlist(Object count) { - return 'Thêm ($count) vào danh sách phát'; - } - - @override - String add_count_to_queue(Object count) { - return 'Thêm ($count) vào hàng đợi'; - } - - @override - String play_count_next(Object count) { - return 'Phát ($count) tiếp theo'; - } - - @override - String get album => 'Album'; - - @override - String copied_to_clipboard(Object data) { - return 'Đã sao chép $data vào clipboard'; - } - - @override - String add_to_following_playlists(Object track) { - return 'Thêm $track vào danh sách phát đang theo dõi'; - } - - @override - String get add => 'Thêm'; - - @override - String added_track_to_queue(Object track) { - return 'Đã thêm $track vào hàng đợi'; - } - - @override - String get add_to_queue => 'Thêm vào hàng đợi'; - - @override - String track_will_play_next(Object track) { - return '$track sẽ được phát tiếp theo'; - } - - @override - String get play_next => 'Phát tiếp theo'; - - @override - String removed_track_from_queue(Object track) { - return 'Đã xóa $track khỏi hàng đợi'; - } - - @override - String get remove_from_queue => 'Xóa khỏi hàng đợi'; - - @override - String get remove_from_favorites => 'Xóa khỏi bài hát yêu thích'; - - @override - String get save_as_favorite => 'Thêm vào bài hát yêu thích'; - - @override - String get add_to_playlist => 'Thêm vào danh sách phát'; - - @override - String get remove_from_playlist => 'Xóa khỏi danh sách phát'; - - @override - String get add_to_blacklist => 'Thêm vào blacklist'; - - @override - String get remove_from_blacklist => 'Xóa khỏi blacklist'; - - @override - String get share => 'Chia sẻ'; - - @override - String get mini_player => 'Trình phát thu nhỏ'; - - @override - String get slide_to_seek => 'Trượt để tìm kiếm tiến hoặc lùi'; - - @override - String get shuffle_playlist => 'Xáo trộn bài hát'; - - @override - String get unshuffle_playlist => 'Hủy xáo trộn bài hát'; - - @override - String get previous_track => 'Bài hát trước'; - - @override - String get next_track => 'Bài hát tiếp theo'; - - @override - String get pause_playback => 'Tạm dừng phát'; - - @override - String get resume_playback => 'Tiếp tục phát'; - - @override - String get loop_track => 'Lặp lại bài hát'; - - @override - String get no_loop => 'Không lặp lại'; - - @override - String get repeat_playlist => 'Lặp lại danh sách phát'; - - @override - String get queue => 'Hàng đợi'; - - @override - String get alternative_track_sources => 'Đổi nguồn bài hát'; - - @override - String get download_track => 'Tải xuống'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks bài hát trong hàng đợi'; - } - - @override - String get clear_all => 'Xóa tất cả'; - - @override - String get show_hide_ui_on_hover => - 'Hiển thị/Ẩn giao diện người dùng khi di chuột qua'; - - @override - String get always_on_top => 'Luôn ở trên cùng'; - - @override - String get exit_mini_player => 'Thoát khỏi trình phát thu nhỏ'; - - @override - String get download_location => 'Vị trí tải xuống'; - - @override - String get local_library => 'Thư viện địa phương'; - - @override - String get add_library_location => 'Thêm vào thư viện'; - - @override - String get remove_library_location => 'Xóa khỏi thư viện'; - - @override - String get account => 'Tài khoản'; - - @override - String get logout => 'Đăng xuất'; - - @override - String get logout_of_this_account => 'Đăng xuất khỏi tài khoản này'; - - @override - String get language_region => 'Ngôn ngữ và Khu vực'; - - @override - String get language => 'Ngôn ngữ'; - - @override - String get system_default => 'Mặc định hệ thống'; - - @override - String get market_place_region => 'Khu vực Marketplace'; - - @override - String get recommendation_country => 'Quốc gia gợi ý'; - - @override - String get appearance => 'Giao diện'; - - @override - String get layout_mode => 'Chế độ layout'; - - @override - String get override_layout_settings => 'Ghi đè cài đặt layout'; - - @override - String get adaptive => 'Tương thích'; - - @override - String get compact => 'Nhỏ gọn'; - - @override - String get extended => 'Mở rộng'; - - @override - String get theme => 'Chủ đề'; - - @override - String get dark => 'Tối'; - - @override - String get light => 'Sáng'; - - @override - String get system => 'Hệ thống'; - - @override - String get accent_color => 'Màu nhấn'; - - @override - String get sync_album_color => 'Đồng bộ màu album'; - - @override - String get sync_album_color_description => - 'Sử dụng màu chủ đạo của hình ảnh album làm màu nhấn'; - - @override - String get playback => 'Phát'; - - @override - String get audio_quality => 'Chất lượng âm thanh'; - - @override - String get high => 'Cao'; - - @override - String get low => 'Thấp'; - - @override - String get pre_download_play => 'Tải xuống và phát'; - - @override - String get pre_download_play_description => - 'Thay vì stream âm thanh, tải xuống trước và phát (Khuyến nghị cho người dùng có băng thông cao)'; - - @override - String get skip_non_music => 'Bỏ qua các đoạn không phải nhạc (SponsorBlock)'; - - @override - String get blacklist_description => 'Các bài hát và nghệ sĩ trong blacklist'; - - @override - String get wait_for_download_to_finish => - 'Vui lòng đợi quá trình tải xuống hiện tại hoàn thành'; - - @override - String get desktop => 'Máy tính'; - - @override - String get close_behavior => 'Thao tác đóng'; - - @override - String get close => 'Đóng'; - - @override - String get minimize_to_tray => 'Thu nhỏ vào khay hệ thống'; - - @override - String get show_tray_icon => 'Hiển thị biểu tượng trên khay hệ thống'; - - @override - String get about => 'Về chúng tôi'; - - @override - String get u_love_spotube => 'Chúng tôi biết bạn yêu Spotube'; - - @override - String get check_for_updates => 'Kiểm tra cập nhật'; - - @override - String get about_spotube => 'Về Spotube'; - - @override - String get blacklist => 'blacklist'; - - @override - String get please_sponsor => 'Vui lòng tài trợ/ủng hộ'; - - @override - String get spotube_description => - 'Spotube, một ứng dụng Spotify nhẹ, đa nền tảng và miễn phí'; - - @override - String get version => 'Phiên bản'; - - @override - String get build_number => 'Số phiên bản'; - - @override - String get founder => 'Người sáng lập'; - - @override - String get repository => 'Mã nguồn'; - - @override - String get bug_issues => 'Báo cáo lỗi'; - - @override - String get made_with => 'Được làm bằng ❤️ ở Băng-la-đét'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => 'Giấy phép'; - - @override - String get credentials_will_not_be_shared_disclaimer => - 'Đừng lo, thông tin đăng nhập của bạn sẽ không được thu thập hoặc chia sẻ với bất kỳ ai'; - - @override - String get know_how_to_login => 'Không biết cách lấy thông tin đăng nhập?'; - - @override - String get follow_step_by_step_guide => 'Các bước lấy thông tin đăng nhập'; - - @override - String cookie_name_cookie(Object name) { - return 'Cookie $name'; - } - - @override - String get fill_in_all_fields => 'Vui lòng điền đầy đủ thông tin'; - - @override - String get submit => 'Gửi'; - - @override - String get exit => 'Thoát'; - - @override - String get previous => 'Trước'; - - @override - String get next => 'Tiếp'; - - @override - String get done => 'Hoàn tất'; - - @override - String get step_1 => 'Bước 1'; - - @override - String get first_go_to => 'Đầu tiên, truy cập'; - - @override - String get something_went_wrong => 'Đã xảy ra lỗi'; - - @override - String get piped_instance => 'Phiên bản Server Piped'; - - @override - String get piped_description => - 'Phiên bản Piped để sử dụng cho Track matching'; - - @override - String get piped_warning => - 'Một số phiên bản Piped có thể không hoạt động tốt'; - - @override - String get invidious_instance => 'Phiên bản máy chủ Invidious'; - - @override - String get invidious_description => - 'Phiên bản máy chủ Invidious để sử dụng để so khớp bản nhạc'; - - @override - String get invidious_warning => - 'Một số có thể sẽ không hoạt động tốt. Vì vậy hãy sử dụng với rủi ro của riêng bạn'; - - @override - String get generate => 'Tạo'; - - @override - String track_exists(Object track) { - return 'Bài hát $track đã tồn tại'; - } - - @override - String get replace_downloaded_tracks => 'Thay thế tất cả các bài hát đã tải'; - - @override - String get skip_download_tracks => - 'Bỏ qua tải xuống tất cả các bài hát đã tải'; - - @override - String get do_you_want_to_replace => - 'Bạn có muốn thay thế bài hát hiện có không?'; - - @override - String get replace => 'Thay thế'; - - @override - String get skip => 'Bỏ qua'; - - @override - String select_up_to_count_type(Object count, Object type) { - return 'Chọn tối đa $count $type'; - } - - @override - String get select_genres => 'Chọn Thể loại'; - - @override - String get add_genres => 'Thêm Thể loại'; - - @override - String get country => 'Quốc gia'; - - @override - String get number_of_tracks_generate => 'Số lượng bài hát để tạo'; - - @override - String get acousticness => 'Độ âm thanh'; - - @override - String get danceability => 'Khả năng nhảy'; - - @override - String get energy => 'Năng lượng'; - - @override - String get instrumentalness => 'Độ nhạc cụ'; - - @override - String get liveness => 'Sống động'; - - @override - String get loudness => 'Độ ồn'; - - @override - String get speechiness => 'Độ nói'; - - @override - String get valence => 'Tính tích cực'; - - @override - String get popularity => 'Độ phổ biến'; - - @override - String get key => 'Tông'; - - @override - String get duration => 'Thời lượng (giây)'; - - @override - String get tempo => 'Nhịp độ (BPM)'; - - @override - String get mode => 'Chế độ'; - - @override - String get time_signature => 'Chữ ký thời gian'; - - @override - String get short => 'Ngắn'; - - @override - String get medium => 'Trung bình'; - - @override - String get long => 'Dài'; - - @override - String get min => 'Tối thiểu'; - - @override - String get max => 'Tối đa'; - - @override - String get target => 'Mục tiêu'; - - @override - String get moderate => 'Trung bình'; - - @override - String get deselect_all => 'Bỏ chọn tất cả'; - - @override - String get select_all => 'Chọn tất cả'; - - @override - String get are_you_sure => 'Bạn có chắc chắn?'; - - @override - String get generating_playlist => - 'Đang tạo danh sách phát tùy chỉnh của bạn...'; - - @override - String selected_count_tracks(Object count) { - return 'Đã chọn $count bài hát'; - } - - @override - String get download_warning => - 'Tải xuống tất cả các bài hát một lần, sẽ vi phạm bản quyền âm nhạc và gây thiệt hại cho xã hội sáng tạo âm nhạc. Hy vọng bạn nhận thức được điều này. Hãy luôn tôn trọng và ủng hộ công sức của nghệ sĩ'; - - @override - String get download_ip_ban_warning => - 'Địa chỉ IP của bạn có thể bị chặn trên YouTube do yêu cầu tải xuống quá mức so với bình thường. Chặn IP có nghĩa là bạn không thể sử dụng YouTube (ngay cả khi bạn đã đăng nhập) ít nhất 2-3 tháng từ thiết bị IP đó. Và Spotube không chịu trách nhiệm nếu điều này xảy ra'; - - @override - String get by_clicking_accept_terms => - 'Bằng cách nhấp vào \'Chấp nhận\', bạn đồng ý với các điều khoản sau:'; - - @override - String get download_agreement_1 => - 'Tôi biết mình đang vi phạm bản quyền âm nhạc. Đó là không tốt.'; - - @override - String get download_agreement_2 => - 'Tôi sẽ ủng hộ nghệ sĩ bất cứ nơi nào tôi có thể và tôi chỉ làm điều này vì tôi không có tiền để mua tác phẩm của họ'; - - @override - String get download_agreement_3 => - 'Tôi hoàn toàn nhận thức được rằng địa chỉ IP của tôi có thể bị chặn trên YouTube và tôi không đổ lỗi cho Spotube hoặc chủ sở hữu/người đóng góp của nó về bất kỳ tai nạn nào do hành động này của tôi'; - - @override - String get decline => 'Từ chối'; - - @override - String get accept => 'Chấp nhận'; - - @override - String get details => 'Chi tiết'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => 'Kênh'; - - @override - String get likes => 'Thích'; - - @override - String get dislikes => 'Không thích'; - - @override - String get views => 'Lượt xem'; - - @override - String get streamUrl => 'URL phát trực tiếp'; - - @override - String get stop => 'Dừng'; - - @override - String get sort_newest => 'Sắp xếp theo mới nhất'; - - @override - String get sort_oldest => 'Sắp xếp theo cũ nhất'; - - @override - String get sleep_timer => 'Hẹn giờ tắt'; - - @override - String mins(Object minutes) { - return '$minutes Phút'; - } - - @override - String hours(Object hours) { - return '$hours Giờ'; - } - - @override - String hour(Object hours) { - return '$hours Giờ'; - } - - @override - String get custom_hours => 'Giờ Tùy chỉnh'; - - @override - String get logs => 'Nhật ký'; - - @override - String get developers => 'Nhà phát triển'; - - @override - String get not_logged_in => 'Bạn chưa đăng nhập'; - - @override - String get search_mode => 'Chế độ tìm kiếm'; - - @override - String get audio_source => 'Nguồn âm thanh'; - - @override - String get ok => 'Ok'; - - @override - String get failed_to_encrypt => 'Mã hóa không thành công'; - - @override - String get encryption_failed_warning => - 'Spotube không thành công trong việc mã hóa nhằm lưu trữ dữ liêu an toàn. vậy nên sẽ chuyển về lưu trữ không an toàn\nNếu bạn đang sử dụng Linux, đảm bảo rằng bạn có sử dụng dịch vụ bảo mật (gnome-keyring, kde-wallet, keepassxc, v.v.)'; - - @override - String get querying_info => 'Đang truy vấn thông tin...'; - - @override - String get piped_api_down => 'API Piped đang gặp sự cố'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return 'Phiên bản Piped $pipedInstance hiện đang gặp sự cố\n\nThay đổi phiên bản hoặc thay đổi \'Loại API\' thành API YouTube official\n\nKhởi động lai ứng dụng sau khi thay đổi.'; - } - - @override - String get you_are_offline => 'Bạn đang ngoại tuyến'; - - @override - String get connection_restored => - 'Kết nối internet của bạn đã được khôi phục'; - - @override - String get use_system_title_bar => 'Sử dụng thanh tiêu đề hệ thống'; - - @override - String get crunching_results => 'Đang tìm kiếm...'; - - @override - String get search_to_get_results => 'Chưa tìm kiếm'; - - @override - String get use_amoled_mode => 'Chủ đề tối hoàn toàn'; - - @override - String get pitch_dark_theme => 'Chế độ AMOLED'; - - @override - String get normalize_audio => 'Bình thường hóa âm thanh'; - - @override - String get change_cover => 'Thay đổi ảnh bìa'; - - @override - String get add_cover => 'Thêm ảnh bìa'; - - @override - String get restore_defaults => 'Khôi phục mặc định'; - - @override - String get download_music_format => 'Định dạng nhạc tải về'; - - @override - String get streaming_music_format => 'Định dạng nhạc phát trực tuyến'; - - @override - String get download_music_quality => 'Chất lượng nhạc tải về'; - - @override - String get streaming_music_quality => 'Chất lượng nhạc phát trực tuyến'; - - @override - String get login_with_lastfm => 'Đăng nhập bằng tài khoản Last.fm'; - - @override - String get connect => 'Liên kết'; - - @override - String get disconnect_lastfm => 'Dừng liên kết Last.fm'; - - @override - String get disconnect => 'Ngắt kết nối'; - - @override - String get username => 'Tên người dùng'; - - @override - String get password => 'Mật khẩu'; - - @override - String get login => 'Đăng nhập'; - - @override - String get login_with_your_lastfm => - 'Đăng nhập bằng tài khoản Last.fm của bạn'; - - @override - String get scrobble_to_lastfm => 'Scrobble đến Last.fm'; - - @override - String get go_to_album => 'Đi đến Album'; - - @override - String get discord_rich_presence => 'Hiển thị trạng thái Discord'; - - @override - String get browse_all => 'Duyệt tất cả'; - - @override - String get genres => 'Thể loại'; - - @override - String get explore_genres => 'Khám phá Thể loại'; - - @override - String get friends => 'Bạn bè'; - - @override - String get no_lyrics_available => - 'Xin lỗi, không tìm thấy lời cho bài hát này'; - - @override - String get start_a_radio => 'Bắt đầu Một Đài phát thanh'; - - @override - String get how_to_start_radio => - 'Bạn muốn bắt đầu đài phát thanh như thế nào?'; - - @override - String get replace_queue_question => - 'Bạn muốn thay thế hàng đợi hiện tại hay thêm vào?'; - - @override - String get endless_playback => 'Phát không giới hạn'; - - @override - String get delete_playlist => 'Xóa Danh sách phát'; - - @override - String get delete_playlist_confirmation => - 'Bạn có chắc chắn muốn xóa danh sách phát này không?'; - - @override - String get local_tracks => 'Bài hát Địa phương'; - - @override - String get local_tab => 'Địa phương'; - - @override - String get song_link => 'Liên kết Bài hát'; - - @override - String get skip_this_nonsense => 'Bỏ qua bớt rối này'; - - @override - String get freedom_of_music => '“Sự Tự do của Âm nhạc”'; - - @override - String get freedom_of_music_palm => - '“Sự Tự do của Âm nhạc trong lòng bàn tay của bạn”'; - - @override - String get get_started => 'Bắt đầu thôi'; - - @override - String get youtube_source_description => - 'Được đề xuất và hoạt động tốt nhất.'; - - @override - String get piped_source_description => - 'Cảm thấy tự do? Giống như YouTube nhưng miễn phí hơn rất nhiều.'; - - @override - String get jiosaavn_source_description => 'Tốt nhất cho khu vực Nam Á.'; - - @override - String get invidious_source_description => - 'Tương tự như Piped nhưng có tính khả dụng cao hơn.'; - - @override - String highest_quality(Object quality) { - return 'Chất lượng Tốt nhất: $quality'; - } - - @override - String get select_audio_source => 'Chọn Nguồn Âm thanh'; - - @override - String get endless_playback_description => - 'Tự động thêm các bài hát mới\nvào cuối hàng đợi'; - - @override - String get choose_your_region => 'Chọn khu vực của bạn'; - - @override - String get choose_your_region_description => - 'Điều này sẽ giúp Spotube hiển thị nội dung phù hợp cho vị trí của bạn.'; - - @override - String get choose_your_language => 'Chọn ngôn ngữ của bạn'; - - @override - String get help_project_grow => 'Hãy giúp dự án này phát triển'; - - @override - String get help_project_grow_description => - 'Spotube là một dự án mã nguồn mở. Bạn có thể giúp dự án này phát triển bằng cách đóng góp vào dự án, báo cáo lỗi hoặc đề xuất tính năng mới.'; - - @override - String get contribute_on_github => 'Đóng góp trên GitHub'; - - @override - String get donate_on_open_collective => 'Quyên góp trên Open Collective'; - - @override - String get browse_anonymously => 'Duyệt Anonymously'; - - @override - String get enable_connect => 'Kích hoạt kết nối'; - - @override - String get enable_connect_description => - 'Điều khiển Spotube từ các thiết bị khác'; - - @override - String get devices => 'Thiết bị'; - - @override - String get select => 'Chọn'; - - @override - String connect_client_alert(Object client) { - return 'Bạn đang được điều khiển bởi $client'; - } - - @override - String get this_device => 'Thiết bị này'; - - @override - String get remote => 'Từ xa'; - - @override - String get stats => 'Thống kê'; - - @override - String and_n_more(Object count) { - return 'và $count cái khác'; - } - - @override - String get recently_played => 'Gần đây đã phát'; - - @override - String get browse_more => 'Xem thêm'; - - @override - String get no_title => 'Không có tiêu đề'; - - @override - String get not_playing => 'Không phát'; - - @override - String get epic_failure => 'Thất bại hoàn toàn!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return 'Đã thêm $tracks_length bài hát vào danh sách phát'; - } - - @override - String get spotube_has_an_update => 'Spotube có bản cập nhật'; - - @override - String get download_now => 'Tải về ngay'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum đã được phát hành'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version đã được phát hành'; - } - - @override - String get read_the_latest => 'Đọc tin mới nhất'; - - @override - String get release_notes => 'ghi chú phát hành'; - - @override - String get pick_color_scheme => 'Chọn chủ đề màu sắc'; - - @override - String get save => 'Lưu'; - - @override - String get choose_the_device => 'Chọn thiết bị:'; - - @override - String get multiple_device_connected => - 'Có nhiều thiết bị kết nối.\nChọn thiết bị mà bạn muốn thực hiện hành động này'; - - @override - String get nothing_found => 'Không tìm thấy gì'; - - @override - String get the_box_is_empty => 'Hộp trống'; - - @override - String get top_artists => 'Những Nghệ Sĩ Hàng Đầu'; - - @override - String get top_albums => 'Những Album Hàng Đầu'; - - @override - String get this_week => 'Tuần này'; - - @override - String get this_month => 'Tháng này'; - - @override - String get last_6_months => '6 tháng qua'; - - @override - String get this_year => 'Năm nay'; - - @override - String get last_2_years => '2 năm qua'; - - @override - String get all_time => 'Mọi thời đại'; - - @override - String powered_by_provider(Object providerName) { - return 'Cung cấp bởi $providerName'; - } - - @override - String get email => 'Email'; - - @override - String get profile_followers => 'Người theo dõi'; - - @override - String get birthday => 'Ngày sinh'; - - @override - String get subscription => 'Gói cước'; - - @override - String get not_born => 'Chưa sinh'; - - @override - String get hacker => 'Tin tặc'; - - @override - String get profile => 'Hồ sơ'; - - @override - String get no_name => 'Không có tên'; - - @override - String get edit => 'Chỉnh sửa'; - - @override - String get user_profile => 'Hồ sơ người dùng'; - - @override - String count_plays(Object count) { - return '$count lần phát'; - } - - @override - String get streaming_fees_hypothetical => - '*Tính toán dựa trên thanh toán của Spotify cho mỗi lần phát\ntừ \$0.003 đến \$0.005. Đây là một tính toán giả định để\ngive người dùng cái nhìn về số tiền họ sẽ chi trả cho các nghệ sĩ nếu họ nghe\nbài hát của họ trên Spotify.'; - - @override - String get minutes_listened => 'Thời gian nghe'; - - @override - String get streamed_songs => 'Bài hát đã phát'; - - @override - String count_streams(Object count) { - return '$count lượt phát'; - } - - @override - String get owned_by_you => 'Thuộc sở hữu của bạn'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl đã sao chép vào bảng tạm'; - } - - @override - String get hipotetical_calculation => - '*Điều này được tính toán dựa trên khoản thanh toán trung bình mỗi luồng của nền tảng phát nhạc trực tuyến là \$0,003 đến \$0,005. Đây là một phép tính giả định để cung cấp cho người dùng cái nhìn sâu sắc về số tiền họ đã trả cho các nghệ sĩ nếu họ nghe bài hát của họ trên các nền tảng phát nhạc trực tuyến khác nhau.'; - - @override - String count_mins(Object minutes) { - return '$minutes phút'; - } - - @override - String get summary_minutes => 'phút'; - - @override - String get summary_listened_to_music => 'Đã nghe nhạc'; - - @override - String get summary_songs => 'bài hát'; - - @override - String get summary_streamed_overall => 'Stream tổng cộng'; - - @override - String get summary_owed_to_artists => 'Nợ nghệ sĩ\ntrong tháng này'; - - @override - String get summary_artists => 'nghệ sĩ'; - - @override - String get summary_music_reached_you => 'Âm nhạc đã đến với bạn'; - - @override - String get summary_full_albums => 'album đầy đủ'; - - @override - String get summary_got_your_love => 'Nhận được tình yêu của bạn'; - - @override - String get summary_playlists => 'danh sách phát'; - - @override - String get summary_were_on_repeat => 'Đã được phát lại'; - - @override - String total_money(Object money) { - return 'Tổng cộng $money'; - } - - @override - String get webview_not_found => 'Không tìm thấy Webview'; - - @override - String get webview_not_found_description => - 'Không có runtime Webview nào được cài đặt trên thiết bị của bạn.\nNếu đã cài đặt, hãy đảm bảo rằng nó nằm trong environment PATH\n\nSau khi cài đặt, hãy khởi động lại ứng dụng'; - - @override - String get unsupported_platform => 'Nền tảng không được hỗ trợ'; - - @override - String get cache_music => 'Lưu nhạc vào bộ nhớ đệm'; - - @override - String get open => 'Mở'; - - @override - String get cache_folder => 'Thư mục bộ nhớ đệm'; - - @override - String get export => 'Xuất'; - - @override - String get clear_cache => 'Xóa bộ nhớ đệm'; - - @override - String get clear_cache_confirmation => 'Bạn có muốn xóa bộ nhớ đệm không?'; - - @override - String get export_cache_files => 'Xuất các tệp được lưu trong bộ nhớ đệm'; - - @override - String found_n_files(Object count) { - return 'Tìm thấy $count tệp'; - } - - @override - String get export_cache_confirmation => 'Bạn có muốn xuất các tệp này đến'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return 'Đã xuất $filesExported trên $files tệp'; - } - - @override - String get undo => 'Hoàn tác'; - - @override - String get download_all => 'Tải xuống tất cả'; - - @override - String get add_all_to_playlist => 'Thêm tất cả vào danh sách phát'; - - @override - String get add_all_to_queue => 'Thêm tất cả vào danh sách chờ'; - - @override - String get play_all_next => 'Chơi tất cả tiếp theo'; - - @override - String get pause => 'Tạm dừng'; - - @override - String get view_all => 'Xem tất cả'; - - @override - String get no_tracks_added_yet => 'Có vẻ bạn chưa thêm bất kỳ bài hát nào'; - - @override - String get no_tracks => 'Có vẻ không có bài hát nào ở đây'; - - @override - String get no_tracks_listened_yet => 'Có vẻ bạn chưa nghe gì cả'; - - @override - String get not_following_artists => - 'Bạn không đang theo dõi bất kỳ nghệ sĩ nào'; - - @override - String get no_favorite_albums_yet => - 'Có vẻ bạn chưa thêm album nào vào danh sách yêu thích'; - - @override - String get no_logs_found => 'Không tìm thấy nhật ký'; - - @override - String get youtube_engine => 'Công cụ YouTube'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine chưa được cài đặt'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine chưa được cài đặt trong hệ thống của bạn.'; - } - - @override - String youtube_engine_set_path(Object engine) { - return 'Đảm bảo nó có sẵn trong biến PATH hoặc\nđặt đường dẫn tuyệt đối đến tệp thực thi $engine dưới đây'; - } - - @override - String get youtube_engine_unix_issue_message => - 'Trên macOS/Linux/Unix, việc thiết lập đường dẫn trong .zshrc/.bashrc/.bash_profile v.v. sẽ không hoạt động.\nBạn cần thiết lập đường dẫn trong tệp cấu hình shell'; - - @override - String get download => 'Tải xuống'; - - @override - String get file_not_found => 'Không tìm thấy tệp'; - - @override - String get custom => 'Tùy chỉnh'; - - @override - String get add_custom_url => 'Thêm URL tùy chỉnh'; - - @override - String get edit_port => 'Chỉnh sửa cổng'; - - @override - String get port_helper_msg => - 'Mặc định là -1, có nghĩa là số ngẫu nhiên. Nếu bạn đã cấu hình tường lửa, nên đặt điều này.'; - - @override - String connect_request(Object client) { - return 'Cho phép $client kết nối?'; - } - - @override - String get connection_request_denied => - 'Kết nối bị từ chối. Người dùng đã từ chối quyền truy cập.'; - - @override - String get an_error_occurred => 'Đã xảy ra lỗi'; - - @override - String get copy_to_clipboard => 'Sao chép vào khay nhớ tạm'; - - @override - String get view_logs => 'Xem nhật ký'; - - @override - String get retry => 'Thử lại'; - - @override - String get no_default_metadata_provider_selected => - 'Bạn chưa đặt nhà cung cấp siêu dữ liệu mặc định nào'; - - @override - String get manage_metadata_providers => 'Quản lý nhà cung cấp siêu dữ liệu'; - - @override - String get open_link_in_browser => 'Mở liên kết trong Trình duyệt?'; - - @override - String get do_you_want_to_open_the_following_link => - 'Bạn có muốn mở liên kết sau không'; - - @override - String get unsafe_url_warning => - 'Việc mở các liên kết từ các nguồn không đáng tin cậy có thể không an toàn. Hãy thận trọng!\nBạn cũng có thể sao chép liên kết vào khay nhớ tạm của mình.'; - - @override - String get copy_link => 'Sao chép liên kết'; - - @override - String get building_your_timeline => - 'Đang xây dựng dòng thời gian của bạn dựa trên những gì bạn đã nghe...'; - - @override - String get official => 'Chính thức'; - - @override - String author_name(Object author) { - return 'Tác giả: $author'; - } - - @override - String get third_party => 'Bên thứ ba'; - - @override - String get plugin_requires_authentication => 'Plugin yêu cầu xác thực'; - - @override - String get update_available => 'Có bản cập nhật'; - - @override - String get supports_scrobbling => 'Hỗ trợ scrobbling'; - - @override - String get plugin_scrobbling_info => - 'Plugin này scrobble nhạc của bạn để tạo lịch sử nghe của bạn.'; - - @override - String get default_metadata_source => 'Nguồn siêu dữ liệu mặc định'; - - @override - String get set_default_metadata_source => 'Đặt nguồn siêu dữ liệu mặc định'; - - @override - String get default_audio_source => 'Nguồn âm thanh mặc định'; - - @override - String get set_default_audio_source => 'Đặt nguồn âm thanh mặc định'; - - @override - String get set_default => 'Đặt làm mặc định'; - - @override - String get support => 'Hỗ trợ'; - - @override - String get support_plugin_development => 'Hỗ trợ phát triển plugin'; - - @override - String can_access_name_api(Object name) { - return '- Có thể truy cập API **$name**'; - } - - @override - String get do_you_want_to_install_this_plugin => - 'Bạn có muốn cài đặt plugin này không?'; - - @override - String get third_party_plugin_warning => - 'Plugin này đến từ một kho lưu trữ của bên thứ ba. Vui lòng đảm bảo rằng bạn tin tưởng nguồn trước khi cài đặt.'; - - @override - String get author => 'Tác giả'; - - @override - String get this_plugin_can_do_following => - 'Plugin này có thể làm những việc sau'; - - @override - String get install => 'Cài đặt'; - - @override - String get install_a_metadata_provider => - 'Cài đặt một Nhà cung cấp siêu dữ liệu'; - - @override - String get no_tracks_playing => 'Hiện không có bản nhạc nào đang phát'; - - @override - String get synced_lyrics_not_available => - 'Lời bài hát được đồng bộ hóa không có sẵn cho bài hát này. Vui lòng sử dụng'; - - @override - String get plain_lyrics => 'Lời bài hát thuần túy'; - - @override - String get tab_instead => 'thay thế.'; - - @override - String get disclaimer => 'Miễn trừ trách nhiệm'; - - @override - String get third_party_plugin_dmca_notice => - 'Nhóm Spotube không chịu bất kỳ trách nhiệm nào (bao gồm cả pháp lý) đối với bất kỳ plugin \"Bên thứ ba\" nào.\nVui lòng sử dụng chúng với rủi ro của riêng bạn. Đối với bất kỳ lỗi/vấn đề nào, vui lòng báo cáo chúng cho kho lưu trữ plugin.\n\nNếu bất kỳ plugin \"Bên thứ ba\" nào vi phạm ToS/DMCA của bất kỳ dịch vụ/thực thể pháp lý nào, vui lòng yêu cầu tác giả plugin \"Bên thứ ba\" hoặc nền tảng lưu trữ, ví dụ: GitHub/Codeberg, thực hiện hành động. Tất cả các plugin được liệt kê ở trên (được gắn nhãn \"Bên thứ ba\") đều là các plugin công cộng/do cộng đồng duy trì. Chúng tôi không quản lý chúng, vì vậy chúng tôi không thể thực hiện bất kỳ hành động nào đối với chúng.\n\n'; - - @override - String get input_does_not_match_format => - 'Đầu vào không khớp với định dạng yêu cầu'; - - @override - String get plugins => 'Tiện ích bổ sung'; - - @override - String get paste_plugin_download_url => - 'Dán url tải xuống hoặc url kho lưu trữ GitHub/Codeberg hoặc liên kết trực tiếp đến tệp .smplug'; - - @override - String get download_and_install_plugin_from_url => - 'Tải xuống và cài đặt plugin từ url'; - - @override - String failed_to_add_plugin_error(Object error) { - return 'Không thể thêm plugin: $error'; - } - - @override - String get upload_plugin_from_file => 'Tải lên plugin từ tệp'; - - @override - String get installed => 'Đã cài đặt'; - - @override - String get available_plugins => 'Các plugin có sẵn'; - - @override - String get configure_plugins => - 'Cấu hình nhà cung cấp siêu dữ liệu và tiện ích nguồn âm thanh riêng'; - - @override - String get audio_scrobblers => 'Bộ scrobbler âm thanh'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => 'Nguồn: '; - - @override - String get uncompressed => 'Không nén'; - - @override - String get dab_music_source_description => - 'Dành cho người yêu âm nhạc chất lượng cao. Cung cấp luồng âm thanh chất lượng cao/không nén. Phù hợp bài hát dựa trên ISRC chính xác.'; -} diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart deleted file mode 100644 index ac7d4890..00000000 --- a/lib/l10n/generated/app_localizations_zh.dart +++ /dev/null @@ -1,3051 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Chinese (`zh`). -class AppLocalizationsZh extends AppLocalizations { - AppLocalizationsZh([String locale = 'zh']) : super(locale); - - @override - String get guest => '访客'; - - @override - String get browse => '浏览'; - - @override - String get search => '搜索'; - - @override - String get library => '音乐库'; - - @override - String get lyrics => '歌词'; - - @override - String get settings => '设置'; - - @override - String get genre_categories_filter => '筛选类别...'; - - @override - String get genre => '探索歌单'; - - @override - String get personalized => '为你打造'; - - @override - String get featured => '推荐'; - - @override - String get new_releases => '新歌热播'; - - @override - String get songs => '歌曲'; - - @override - String playing_track(Object track) { - return '播放 $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return '这将清空当前的播放队列。$track_length 首歌曲将被移除\n你确定要继续吗?'; - } - - @override - String get load_more => '加载更多'; - - @override - String get playlists => '歌单'; - - @override - String get artists => '艺人'; - - @override - String get albums => '专辑'; - - @override - String get tracks => '歌曲'; - - @override - String get downloads => '下载'; - - @override - String get filter_playlists => '筛选歌单...'; - - @override - String get liked_tracks => '已点赞的歌曲'; - - @override - String get liked_tracks_description => '你点赞过的所有歌曲'; - - @override - String get playlist => '播放列表'; - - @override - String get create_a_playlist => '创建一个歌单'; - - @override - String get update_playlist => '更新播放列表'; - - @override - String get create => '创建'; - - @override - String get cancel => '取消'; - - @override - String get update => '更新'; - - @override - String get playlist_name => '歌单名称'; - - @override - String get name_of_playlist => '歌单的名称'; - - @override - String get description => '描述'; - - @override - String get public => '公开'; - - @override - String get collaborative => '共享协作'; - - @override - String get search_local_tracks => '搜索本地歌曲...'; - - @override - String get play => '播放'; - - @override - String get delete => '删除'; - - @override - String get none => '无'; - - @override - String get sort_a_z => '按字母正序'; - - @override - String get sort_z_a => '按字母倒序'; - - @override - String get sort_artist => '按艺人'; - - @override - String get sort_album => '按专辑'; - - @override - String get sort_duration => '按时长排序'; - - @override - String get sort_tracks => '排序方式'; - - @override - String currently_downloading(Object tracks_length) { - return '正在下载 ($tracks_length)'; - } - - @override - String get cancel_all => '取消全部'; - - @override - String get filter_artist => '筛选艺人...'; - - @override - String followers(Object followers) { - return '$followers 名关注者'; - } - - @override - String get add_artist_to_blacklist => '屏蔽该艺人'; - - @override - String get top_tracks => '热门歌曲'; - - @override - String get fans_also_like => '粉丝也喜欢'; - - @override - String get loading => '加载中...'; - - @override - String get artist => '艺人'; - - @override - String get blacklisted => '已屏蔽'; - - @override - String get following => '关注中'; - - @override - String get follow => '关注'; - - @override - String get artist_url_copied => '艺人的分享链接已复制至剪贴板'; - - @override - String added_to_queue(Object tracks) { - return '已添加 $tracks 首歌曲到播放队列'; - } - - @override - String get filter_albums => '筛选专辑...'; - - @override - String get synced => '同步'; - - @override - String get plain => '无同步'; - - @override - String get shuffle => '随机播放'; - - @override - String get search_tracks => '搜索歌曲...'; - - @override - String get released => '发行时间'; - - @override - String error(Object error) { - return '错误 $error'; - } - - @override - String get title => '标题'; - - @override - String get time => '时长'; - - @override - String get more_actions => '更多操作'; - - @override - String download_count(Object count) { - return '下载 ($count) 首歌曲'; - } - - @override - String add_count_to_playlist(Object count) { - return '添加 ($count) 首歌曲到歌单中'; - } - - @override - String add_count_to_queue(Object count) { - return '添加 ($count) 首歌曲到播放队列中'; - } - - @override - String play_count_next(Object count) { - return '接下来播放 ($count) 首歌曲'; - } - - @override - String get album => '专辑'; - - @override - String copied_to_clipboard(Object data) { - return '已将 $data 复制至剪贴板'; - } - - @override - String add_to_following_playlists(Object track) { - return '添加 $track 到以下播放列表'; - } - - @override - String get add => '添加'; - - @override - String added_track_to_queue(Object track) { - return '添加 $track 到播放队列'; - } - - @override - String get add_to_queue => '添加到播放队列'; - - @override - String track_will_play_next(Object track) { - return '$track 将在下一首播放'; - } - - @override - String get play_next => '下一首播放'; - - @override - String removed_track_from_queue(Object track) { - return '将 $track 从播放队列中移除'; - } - - @override - String get remove_from_queue => '从播放队列移除'; - - @override - String get remove_from_favorites => '取消点赞'; - - @override - String get save_as_favorite => '点赞'; - - @override - String get add_to_playlist => '添加到歌单'; - - @override - String get remove_from_playlist => '从歌单中移除'; - - @override - String get add_to_blacklist => '添加到屏蔽列表'; - - @override - String get remove_from_blacklist => '从屏蔽列表中移除'; - - @override - String get share => '分享'; - - @override - String get mini_player => '小窗模式'; - - @override - String get slide_to_seek => '滑动以前进或后退'; - - @override - String get shuffle_playlist => '随机播放歌单'; - - @override - String get unshuffle_playlist => '取消随机播放歌单'; - - @override - String get previous_track => '上一首歌曲'; - - @override - String get next_track => '下一首歌曲'; - - @override - String get pause_playback => '暂停播放'; - - @override - String get resume_playback => '恢复播放'; - - @override - String get loop_track => '单曲循环'; - - @override - String get no_loop => '无循环'; - - @override - String get repeat_playlist => '歌单循环'; - - @override - String get queue => '播放队列'; - - @override - String get alternative_track_sources => '其它音源'; - - @override - String get download_track => '下载歌曲'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks 首歌曲在播放队列中'; - } - - @override - String get clear_all => '清除全部'; - - @override - String get show_hide_ui_on_hover => '悬停时显示/隐藏控制栏'; - - @override - String get always_on_top => '置顶'; - - @override - String get exit_mini_player => '退出小窗模式'; - - @override - String get download_location => '下载路径'; - - @override - String get local_library => '本地图书馆'; - - @override - String get add_library_location => '添加到图书馆'; - - @override - String get remove_library_location => '从图书馆中删除'; - - @override - String get account => '账户'; - - @override - String get logout => '退出'; - - @override - String get logout_of_this_account => '退出该账户'; - - @override - String get language_region => '语言和地区'; - - @override - String get language => '语言'; - - @override - String get system_default => '系统默认'; - - @override - String get market_place_region => '市场地区'; - - @override - String get recommendation_country => '选择国家与地区以获取对应推荐'; - - @override - String get appearance => '外观'; - - @override - String get layout_mode => '布局类型'; - - @override - String get override_layout_settings => '将覆盖响应式布局设置'; - - @override - String get adaptive => '自适应'; - - @override - String get compact => '紧凑'; - - @override - String get extended => '宽广'; - - @override - String get theme => '主题'; - - @override - String get dark => '深色'; - - @override - String get light => '浅色'; - - @override - String get system => '系统'; - - @override - String get accent_color => '主色调'; - - @override - String get sync_album_color => '匹配封面颜色'; - - @override - String get sync_album_color_description => '选取专辑封面主题色作为主色调'; - - @override - String get playback => '播放'; - - @override - String get audio_quality => '音质'; - - @override - String get high => '高'; - - @override - String get low => '低'; - - @override - String get pre_download_play => '先下后播'; - - @override - String get pre_download_play_description => '先下载歌曲后再播放而非流式播放(推荐带宽较高用户使用)'; - - @override - String get skip_non_music => '跳过非音乐片段(屏蔽赞助商)'; - - @override - String get blacklist_description => '已屏蔽的歌曲与艺人'; - - @override - String get wait_for_download_to_finish => '请等待当前下载任务完成'; - - @override - String get desktop => '桌面端设置'; - - @override - String get close_behavior => '点击关闭按钮行为'; - - @override - String get close => '关闭'; - - @override - String get minimize_to_tray => '最小化到托盘'; - - @override - String get show_tray_icon => '显示托盘图标'; - - @override - String get about => '关于'; - - @override - String get u_love_spotube => '我们明白你喜欢 Spotube'; - - @override - String get check_for_updates => '检查更新'; - - @override - String get about_spotube => '关于 Spotube'; - - @override - String get blacklist => '屏蔽列表'; - - @override - String get please_sponsor => '请赞助/捐赠'; - - @override - String get spotube_description => 'Spotube,一个轻量、跨平台且完全免费的 Spotify 客户端。'; - - @override - String get version => '版本'; - - @override - String get build_number => '构建代码'; - - @override - String get founder => '发起人'; - - @override - String get repository => '源码'; - - @override - String get bug_issues => '缺陷和问题报告'; - - @override - String get made_with => '于孟加拉🇧🇩用 ❤️ 发电'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => '许可证'; - - @override - String get credentials_will_not_be_shared_disclaimer => - '不用担心,软件不会收集或分享任何个人数据给第三方'; - - @override - String get know_how_to_login => '不知道该怎么做?'; - - @override - String get follow_step_by_step_guide => '请按照以下指南进行'; - - @override - String cookie_name_cookie(Object name) { - return '$name Cookie'; - } - - @override - String get fill_in_all_fields => '请填写所有栏目'; - - @override - String get submit => '提交'; - - @override - String get exit => '退出'; - - @override - String get previous => '上一步'; - - @override - String get next => '下一步'; - - @override - String get done => '完成'; - - @override - String get step_1 => '步骤 1'; - - @override - String get first_go_to => '首先,前往'; - - @override - String get something_went_wrong => '某些地方出现了问题'; - - @override - String get piped_instance => 'Piped 服务器实例'; - - @override - String get piped_description => 'Piped 服务器实例用于匹配歌曲'; - - @override - String get piped_warning => '它们中的一部分可能并不能正常工作。使用时请自行承担风险'; - - @override - String get invidious_instance => 'Invidious服务器实例'; - - @override - String get invidious_description => '用于音轨匹配的Invidious服务器实例'; - - @override - String get invidious_warning => '有些可能无法正常工作。请自行承担风险'; - - @override - String get generate => '生成'; - - @override - String track_exists(Object track) { - return '歌曲 $track 已存在'; - } - - @override - String get replace_downloaded_tracks => '替换已下载的歌曲'; - - @override - String get skip_download_tracks => '下载时跳过已下载的歌曲'; - - @override - String get do_you_want_to_replace => '你确定要替换已下载的歌曲吗??'; - - @override - String get replace => '替换'; - - @override - String get skip => '跳过'; - - @override - String select_up_to_count_type(Object count, Object type) { - return '选择多达 $count 种的类型 $type'; - } - - @override - String get select_genres => '选择曲风'; - - @override - String get add_genres => '添加曲风'; - - @override - String get country => '国家和地区'; - - @override - String get number_of_tracks_generate => '生成歌曲的数目'; - - @override - String get acousticness => '原声程度'; - - @override - String get danceability => '律动感'; - - @override - String get energy => '冲击感'; - - @override - String get instrumentalness => '歌唱部分占比'; - - @override - String get liveness => '现场感'; - - @override - String get loudness => '响度'; - - @override - String get speechiness => '朗诵比例'; - - @override - String get valence => '心理感受'; - - @override - String get popularity => '流行度'; - - @override - String get key => '曲调'; - - @override - String get duration => '歌曲时长 (s)'; - - @override - String get tempo => '分钟节拍数 (BPM)'; - - @override - String get mode => '旋律重复度'; - - @override - String get time_signature => '音符时值'; - - @override - String get short => '短'; - - @override - String get medium => '中'; - - @override - String get long => '长'; - - @override - String get min => '最低'; - - @override - String get max => '最高'; - - @override - String get target => '目标'; - - @override - String get moderate => '中'; - - @override - String get deselect_all => '取消全选'; - - @override - String get select_all => '全选'; - - @override - String get are_you_sure => '你确定吗?'; - - @override - String get generating_playlist => '正在生成你的自定义歌单...'; - - @override - String selected_count_tracks(Object count) { - return '已选择 $count 首歌曲'; - } - - @override - String get download_warning => - '如果你大量下载这些歌曲,你显然在侵犯音乐的版权并对音乐创作社区造成了伤害。我希望你能意识到这一点。永远要尊重并支持艺术家们的辛勤工作'; - - @override - String get download_ip_ban_warning => - '小心,如果出现超出正常的下载请求那你的 IP 可能会被 YouTube 封禁,这意味着你的设备将在长达 2-3 个月的时间内无法使用该 IP 访问 YouTube(即使你没登录)。Spotube 对此不承担任何责任'; - - @override - String get by_clicking_accept_terms => '点击 \'同意\' 代表着你同意以下的条款'; - - @override - String get download_agreement_1 => '我明白侵犯音乐版权是一件不好的事情'; - - @override - String get download_agreement_2 => '我将尽可能支持艺术家的工作。我现在之所以做不到是因为缺乏资金来购买正版'; - - @override - String get download_agreement_3 => - '我完全了解我的 IP 存在被 YouTube的风险。我同意 Spotube 的所有者与贡献者们无须对我目前的行为所导致的任何后果负责'; - - @override - String get decline => '拒绝'; - - @override - String get accept => '同意'; - - @override - String get details => '详情'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => '频道'; - - @override - String get likes => '赞'; - - @override - String get dislikes => '踩'; - - @override - String get views => '浏览次数'; - - @override - String get streamUrl => '播放流 URL'; - - @override - String get stop => '停止'; - - @override - String get sort_newest => '按添加日期正序'; - - @override - String get sort_oldest => '按添加日期倒序'; - - @override - String get sleep_timer => '睡眠定时器'; - - @override - String mins(Object minutes) { - return '$minutes 分'; - } - - @override - String hours(Object hours) { - return '$hours 时'; - } - - @override - String hour(Object hours) { - return '$hours 时'; - } - - @override - String get custom_hours => '自定义时间'; - - @override - String get logs => '日志'; - - @override - String get developers => '开发者'; - - @override - String get not_logged_in => '你尚未登录'; - - @override - String get search_mode => '搜索模式'; - - @override - String get audio_source => '音频源'; - - @override - String get ok => '确定'; - - @override - String get failed_to_encrypt => '加密失败'; - - @override - String get encryption_failed_warning => - 'Spotube使用加密来安全地存储您的数据。但是失败了。因此,它将回退到不安全的存储\n如果您使用Linux,请确保已安装gnome-keyring、kde-wallet和keepassxc等秘密服务'; - - @override - String get querying_info => '正在查询信息...'; - - @override - String get piped_api_down => 'Piped API不可用'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return '当前Piped实例$pipedInstance不可用\n\n请更改实例或将\'API类型\'更改为官方YouTube API\n\n更改后请确保重新启动应用程序'; - } - - @override - String get you_are_offline => '您当前处于离线状态'; - - @override - String get connection_restored => '您的互联网连接已恢复'; - - @override - String get use_system_title_bar => '使用系统标题栏'; - - @override - String get crunching_results => '处理结果中...'; - - @override - String get search_to_get_results => '搜索以获取结果'; - - @override - String get use_amoled_mode => '使用 AMOLED 模式'; - - @override - String get pitch_dark_theme => '深色主题'; - - @override - String get normalize_audio => '标准化音频'; - - @override - String get change_cover => '更改封面'; - - @override - String get add_cover => '添加封面'; - - @override - String get restore_defaults => '恢复默认值'; - - @override - String get download_music_format => '下载音乐格式'; - - @override - String get streaming_music_format => '流媒体音乐格式'; - - @override - String get download_music_quality => '下载音乐质量'; - - @override - String get streaming_music_quality => '流媒体音乐质量'; - - @override - String get login_with_lastfm => '使用 Last.fm 登录'; - - @override - String get connect => '连接'; - - @override - String get disconnect_lastfm => '断开 Last.fm 连接'; - - @override - String get disconnect => '断开连接'; - - @override - String get username => '用户名'; - - @override - String get password => '密码'; - - @override - String get login => '登录'; - - @override - String get login_with_your_lastfm => '使用您的 Last.fm 帐户登录'; - - @override - String get scrobble_to_lastfm => '在 Last.fm 上记录播放'; - - @override - String get go_to_album => '前往专辑'; - - @override - String get discord_rich_presence => 'Discord 丰富展现'; - - @override - String get browse_all => '浏览全部'; - - @override - String get genres => '音乐类型'; - - @override - String get explore_genres => '探索音乐类型'; - - @override - String get friends => '朋友'; - - @override - String get no_lyrics_available => '抱歉,无法找到此曲的歌词'; - - @override - String get start_a_radio => '开始收听电台'; - - @override - String get how_to_start_radio => '您想如何开始收听电台?'; - - @override - String get replace_queue_question => '您想要替换当前队列还是追加到队列?'; - - @override - String get endless_playback => '无尽播放'; - - @override - String get delete_playlist => '删除播放列表'; - - @override - String get delete_playlist_confirmation => '您确定要删除此播放列表吗?'; - - @override - String get local_tracks => '本地音轨'; - - @override - String get local_tab => '本地'; - - @override - String get song_link => '歌曲链接'; - - @override - String get skip_this_nonsense => '跳过此无聊内容'; - - @override - String get freedom_of_music => '“音乐的自由”'; - - @override - String get freedom_of_music_palm => '“音乐的自由掌握在您手中”'; - - @override - String get get_started => '让我们开始吧'; - - @override - String get youtube_source_description => '推荐并且效果最佳。'; - - @override - String get piped_source_description => '感觉自由?与YouTube一样但更自由。'; - - @override - String get jiosaavn_source_description => '最适合南亚地区。'; - - @override - String get invidious_source_description => '类似于Piped,但可用性更高。'; - - @override - String highest_quality(Object quality) { - return '最高音质:$quality'; - } - - @override - String get select_audio_source => '选择音频源'; - - @override - String get endless_playback_description => '自动将新歌曲添加到队列的末尾'; - - @override - String get choose_your_region => '选择您的地区'; - - @override - String get choose_your_region_description => '这将帮助Spotube为您的位置显示正确的内容。'; - - @override - String get choose_your_language => '选择您的语言'; - - @override - String get help_project_grow => '帮助这个项目成长'; - - @override - String get help_project_grow_description => - 'Spotube是一个开源项目。您可以通过为项目做出贡献、报告错误或建议新功能来帮助该项目成长。'; - - @override - String get contribute_on_github => '在GitHub上做出贡献'; - - @override - String get donate_on_open_collective => '在Open Collective上捐款'; - - @override - String get browse_anonymously => '匿名浏览'; - - @override - String get enable_connect => '启用连接'; - - @override - String get enable_connect_description => '从其他设备控制Spotube'; - - @override - String get devices => '设备'; - - @override - String get select => '选择'; - - @override - String connect_client_alert(Object client) { - return '您正在被 $client 控制'; - } - - @override - String get this_device => '此设备'; - - @override - String get remote => '远程'; - - @override - String get stats => '统计'; - - @override - String and_n_more(Object count) { - return '和 $count 更多'; - } - - @override - String get recently_played => '最近播放'; - - @override - String get browse_more => '浏览更多'; - - @override - String get no_title => '没有标题'; - - @override - String get not_playing => '未播放'; - - @override - String get epic_failure => '史诗级失败!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return '已将 $tracks_length 首曲目添加到队列'; - } - - @override - String get spotube_has_an_update => 'Spotube 有更新'; - - @override - String get download_now => '立即下载'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum 已发布'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version 已发布'; - } - - @override - String get read_the_latest => '阅读最新'; - - @override - String get release_notes => '版本说明'; - - @override - String get pick_color_scheme => '选择配色方案'; - - @override - String get save => '保存'; - - @override - String get choose_the_device => '选择设备:'; - - @override - String get multiple_device_connected => '已连接多个设备。\n选择您希望执行此操作的设备'; - - @override - String get nothing_found => '未找到任何内容'; - - @override - String get the_box_is_empty => '箱子为空'; - - @override - String get top_artists => '热门艺术家'; - - @override - String get top_albums => '热门专辑'; - - @override - String get this_week => '本周'; - - @override - String get this_month => '本月'; - - @override - String get last_6_months => '过去6个月'; - - @override - String get this_year => '今年'; - - @override - String get last_2_years => '过去2年'; - - @override - String get all_time => '所有时间'; - - @override - String powered_by_provider(Object providerName) { - return '由 $providerName 提供支持'; - } - - @override - String get email => '电子邮件'; - - @override - String get profile_followers => '关注者'; - - @override - String get birthday => '生日'; - - @override - String get subscription => '订阅'; - - @override - String get not_born => '尚未出生'; - - @override - String get hacker => '黑客'; - - @override - String get profile => '个人资料'; - - @override - String get no_name => '无名'; - - @override - String get edit => '编辑'; - - @override - String get user_profile => '用户资料'; - - @override - String count_plays(Object count) { - return '$count 次播放'; - } - - @override - String get streaming_fees_hypothetical => - '*基于 Spotify 每次播放的支付金额\n从 \$0.003 到 \$0.005 计算。这是一个假设性的\n计算,旨在让用户了解如果他们在 Spotify 上收听\n这些歌曲,可能会付给艺术家的金额。'; - - @override - String get minutes_listened => '听的分钟数'; - - @override - String get streamed_songs => '已流媒体歌曲'; - - @override - String count_streams(Object count) { - return '$count 次流媒体'; - } - - @override - String get owned_by_you => '由您拥有'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl 已复制到剪贴板'; - } - - @override - String get hipotetical_calculation => - '*这是根据在线音乐流媒体平台每流平均支付0.003美元至0.005美元计算得出的。这是一个假设性的计算,旨在让用户了解如果他们在不同的音乐流媒体平台上收听歌曲,他们将需要向艺人支付多少费用。'; - - @override - String count_mins(Object minutes) { - return '$minutes 分钟'; - } - - @override - String get summary_minutes => '分钟'; - - @override - String get summary_listened_to_music => '听音乐'; - - @override - String get summary_songs => '歌曲'; - - @override - String get summary_streamed_overall => '总体流媒体'; - - @override - String get summary_owed_to_artists => '本月欠艺术家的'; - - @override - String get summary_artists => '艺术家的'; - - @override - String get summary_music_reached_you => '音乐触及了你'; - - @override - String get summary_full_albums => '完整专辑'; - - @override - String get summary_got_your_love => '获得了你的爱'; - - @override - String get summary_playlists => '播放列表'; - - @override - String get summary_were_on_repeat => '已重复播放'; - - @override - String total_money(Object money) { - return '总计 $money'; - } - - @override - String get webview_not_found => '未找到 Webview'; - - @override - String get webview_not_found_description => - '您的设备中未安装 Webview 运行时。\n如果已安装,请确保它在 environment PATH 中\n\n安装后,重新启动应用程序'; - - @override - String get unsupported_platform => '不支持的平台'; - - @override - String get cache_music => '缓存音乐'; - - @override - String get open => '打开'; - - @override - String get cache_folder => '缓存文件夹'; - - @override - String get export => '导出'; - - @override - String get clear_cache => '清除缓存'; - - @override - String get clear_cache_confirmation => '您要清除缓存吗?'; - - @override - String get export_cache_files => '导出缓存文件'; - - @override - String found_n_files(Object count) { - return '找到 $count 个文件'; - } - - @override - String get export_cache_confirmation => '您要导出这些文件到'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return '导出了 $filesExported / $files 个文件'; - } - - @override - String get undo => '撤销'; - - @override - String get download_all => '下载全部'; - - @override - String get add_all_to_playlist => '将全部添加到播放列表'; - - @override - String get add_all_to_queue => '将全部添加到队列'; - - @override - String get play_all_next => '播放全部下一首'; - - @override - String get pause => '暂停'; - - @override - String get view_all => '查看所有'; - - @override - String get no_tracks_added_yet => '看起来你还没有添加任何曲目'; - - @override - String get no_tracks => '看起来这里没有任何曲目'; - - @override - String get no_tracks_listened_yet => '看起来你还没有听任何东西'; - - @override - String get not_following_artists => '你没有关注任何艺术家'; - - @override - String get no_favorite_albums_yet => '看起来你还没有将任何专辑添加到收藏夹'; - - @override - String get no_logs_found => '未找到日志'; - - @override - String get youtube_engine => 'YouTube 引擎'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine 未安装'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine 未在您的系统中安装。'; - } - - @override - String youtube_engine_set_path(Object engine) { - return '确保它可用在 PATH 变量中,或\n设置 $engine 可执行文件的绝对路径'; - } - - @override - String get youtube_engine_unix_issue_message => - '在 macOS/Linux/Unix 类操作系统中,在 .zshrc/.bashrc/.bash_profile 等文件中设置路径无效。\n您需要在 shell 配置文件中设置路径'; - - @override - String get download => '下载'; - - @override - String get file_not_found => '文件未找到'; - - @override - String get custom => '自定义'; - - @override - String get add_custom_url => '添加自定义 URL'; - - @override - String get edit_port => '编辑端口'; - - @override - String get port_helper_msg => '默认值为-1,表示随机数。如果您已配置防火墙,建议设置此项。'; - - @override - String connect_request(Object client) { - return '允许 $client 连接吗?'; - } - - @override - String get connection_request_denied => '连接被拒绝。用户拒绝访问。'; - - @override - String get an_error_occurred => '发生错误'; - - @override - String get copy_to_clipboard => '复制到剪贴板'; - - @override - String get view_logs => '查看日志'; - - @override - String get retry => '重试'; - - @override - String get no_default_metadata_provider_selected => '您未设置默认元数据提供者'; - - @override - String get manage_metadata_providers => '管理元数据提供者'; - - @override - String get open_link_in_browser => '在浏览器中打开链接?'; - - @override - String get do_you_want_to_open_the_following_link => '您想打开以下链接吗'; - - @override - String get unsafe_url_warning => '从不受信任的来源打开链接可能不安全。请谨慎!\n您也可以将链接复制到剪贴板。'; - - @override - String get copy_link => '复制链接'; - - @override - String get building_your_timeline => '正在根据您的收听记录构建您的时间线...'; - - @override - String get official => '官方'; - - @override - String author_name(Object author) { - return '作者:$author'; - } - - @override - String get third_party => '第三方'; - - @override - String get plugin_requires_authentication => '插件需要身份验证'; - - @override - String get update_available => '有可用更新'; - - @override - String get supports_scrobbling => '支持 Scrobbling'; - - @override - String get plugin_scrobbling_info => '此插件会 scrobble 您的音乐以生成您的收听历史记录。'; - - @override - String get default_metadata_source => '默认元数据源'; - - @override - String get set_default_metadata_source => '设置默认元数据源'; - - @override - String get default_audio_source => '默认音频源'; - - @override - String get set_default_audio_source => '设置默认音频源'; - - @override - String get set_default => '设为默认'; - - @override - String get support => '支持'; - - @override - String get support_plugin_development => '支持插件开发'; - - @override - String can_access_name_api(Object name) { - return '- 可以访问 **$name** API'; - } - - @override - String get do_you_want_to_install_this_plugin => '您想安装此插件吗?'; - - @override - String get third_party_plugin_warning => '此插件来自第三方存储库。请在安装前确保您信任此来源。'; - - @override - String get author => '作者'; - - @override - String get this_plugin_can_do_following => '此插件可以执行以下操作'; - - @override - String get install => '安装'; - - @override - String get install_a_metadata_provider => '安装元数据提供者'; - - @override - String get no_tracks_playing => '当前没有播放任何曲目'; - - @override - String get synced_lyrics_not_available => '此歌曲的同步歌词不可用。请使用'; - - @override - String get plain_lyrics => '纯歌词'; - - @override - String get tab_instead => '选项卡。'; - - @override - String get disclaimer => '免责声明'; - - @override - String get third_party_plugin_dmca_notice => - 'Spotube 团队对任何“第三方”插件不承担任何责任(包括法律责任)。\n请自行承担风险使用。对于任何错误/问题,请向插件存储库报告。\n\n如果任何“第三方”插件违反了任何服务/法律实体的服务条款/DMCA,请要求该“第三方”插件作者或托管平台(例如 GitHub/Codeberg)采取行动。上面列出的(标记为“第三方”)都是公共/社区维护的插件。我们不对此类插件进行管理,因此无法对其采取任何行动。\n\n'; - - @override - String get input_does_not_match_format => '输入与所需格式不匹配'; - - @override - String get plugins => '插件'; - - @override - String get paste_plugin_download_url => - '粘贴下载 URL、GitHub/Codeberg 存储库 URL 或 .smplug 文件的直接链接'; - - @override - String get download_and_install_plugin_from_url => '从 URL 下载并安装插件'; - - @override - String failed_to_add_plugin_error(Object error) { - return '添加插件失败:$error'; - } - - @override - String get upload_plugin_from_file => '从文件上传插件'; - - @override - String get installed => '已安装'; - - @override - String get available_plugins => '可用插件'; - - @override - String get configure_plugins => '配置您自己的元数据提供者和音频源插件'; - - @override - String get audio_scrobblers => '音频 Scrobblers'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => '来源:'; - - @override - String get uncompressed => '无损'; - - @override - String get dab_music_source_description => - '适合发烧友。提供高质量/无损音频流。基于 ISRC 的精确曲目匹配。'; -} - -/// The translations for Chinese, as used in Taiwan (`zh_TW`). -class AppLocalizationsZhTw extends AppLocalizationsZh { - AppLocalizationsZhTw() : super('zh_TW'); - - @override - String get guest => '訪客'; - - @override - String get browse => '瀏覽'; - - @override - String get search => '搜尋'; - - @override - String get library => '音樂庫'; - - @override - String get lyrics => '歌詞'; - - @override - String get settings => '設定'; - - @override - String get genre_categories_filter => '過濾分類...'; - - @override - String get genre => '探索歌單'; - - @override - String get personalized => '為你打造'; - - @override - String get featured => '推薦'; - - @override - String get new_releases => '新歌熱播'; - - @override - String get songs => '歌曲'; - - @override - String playing_track(Object track) { - return '播放 $track'; - } - - @override - String queue_clear_alert(Object track_length) { - return '這將清空目前的播放清單。$track_length 首歌曲將被移除\n你確定要繼續嗎?'; - } - - @override - String get load_more => '載入更多'; - - @override - String get playlists => '歌單'; - - @override - String get artists => '藝人'; - - @override - String get albums => '專輯'; - - @override - String get tracks => '歌曲'; - - @override - String get downloads => '下載'; - - @override - String get filter_playlists => '過濾歌單...'; - - @override - String get liked_tracks => '已按讚的歌曲'; - - @override - String get liked_tracks_description => '你按過讚的所有歌曲'; - - @override - String get playlist => '播放清單'; - - @override - String get create_a_playlist => '建立一個歌單'; - - @override - String get update_playlist => '更新播放清單'; - - @override - String get create => '建立'; - - @override - String get cancel => '取消'; - - @override - String get update => '更新'; - - @override - String get playlist_name => '歌單名稱'; - - @override - String get name_of_playlist => '歌單的名稱'; - - @override - String get description => '說明'; - - @override - String get public => '公開'; - - @override - String get collaborative => '共享協作'; - - @override - String get search_local_tracks => '搜尋本地歌曲...'; - - @override - String get play => '播放'; - - @override - String get delete => '刪除'; - - @override - String get none => '無'; - - @override - String get sort_a_z => '依字母順序'; - - @override - String get sort_z_a => '依字母倒序'; - - @override - String get sort_artist => '按藝人'; - - @override - String get sort_album => '按專輯'; - - @override - String get sort_duration => '依長度排序'; - - @override - String get sort_tracks => '排序方式'; - - @override - String currently_downloading(Object tracks_length) { - return '正在下載 ($tracks_length)'; - } - - @override - String get cancel_all => '取消全部'; - - @override - String get filter_artist => '過濾藝人...'; - - @override - String followers(Object followers) { - return '$followers 名追蹤者'; - } - - @override - String get add_artist_to_blacklist => '封鎖該藝人'; - - @override - String get top_tracks => '熱門歌曲'; - - @override - String get fans_also_like => '粉絲也喜歡'; - - @override - String get loading => '載入中...'; - - @override - String get artist => '藝人'; - - @override - String get blacklisted => '已封鎖'; - - @override - String get following => '關注中'; - - @override - String get follow => '關注'; - - @override - String get artist_url_copied => '此名藝人的分享連結已複製至剪貼簿'; - - @override - String added_to_queue(Object tracks) { - return '已新增 $tracks 首歌曲到播放清單'; - } - - @override - String get filter_albums => '過濾專輯...'; - - @override - String get synced => '同步'; - - @override - String get plain => '未同步'; - - @override - String get shuffle => '隨機播放'; - - @override - String get search_tracks => '搜尋歌曲...'; - - @override - String get released => '發表時間'; - - @override - String error(Object error) { - return '發生錯誤: $error'; - } - - @override - String get title => '標題'; - - @override - String get time => '時長'; - - @override - String get more_actions => '更多動作'; - - @override - String download_count(Object count) { - return '下載 ($count) 首歌曲'; - } - - @override - String add_count_to_playlist(Object count) { - return '將 ($count) 首歌曲新增到歌單中'; - } - - @override - String add_count_to_queue(Object count) { - return '新增 ($count) 首歌曲到播放清單'; - } - - @override - String play_count_next(Object count) { - return '接下來將播放 ($count) 首歌曲'; - } - - @override - String get album => '專輯'; - - @override - String copied_to_clipboard(Object data) { - return '已將 $data 複製至剪貼簿'; - } - - @override - String add_to_following_playlists(Object track) { - return '新增 $track 到以下播放清單'; - } - - @override - String get add => '新增'; - - @override - String added_track_to_queue(Object track) { - return '新增 $track 到播放清單'; - } - - @override - String get add_to_queue => '新增至播放清單'; - - @override - String track_will_play_next(Object track) { - return '$track 將在下一首播放'; - } - - @override - String get play_next => '下一首播放'; - - @override - String removed_track_from_queue(Object track) { - return '將 $track 從播放清單移除'; - } - - @override - String get remove_from_queue => '從播放清單移除'; - - @override - String get remove_from_favorites => '取消按讚'; - - @override - String get save_as_favorite => '按讚'; - - @override - String get add_to_playlist => '新增到歌單'; - - @override - String get remove_from_playlist => '從歌單移除'; - - @override - String get add_to_blacklist => '新增到已封鎖清單'; - - @override - String get remove_from_blacklist => '從已封鎖清單移除'; - - @override - String get share => '分享'; - - @override - String get mini_player => '小窗模式'; - - @override - String get slide_to_seek => '滑動以前進或後退'; - - @override - String get shuffle_playlist => '隨機播放歌單'; - - @override - String get unshuffle_playlist => '取消隨機播放歌單'; - - @override - String get previous_track => '上一首歌曲'; - - @override - String get next_track => '下一首歌'; - - @override - String get pause_playback => '暫停播放'; - - @override - String get resume_playback => '恢復播放'; - - @override - String get loop_track => '單曲循環'; - - @override - String get no_loop => '無循環'; - - @override - String get repeat_playlist => '歌單循環'; - - @override - String get queue => '播放清單'; - - @override - String get alternative_track_sources => '其它音源'; - - @override - String get download_track => '下載歌曲'; - - @override - String tracks_in_queue(Object tracks) { - return '$tracks 首歌曲在播放清單中'; - } - - @override - String get clear_all => '清除全部'; - - @override - String get show_hide_ui_on_hover => '游標暫留時顯示 / 隱藏控制列'; - - @override - String get always_on_top => '置頂'; - - @override - String get exit_mini_player => '退出小窗模式'; - - @override - String get download_location => '下載路徑'; - - @override - String get local_library => '本地媒體庫'; - - @override - String get add_library_location => '新增至媒體庫'; - - @override - String get remove_library_location => '從媒體庫移除'; - - @override - String get account => '帳戶'; - - @override - String get logout => '退出'; - - @override - String get logout_of_this_account => '退出該帳戶'; - - @override - String get language_region => '語言與地區'; - - @override - String get language => '語言'; - - @override - String get system_default => '系統預設'; - - @override - String get market_place_region => '市集地區'; - - @override - String get recommendation_country => '請選擇國家與地區以取得對應的音樂推薦'; - - @override - String get appearance => '外觀'; - - @override - String get layout_mode => '佈局類型'; - - @override - String get override_layout_settings => '將覆寫響應式佈局設定'; - - @override - String get adaptive => '響應式'; - - @override - String get compact => '緊湊'; - - @override - String get extended => '寬闊'; - - @override - String get theme => '主題'; - - @override - String get dark => '深色'; - - @override - String get light => '淺色'; - - @override - String get system => '依循系統'; - - @override - String get accent_color => '主色調'; - - @override - String get sync_album_color => '符合封面顏色'; - - @override - String get sync_album_color_description => '選取專輯封面主題色為主色調'; - - @override - String get playback => '播放'; - - @override - String get audio_quality => '音質'; - - @override - String get high => '高'; - - @override - String get low => '低'; - - @override - String get pre_download_play => '下載後播放'; - - @override - String get pre_download_play_description => '先下載歌曲後再播放而非串流播放(建議頻寬較高使用者使用)'; - - @override - String get skip_non_music => '跳過非音樂片段(跳過贊助商廣告)'; - - @override - String get blacklist_description => '已封鎖的歌曲與藝人'; - - @override - String get wait_for_download_to_finish => '請等待目前下載工作完成'; - - @override - String get desktop => '桌面版設定'; - - @override - String get close_behavior => '點選關閉按鈕行為'; - - @override - String get close => '關閉'; - - @override - String get minimize_to_tray => '最小化到工作列'; - - @override - String get show_tray_icon => '顯示工作列圖示'; - - @override - String get about => '關於'; - - @override - String get u_love_spotube => '我們明白你喜歡 Spotube'; - - @override - String get check_for_updates => '檢查更新'; - - @override - String get about_spotube => '關於 Spotube'; - - @override - String get blacklist => '黑名單'; - - @override - String get please_sponsor => '請考慮贊助或捐款'; - - @override - String get spotube_description => 'Spotube,一款輕量、跨平台且完全免費的 Spotify 用戶端。'; - - @override - String get version => '版本'; - - @override - String get build_number => '建置編號'; - - @override - String get founder => '發起人'; - - @override - String get repository => '專案儲存庫'; - - @override - String get bug_issues => '缺陷與問題報告'; - - @override - String get made_with => '於孟加拉🇧🇩用 ❤️ 發電'; - - @override - String get kingkor_roy_tirtho => 'Kingkor Roy Tirtho'; - - @override - String copyright(Object current_year) { - return '© 2021-$current_year Kingkor Roy Tirtho'; - } - - @override - String get license => '授權'; - - @override - String get credentials_will_not_be_shared_disclaimer => - '您大可放心,軟體不會收集或分享任何個人資料給第三方'; - - @override - String get know_how_to_login => '不知道該怎麼辦?'; - - @override - String get follow_step_by_step_guide => '請依照以下說明進行'; - - @override - String cookie_name_cookie(Object name) { - return '$name Cookie'; - } - - @override - String get fill_in_all_fields => '請填入所有欄位'; - - @override - String get submit => '提交'; - - @override - String get exit => '退出'; - - @override - String get previous => '上一步'; - - @override - String get next => '下一步'; - - @override - String get done => '完成'; - - @override - String get step_1 => '步驟 1'; - - @override - String get first_go_to => '首先,前往'; - - @override - String get something_went_wrong => '某些地方出現了問題'; - - @override - String get piped_instance => 'Piped 伺服器實例'; - - @override - String get piped_description => 'Piped 伺服器實例用於匹配歌曲'; - - @override - String get piped_warning => '它們之中的一部分可能無法正常運作。使用時請自行承擔風險'; - - @override - String get invidious_instance => 'Invidious 伺服器實例'; - - @override - String get invidious_description => '用於音軌匹配的 Invidious 伺服器實例'; - - @override - String get invidious_warning => '有些可能無法正常運作。請自行承擔風險'; - - @override - String get generate => '生成'; - - @override - String track_exists(Object track) { - return '曲目 $track 已存在'; - } - - @override - String get replace_downloaded_tracks => '替換已下載的歌曲'; - - @override - String get skip_download_tracks => '下載時跳過已下載的歌曲'; - - @override - String get do_you_want_to_replace => '你確定要取代已下載的歌曲嗎??'; - - @override - String get replace => '取代'; - - @override - String get skip => '跳過'; - - @override - String select_up_to_count_type(Object count, Object type) { - return '選擇最多 $count 種的類型 $type'; - } - - @override - String get select_genres => '選擇曲風'; - - @override - String get add_genres => '新增曲風'; - - @override - String get country => '國家和地區'; - - @override - String get number_of_tracks_generate => '產生歌曲的數目'; - - @override - String get acousticness => '原聲程度'; - - @override - String get danceability => '律動感'; - - @override - String get energy => '衝擊感'; - - @override - String get instrumentalness => '歌唱部分佔比'; - - @override - String get liveness => '現場感'; - - @override - String get loudness => '響度'; - - @override - String get speechiness => '朗誦比例'; - - @override - String get valence => '心理感受'; - - @override - String get popularity => '流行度'; - - @override - String get key => '曲調'; - - @override - String get duration => '歌曲長度 (s)'; - - @override - String get tempo => '每分鐘拍數 (BPM)'; - - @override - String get mode => '旋律重複度'; - - @override - String get time_signature => '音符時值'; - - @override - String get short => '短'; - - @override - String get medium => '中'; - - @override - String get long => '長'; - - @override - String get min => '最低'; - - @override - String get max => '最高'; - - @override - String get target => '目標'; - - @override - String get moderate => '中'; - - @override - String get deselect_all => '取消全選'; - - @override - String get select_all => '全選'; - - @override - String get are_you_sure => '你確定嗎?'; - - @override - String get generating_playlist => '正在產生你的自訂歌單...'; - - @override - String selected_count_tracks(Object count) { - return '已選取 $count 首歌曲'; - } - - @override - String get download_warning => - '如果你大量下載這些歌曲,你顯然在侵犯音樂的版權並對音樂創作社區造成了傷害。我希望你能意識到這一點。永遠要尊重並支持藝術家們的辛勤工作'; - - @override - String get download_ip_ban_warning => - '小心,如果出現超出正常的下載請求,那你的 IP 可能會被 YouTube 封鎖,這意味著你的裝置將在長達 2-3 個月的時間內無法使用該 IP 訪問 YouTube(即使你沒登入)。Spotube 不會因而承擔任何責任'; - - @override - String get by_clicking_accept_terms => '點擊 \'同意\' 代表你同意以下的條款'; - - @override - String get download_agreement_1 => '我明白侵害音樂版權是一件不好的事'; - - @override - String get download_agreement_2 => '我將盡可能支持藝術家的工作。我現在之所以做不到是因為缺乏資金來購買正版'; - - @override - String get download_agreement_3 => - '我完全了解我的 IP 存在被 YouTube 封鎖的風險。並且我明白 Spotube 的擁有者與貢獻者們無須對我目前的行為所導致的任何後果負責'; - - @override - String get decline => '拒絕'; - - @override - String get accept => '同意'; - - @override - String get details => '詳細資訊'; - - @override - String get youtube => 'YouTube'; - - @override - String get channel => '頻道'; - - @override - String get likes => '讚'; - - @override - String get dislikes => '倒讚'; - - @override - String get views => '瀏覽次數'; - - @override - String get streamUrl => '播放串流 URL'; - - @override - String get stop => '停止'; - - @override - String get sort_newest => '依新增日期順序'; - - @override - String get sort_oldest => '依新增日期倒序'; - - @override - String get sleep_timer => '睡眠計時器'; - - @override - String mins(Object minutes) { - return '$minutes 分'; - } - - @override - String hours(Object hours) { - return '$hours 時'; - } - - @override - String hour(Object hours) { - return '$hours 時'; - } - - @override - String get custom_hours => '自訂時長'; - - @override - String get logs => '記錄檔(Log)'; - - @override - String get developers => '開發者'; - - @override - String get not_logged_in => '你尚未登入'; - - @override - String get search_mode => '搜尋模式'; - - @override - String get audio_source => '音訊來源'; - - @override - String get ok => '確定'; - - @override - String get failed_to_encrypt => '加密失敗'; - - @override - String get encryption_failed_warning => - 'Spotube使用加密來安全地儲存您的資料。但是失敗了。因此,它將回退到不安全的儲存空間\n如果您使用Linux,請確保已安裝gnome-keyring、kde-wallet和keepassxc等加密服務'; - - @override - String get querying_info => '正在查詢資訊...'; - - @override - String get piped_api_down => 'Piped API 無法使用'; - - @override - String piped_down_error_instructions(Object pipedInstance) { - return '當前Piped實例 $pipedInstance 不可用\n\n請更改實例或將\'API類型\'更改為官方YouTube API\n\n更改後請確保重新啟動應用程式'; - } - - @override - String get you_are_offline => '您目前處於離線狀態'; - - @override - String get connection_restored => '您的網路連線已恢復'; - - @override - String get use_system_title_bar => '使用作業系統的預設視窗標題列'; - - @override - String get crunching_results => '處理結果中...'; - - @override - String get search_to_get_results => '搜尋以取得結果'; - - @override - String get use_amoled_mode => '使用 AMOLED 模式'; - - @override - String get pitch_dark_theme => '漆黑主題'; - - @override - String get normalize_audio => '標準化音訊'; - - @override - String get change_cover => '更改封面'; - - @override - String get add_cover => '新增封面'; - - @override - String get restore_defaults => '恢復預設值'; - - @override - String get download_music_format => '下載音樂格式'; - - @override - String get streaming_music_format => '串流音樂格式'; - - @override - String get download_music_quality => '下載音樂品質'; - - @override - String get streaming_music_quality => '串流音樂品質'; - - @override - String get login_with_lastfm => '使用 Last.fm 登入'; - - @override - String get connect => '連線'; - - @override - String get disconnect_lastfm => '切斷 Last.fm 連線'; - - @override - String get disconnect => '斷開連線'; - - @override - String get username => '帳號'; - - @override - String get password => '密碼'; - - @override - String get login => '登入'; - - @override - String get login_with_your_lastfm => '使用您的 Last.fm 帳號登入'; - - @override - String get scrobble_to_lastfm => '在 Last.fm 上記錄你的播放'; - - @override - String get go_to_album => '前往專輯'; - - @override - String get discord_rich_presence => 'Discord Rick Presence(Discord 狀態)'; - - @override - String get browse_all => '瀏覽全部'; - - @override - String get genres => '音樂類型'; - - @override - String get explore_genres => '探索音樂類型'; - - @override - String get friends => '好友'; - - @override - String get no_lyrics_available => '抱歉,無法找到這首歌的歌詞'; - - @override - String get start_a_radio => '開始收聽電台'; - - @override - String get how_to_start_radio => '您想如何開始收聽電台?'; - - @override - String get replace_queue_question => '您想要取代目前清單還是追加到清單?'; - - @override - String get endless_playback => '無限播放'; - - @override - String get delete_playlist => '刪除播放清單'; - - @override - String get delete_playlist_confirmation => '您確定要刪除此播放清單嗎?'; - - @override - String get local_tracks => '本地音訊'; - - @override - String get local_tab => '本地'; - - @override - String get song_link => '歌曲連結'; - - @override - String get skip_this_nonsense => '跳過這個無聊內容'; - - @override - String get freedom_of_music => '“音樂的自由”'; - - @override - String get freedom_of_music_palm => '「音樂的自由掌握在您手中」'; - - @override - String get get_started => '我們開始吧'; - - @override - String get youtube_source_description => '建議且效果最佳。'; - - @override - String get piped_source_description => '感覺自由?與 YouTube 一樣,但更自由。'; - - @override - String get jiosaavn_source_description => '最適合南亞地區。'; - - @override - String get invidious_source_description => '類似 Piped,但可用性更高。'; - - @override - String highest_quality(Object quality) { - return '最高音質:$quality'; - } - - @override - String get select_audio_source => '選擇音訊來源'; - - @override - String get endless_playback_description => '自動將新歌曲加入清單的結尾'; - - @override - String get choose_your_region => '選擇您的所在地區'; - - @override - String get choose_your_region_description => '這能幫助 Spotube 為您的所在位置顯示正確的內容。'; - - @override - String get choose_your_language => '選擇您的語言'; - - @override - String get help_project_grow => '幫助這個專案成長'; - - @override - String get help_project_grow_description => - 'Spotube是一個開源專案。您可以透過為專案做出貢獻、回報錯誤或建議新功能來幫助專案成長。'; - - @override - String get contribute_on_github => '在GitHub上做出貢獻'; - - @override - String get donate_on_open_collective => '在Open Collective上捐款'; - - @override - String get browse_anonymously => '匿名瀏覽'; - - @override - String get enable_connect => '啟用連線'; - - @override - String get enable_connect_description => '從其他裝置控制Spotube'; - - @override - String get devices => '裝置'; - - @override - String get select => '選擇'; - - @override - String connect_client_alert(Object client) { - return '您正在被 $client 控制'; - } - - @override - String get this_device => '此裝置'; - - @override - String get remote => '遠端'; - - @override - String get stats => '統計'; - - @override - String and_n_more(Object count) { - return '還有 $count 個'; - } - - @override - String get recently_played => '最近播放'; - - @override - String get browse_more => '瀏覽更多'; - - @override - String get no_title => '無標題'; - - @override - String get not_playing => '未播放'; - - @override - String get epic_failure => '史詩級的失敗!'; - - @override - String added_num_tracks_to_queue(Object tracks_length) { - return '已將 $tracks_length 首曲目新增至清單'; - } - - @override - String get spotube_has_an_update => 'Spotube 有更新版本'; - - @override - String get download_now => '立即下載'; - - @override - String nightly_version(Object nightlyBuildNum) { - return 'Spotube Nightly $nightlyBuildNum 已發佈'; - } - - @override - String release_version(Object version) { - return 'Spotube v$version 已發布'; - } - - @override - String get read_the_latest => '閱讀最新'; - - @override - String get release_notes => '版本說明'; - - @override - String get pick_color_scheme => '選擇配色方案'; - - @override - String get save => '儲存'; - - @override - String get choose_the_device => '選擇裝置:'; - - @override - String get multiple_device_connected => '已連接多個裝置。\n選擇您希望執行此操作的裝置'; - - @override - String get nothing_found => '未找到任何內容'; - - @override - String get the_box_is_empty => '箱子為空'; - - @override - String get top_artists => '熱門藝人'; - - @override - String get top_albums => '熱門專輯'; - - @override - String get this_week => '本週'; - - @override - String get this_month => '本月'; - - @override - String get last_6_months => '過去6個月'; - - @override - String get this_year => '今年'; - - @override - String get last_2_years => '過去2年'; - - @override - String get all_time => '所有時間'; - - @override - String powered_by_provider(Object providerName) { - return '由 $providerName 提供支援'; - } - - @override - String get email => '電子郵件'; - - @override - String get profile_followers => '追蹤者'; - - @override - String get birthday => '生日'; - - @override - String get subscription => '訂閱'; - - @override - String get not_born => '尚未建立'; - - @override - String get hacker => '駭客'; - - @override - String get profile => '個人資訊'; - - @override - String get no_name => '沒有名字'; - - @override - String get edit => '編輯'; - - @override - String get user_profile => '使用者資料'; - - @override - String count_plays(Object count) { - return '$count 次播放'; - } - - @override - String get streaming_fees_hypothetical => - '*基於 Spotify 每次播放的支付金額\n從 \$0.003 到 \$0.005 計算。這是一個假設性的\n計算,旨在讓用戶了解如果他們在 Spotify 上收聽\n這些歌曲,可能會付給作者的金額。'; - - @override - String get minutes_listened => '聽的分鐘數'; - - @override - String get streamed_songs => '已串流歌曲'; - - @override - String count_streams(Object count) { - return '$count 次串流'; - } - - @override - String get owned_by_you => '由您所有'; - - @override - String copied_shareurl_to_clipboard(Object shareUrl) { - return '$shareUrl 已複製到剪貼簿'; - } - - @override - String get hipotetical_calculation => - '*此為根據線上音樂串流平台平均每次播放 \$0.003 至 \$0.005 的收益所計算的假設值。此為一個假設性計算,旨在讓使用者了解若他們在不同的音樂串流平台上收聽同一首歌曲,他們將會支付給藝人多少費用。'; - - @override - String count_mins(Object minutes) { - return '$minutes 分鐘'; - } - - @override - String get summary_minutes => '分鐘'; - - @override - String get summary_listened_to_music => '聽音樂'; - - @override - String get summary_songs => '歌曲'; - - @override - String get summary_streamed_overall => '整體串流媒體'; - - @override - String get summary_owed_to_artists => '本月欠藝術家的'; - - @override - String get summary_artists => '藝術家的'; - - @override - String get summary_music_reached_you => '音樂接觸到你'; - - @override - String get summary_full_albums => '完整專輯'; - - @override - String get summary_got_your_love => '獲得了你的愛心'; - - @override - String get summary_playlists => '播放清單'; - - @override - String get summary_were_on_repeat => '已經重複播放'; - - @override - String total_money(Object money) { - return '總計 $money'; - } - - @override - String get webview_not_found => '未找到 Webview 框架'; - - @override - String get webview_not_found_description => - '您的裝置中未安裝 Webview Runtime。\n如果已安裝,請確保它的位置在系統環境變數(PATH)中\n\n安裝後,重新啟動應用程式'; - - @override - String get unsupported_platform => '不支援的平台'; - - @override - String get cache_music => '快取音樂'; - - @override - String get open => '開啟'; - - @override - String get cache_folder => '快取資料夾'; - - @override - String get export => '導出'; - - @override - String get clear_cache => '清除快取'; - - @override - String get clear_cache_confirmation => '您要清除快取嗎?'; - - @override - String get export_cache_files => '匯出快取檔案'; - - @override - String found_n_files(Object count) { - return '找到 $count 個檔案'; - } - - @override - String get export_cache_confirmation => '您要匯出這些檔案到'; - - @override - String exported_n_out_of_m_files(Object files, Object filesExported) { - return '匯出了 $filesExported / $files 個檔案'; - } - - @override - String get undo => '取消'; - - @override - String get download_all => '下載全部'; - - @override - String get add_all_to_playlist => '全部加入到播放清單'; - - @override - String get add_all_to_queue => '全部加入清單'; - - @override - String get play_all_next => '播放全部下一首'; - - @override - String get pause => '暫停'; - - @override - String get view_all => '檢視全部'; - - @override - String get no_tracks_added_yet => '看起來你還沒有加入任何歌曲'; - - @override - String get no_tracks => '看起來這裡沒有任何歌曲'; - - @override - String get no_tracks_listened_yet => '看起來你還沒聽任何歌曲'; - - @override - String get not_following_artists => '你沒有關注任何藝術家'; - - @override - String get no_favorite_albums_yet => '看起來你還沒有將任何專輯加入到收藏夾'; - - @override - String get no_logs_found => '未找到日誌'; - - @override - String get youtube_engine => 'YouTube 引擎'; - - @override - String youtube_engine_not_installed_title(Object engine) { - return '$engine 未安裝'; - } - - @override - String youtube_engine_not_installed_message(Object engine) { - return '$engine 未在您的系統中安裝。'; - } - - @override - String youtube_engine_set_path(Object engine) { - return '確保它可用在 PATH 變數中,或\n設定 $engine 執行檔的絕對路徑'; - } - - @override - String get youtube_engine_unix_issue_message => - '在類 Unix 作業系統(如 macOS/Linux/Unix)中,請在 .zshrc/.bashrc/.bash_profile 等檔案中設定路徑無效。\n您需要在 shell 設定檔中設定路徑'; - - @override - String get download => '下載'; - - @override - String get file_not_found => '找不到檔案'; - - @override - String get custom => '自訂'; - - @override - String get add_custom_url => '新增自訂 URL'; - - @override - String get edit_port => '編輯端口'; - - @override - String get port_helper_msg => '預設值為 -1,表示隨機數。如果您已配置防火牆,建議設定此項目。'; - - @override - String connect_request(Object client) { - return '允許 $client 連線嗎?'; - } - - @override - String get connection_request_denied => '連線被拒絕。請求被使用者拒絕。'; - - @override - String get an_error_occurred => '發生錯誤'; - - @override - String get copy_to_clipboard => '複製到剪貼簿'; - - @override - String get view_logs => '檢視日誌'; - - @override - String get retry => '重試'; - - @override - String get no_default_metadata_provider_selected => '您沒有設定預設的中繼資料供應商'; - - @override - String get manage_metadata_providers => '管理中繼資料供應商'; - - @override - String get open_link_in_browser => '要在瀏覽器中開啟連結嗎?'; - - @override - String get do_you_want_to_open_the_following_link => '您想開啟以下連結嗎'; - - @override - String get unsafe_url_warning => '從不受信任的來源開啟連結可能不安全。請務必小心!\n您也可以將連結複製到剪貼簿。'; - - @override - String get copy_link => '複製連結'; - - @override - String get building_your_timeline => '正在根據您的收聽記錄建立您的時間軸...'; - - @override - String get official => '官方'; - - @override - String author_name(Object author) { - return '作者:$author'; - } - - @override - String get third_party => '第三方'; - - @override - String get plugin_requires_authentication => '此外掛程式需要驗證'; - - @override - String get update_available => '有可用的更新'; - - @override - String get supports_scrobbling => '支援 Scrobbling'; - - @override - String get plugin_scrobbling_info => '此外掛程式會 Scrobble 您的音樂以產生您的收聽記錄。'; - - @override - String get default_metadata_source => '預設中繼資料來源'; - - @override - String get set_default_metadata_source => '設定預設中繼資料來源'; - - @override - String get default_audio_source => '預設音訊來源'; - - @override - String get set_default_audio_source => '設定預設音訊來源'; - - @override - String get set_default => '設為預設'; - - @override - String get support => '支援'; - - @override - String get support_plugin_development => '支援外掛程式開發'; - - @override - String can_access_name_api(Object name) { - return '- 可以存取 **$name** API'; - } - - @override - String get do_you_want_to_install_this_plugin => '您想安裝此外掛程式嗎?'; - - @override - String get third_party_plugin_warning => '此外掛程式來自第三方儲存庫。請在安裝前確認您信任該來源。'; - - @override - String get author => '作者'; - - @override - String get this_plugin_can_do_following => '此外掛程式可以執行以下操作'; - - @override - String get install => '安裝'; - - @override - String get install_a_metadata_provider => '安裝中繼資料供應商'; - - @override - String get no_tracks_playing => '目前沒有正在播放的曲目'; - - @override - String get synced_lyrics_not_available => '此歌曲沒有同步歌詞。請改用'; - - @override - String get plain_lyrics => '純歌詞'; - - @override - String get tab_instead => '分頁。'; - - @override - String get disclaimer => '免責聲明'; - - @override - String get third_party_plugin_dmca_notice => - 'Spotube 團隊對任何「第三方」外掛程式不負任何責任(包括法律責任)。\n請自行承擔使用風險。如有任何錯誤/問題,請向該外掛程式的儲存庫回報。\n\n若有任何「第三方」外掛程式違反任何服務/法律實體的服務條款/DMCA,請向「第三方」外掛程式作者或託管平台(如 GitHub/Codeberg)要求採取行動。以上列出的(標記為「第三方」)外掛程式均為公開/社群維護的外掛程式。我們沒有對其進行審核,因此無法對其採取任何行動。\n\n'; - - @override - String get input_does_not_match_format => '輸入不符合所需格式'; - - @override - String get plugins => '外掛程式'; - - @override - String get paste_plugin_download_url => - '貼上下載網址、GitHub/Codeberg 儲存庫網址或 .smplug 檔案的直接連結'; - - @override - String get download_and_install_plugin_from_url => '從網址下載並安裝外掛程式'; - - @override - String failed_to_add_plugin_error(Object error) { - return '新增外掛程式失敗:$error'; - } - - @override - String get upload_plugin_from_file => '從檔案上傳外掛程式'; - - @override - String get installed => '已安裝'; - - @override - String get available_plugins => '可用的外掛程式'; - - @override - String get configure_plugins => '配置您自己的中繼資料提供者和音訊來源外掛程式'; - - @override - String get audio_scrobblers => '音訊 Scrobblers'; - - @override - String get scrobbling => 'Scrobbling'; - - @override - String get source => '來源:'; - - @override - String get uncompressed => '未壓縮'; - - @override - String get dab_music_source_description => - '適合音響發燒友。提供高品質/無損音訊串流。精確的 ISRC 曲目比對。'; -} diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart deleted file mode 100644 index d0aeccc4..00000000 --- a/lib/l10n/l10n.dart +++ /dev/null @@ -1,57 +0,0 @@ -/// credits: -/// -/// Kingkor Roy Tirtho => English, Bengali -/// ChatGPT (GPT 3.5) XD => Hindi, French -/// maboroshin@github => Japanese -/// iceyear@github => Simplified Chinese -/// TexturedPolak@github => Polish -/// yuri-val@github => Ukrainian -/// energywave@github, ncvescera@github, OpenCode@github => Italian -/// mikropsoft@github => Turkish -/// Stephan-P@github, SecularSteve@github => Dutch -/// doannc2212@github => Vietnamese -/// sappho192@github => Korean -/// watchakorn-18k@github => Thai -/// llama3, vishnumur777@github => Tamil -/// Microsoft Copilot, Tutislav@github => Czech -/// 510208@github => Traditional Chinese - -library l10n; - -import 'package:shadcn_flutter/shadcn_flutter.dart'; -export 'package:spotube/l10n/generated/app_localizations.dart'; - -class L10n { - static final all = [ - const Locale('en'), - const Locale('ar', 'SA'), - const Locale('bn', 'BD'), - const Locale('ca', 'AD'), - const Locale('cs', 'CZ'), - const Locale('de', 'GE'), - const Locale('es', 'ES'), - const Locale('fa', 'IR'), - const Locale('fi', 'FI'), - const Locale('fr', 'FR'), - const Locale('ne', 'NP'), - const Locale('hi', 'IN'), - const Locale('id', 'ID'), - const Locale('it', 'IT'), - const Locale('ja', 'JP'), - const Locale('ka', 'GE'), - const Locale('ko', 'KR'), - const Locale('nl', 'NL'), - const Locale('pl', 'PL'), - const Locale('pt', 'PT'), - const Locale('ru', 'RU'), - const Locale('tl', 'PH'), - const Locale('uk', 'UA'), - const Locale('th', 'TH'), - const Locale('ta', 'IN'), - const Locale('tr', 'TR'), - const Locale('zh', 'CN'), - const Locale('zh', 'TW'), - const Locale('vi', 'VN'), - const Locale('eu', 'ES'), - ]; -} diff --git a/lib/main.dart b/lib/main.dart deleted file mode 100644 index ecf7148d..00000000 --- a/lib/main.dart +++ /dev/null @@ -1,317 +0,0 @@ -import 'dart:async'; -import 'dart:ui'; -import 'dart:io'; - -import 'package:desktop_webview_window/desktop_webview_window.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart'; -import 'package:flutter_discord_rpc/flutter_discord_rpc.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; - -import 'package:home_widget/home_widget.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:local_notifier/local_notifier.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:metadata_god/metadata_god.dart'; -import 'package:smtc_windows/smtc_windows.dart'; -import 'package:spotube/collections/env.dart'; -import 'package:spotube/collections/http-override.dart'; -import 'package:spotube/collections/intents.dart'; -import 'package:spotube/collections/routes.dart'; -import 'package:spotube/hooks/configurators/use_close_behavior.dart'; -import 'package:spotube/hooks/configurators/use_deep_linking.dart'; -import 'package:spotube/hooks/configurators/use_disable_battery_optimizations.dart'; -import 'package:spotube/hooks/configurators/use_fix_window_stretching.dart'; -import 'package:spotube/hooks/configurators/use_get_storage_perms.dart'; -import 'package:spotube/hooks/configurators/use_has_touch.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; -import 'package:spotube/provider/audio_player/audio_player_streams.dart'; -import 'package:spotube/provider/database/database.dart'; -import 'package:spotube/provider/glance/glance.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/updater/update_checker.dart'; -import 'package:spotube/provider/server/bonsoir.dart'; -import 'package:spotube/provider/server/server.dart'; -import 'package:spotube/provider/tray_manager/tray_manager.dart'; -import 'package:spotube/l10n/l10n.dart'; -import 'package:spotube/provider/connect/clients.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/cli/cli.dart'; -import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; -import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:spotube/services/wm_tools/wm_tools.dart'; -import 'package:spotube/utils/migrations/sandbox.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:flutter_native_splash/flutter_native_splash.dart'; -import 'package:flutter_displaymode/flutter_displaymode.dart'; -import 'package:timezone/data/latest.dart' as tz; -import 'package:window_manager/window_manager.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:yt_dlp_dart/yt_dlp_dart.dart'; -import 'package:flutter_new_pipe_extractor/flutter_new_pipe_extractor.dart'; - -Future main(List rawArgs) async { - if (rawArgs.contains("web_view_title_bar")) { - WidgetsFlutterBinding.ensureInitialized(); - if (runWebViewTitleBarWidget(rawArgs)) { - return; - } - } - final arguments = await startCLI(rawArgs); - AppLogger.initialize(arguments["verbose"]); - - AppLogger.runZoned(() async { - final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); - - HttpOverrides.global = BadCertificateAllowlistOverrides(); - - // await registerWindowsScheme("spotify"); - - tz.initializeTimeZones(); - - FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); - - MediaKit.ensureInitialized(); - - await migrateMacOsFromSandboxToNoSandbox(); - - // force High Refresh Rate on some Android devices (like One Plus) - if (kIsAndroid) { - await FlutterDisplayMode.setHighRefreshRate(); - } - if (kIsAndroid || kIsDesktop) { - await NewPipeExtractor.init(); - } - - if (!kIsWeb) { - MetadataGod.initialize(); - } - - await KVStoreService.initialize(); - - if (kIsDesktop) { - await windowManager.setPreventClose(true); - await YtDlp.instance - .setBinaryLocation( - KVStoreService.getYoutubeEnginePath(YoutubeClientEngine.ytDlp) ?? - "yt-dlp${kIsWindows ? '.exe' : ''}", - ) - .catchError((e, stack) => null); - await FlutterDiscordRPC.initialize(Env.discordAppId); - } - - if (kIsWindows) { - await SMTCWindows.initialize(); - } - - await EncryptedKvStoreService.initialize(); - - final database = AppDatabase(); - - if (kIsDesktop) { - await localNotifier.setup(appName: "Spotube"); - await WindowManagerTools.initialize(); - } - - if (kIsIOS) { - HomeWidget.setAppGroupId("group.spotube_home_player_widget"); - } - - runApp( - ProviderScope( - overrides: [ - databaseProvider.overrideWith((ref) => database), - ], - observers: const [ - AppLoggerProviderObserver(), - ], - child: const Spotube(), - ), - ); - }); -} - -class Spotube extends HookConsumerWidget { - const Spotube({super.key}); - - @override - Widget build(BuildContext context, ref) { - final themeMode = - ref.watch(userPreferencesProvider.select((s) => s.themeMode)); - final locale = ref.watch(userPreferencesProvider.select((s) => s.locale)); - final accentMaterialColor = - ref.watch(userPreferencesProvider.select((s) => s.accentColorScheme)); - final router = useMemoized(() => AppRouter(ref), []); - final hasTouchSupport = useHasTouch(); - - ref.listen(audioPlayerStreamListenersProvider, (_, __) {}); - ref.listen(bonsoirProvider, (_, __) {}); - ref.listen(connectClientsProvider, (_, __) {}); - ref.listen(serverProvider, (_, __) {}); - ref.listen(trayManagerProvider, (_, __) {}); - ref.listen(metadataPluginsProvider, (_, __) {}); - ref.listen(metadataPluginProvider, (_, __) {}); - ref.listen(audioSourcePluginProvider, (_, __) {}); - ref.listen(metadataPluginUpdateCheckerProvider, (_, __) {}); - ref.listen(audioSourcePluginUpdateCheckerProvider, (_, __) {}); - - useFixWindowStretching(); - useDisableBatteryOptimizations(); - useDeepLinking(ref, router); - useCloseBehavior(ref); - useGetStoragePermissions(ref); - - useEffect(() { - FlutterNativeSplash.remove(); - - if (kIsMobile) { - HomeWidget.registerInteractivityCallback(glanceBackgroundCallback); - } - - return () { - /// For enabling hot reload for audio player - if (!kDebugMode) return; - audioPlayer.dispose(); - }; - }, []); - - return ShadcnApp.router( - supportedLocales: L10n.all, - locale: locale.languageCode == "system" ? null : locale, - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - routerConfig: router.config(), - debugShowCheckedModeBanner: false, - title: 'Spotube', - builder: (context, child) { - child = ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: hasTouchSupport - ? { - PointerDeviceKind.touch, - PointerDeviceKind.stylus, - PointerDeviceKind.invertedStylus, - } - : null, - ), - child: child!, - ); - - if (kIsLinux) { - child = DragToResizeArea( - resizeEdgeSize: 2.5, - child: child, - ); - } - - return child; - }, - scaling: const AdaptiveScaling(1), - theme: ThemeData( - radius: .5, - iconTheme: const IconThemeProperties(), - colorScheme: - colorSchemeMap[accentMaterialColor.name]?.call(ThemeMode.light) ?? - LegacyColorSchemes.lightSlate(), - surfaceOpacity: .8, - surfaceBlur: 10, - ), - darkTheme: ThemeData( - radius: .5, - iconTheme: const IconThemeProperties(), - colorScheme: - colorSchemeMap[accentMaterialColor.name]?.call(ThemeMode.dark) ?? - LegacyColorSchemes.darkSlate(), - surfaceOpacity: .8, - surfaceBlur: 10, - ), - materialTheme: material.ThemeData( - brightness: switch (themeMode) { - ThemeMode.system => MediaQuery.platformBrightnessOf(context), - ThemeMode.light => Brightness.light, - ThemeMode.dark => Brightness.dark, - }, - splashFactory: material.NoSplash.splashFactory, - appBarTheme: const material.AppBarTheme( - surfaceTintColor: Colors.transparent, - scrolledUnderElevation: 0, - shadowColor: Colors.transparent, - elevation: 0, - ), - ), - themeMode: themeMode, - shortcuts: { - ...WidgetsApp.defaultShortcuts.map((key, value) { - return MapEntry( - LogicalKeySet.fromSet(key.triggers?.toSet() ?? {}), - value, - ); - }), - LogicalKeySet(LogicalKeyboardKey.space): PlayPauseIntent(ref), - LogicalKeySet(LogicalKeyboardKey.comma, LogicalKeyboardKey.control): - NavigationIntent(router, "/settings"), - LogicalKeySet( - LogicalKeyboardKey.digit1, - LogicalKeyboardKey.control, - LogicalKeyboardKey.shift, - ): HomeTabIntent(router, tab: HomeTabs.browse), - LogicalKeySet( - LogicalKeyboardKey.digit2, - LogicalKeyboardKey.control, - LogicalKeyboardKey.shift, - ): HomeTabIntent(router, tab: HomeTabs.search), - LogicalKeySet( - LogicalKeyboardKey.digit3, - LogicalKeyboardKey.control, - LogicalKeyboardKey.shift, - ): HomeTabIntent(router, tab: HomeTabs.lyrics), - LogicalKeySet( - LogicalKeyboardKey.digit4, - LogicalKeyboardKey.control, - LogicalKeyboardKey.shift, - ): HomeTabIntent(router, tab: HomeTabs.userPlaylists), - LogicalKeySet( - LogicalKeyboardKey.digit5, - LogicalKeyboardKey.control, - LogicalKeyboardKey.shift, - ): HomeTabIntent(router, tab: HomeTabs.userArtists), - LogicalKeySet( - LogicalKeyboardKey.digit6, - LogicalKeyboardKey.control, - LogicalKeyboardKey.shift, - ): HomeTabIntent(router, tab: HomeTabs.userAlbums), - LogicalKeySet( - LogicalKeyboardKey.digit7, - LogicalKeyboardKey.control, - LogicalKeyboardKey.shift, - ): HomeTabIntent(router, tab: HomeTabs.userLocalLibrary), - LogicalKeySet( - LogicalKeyboardKey.digit8, - LogicalKeyboardKey.control, - LogicalKeyboardKey.shift, - ): HomeTabIntent(router, tab: HomeTabs.userDownloads), - LogicalKeySet( - LogicalKeyboardKey.keyW, - LogicalKeyboardKey.control, - LogicalKeyboardKey.shift, - ): CloseAppIntent(), - }, - actions: { - ...WidgetsApp.defaultActions, - PlayPauseIntent: PlayPauseAction(), - NavigationIntent: NavigationAction(), - HomeTabIntent: HomeTabAction(), - CloseAppIntent: CloseAppAction(), - }, - ); - } -} diff --git a/lib/models/connect/connect.dart b/lib/models/connect/connect.dart deleted file mode 100644 index 11370dcb..00000000 --- a/lib/models/connect/connect.dart +++ /dev/null @@ -1,15 +0,0 @@ -library connect; - -import 'dart:async'; -import 'dart:convert'; - -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:media_kit/media_kit.dart' hide Track; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/state.dart'; - -part 'connect.freezed.dart'; -part 'connect.g.dart'; - -part 'ws_event.dart'; -part 'load.dart'; diff --git a/lib/models/connect/connect.freezed.dart b/lib/models/connect/connect.freezed.dart deleted file mode 100644 index 157d0911..00000000 --- a/lib/models/connect/connect.freezed.dart +++ /dev/null @@ -1,715 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'connect.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -WebSocketLoadEventData _$WebSocketLoadEventDataFromJson( - Map json) { - switch (json['runtimeType']) { - case 'playlist': - return WebSocketLoadEventDataPlaylist.fromJson(json); - case 'album': - return WebSocketLoadEventDataAlbum.fromJson(json); - - default: - throw CheckedFromJsonException( - json, - 'runtimeType', - 'WebSocketLoadEventData', - 'Invalid union type "${json['runtimeType']}"!'); - } -} - -/// @nodoc -mixin _$WebSocketLoadEventData { - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List get tracks => throw _privateConstructorUsedError; - Object? get collection => throw _privateConstructorUsedError; - int? get initialIndex => throw _privateConstructorUsedError; - @optionalTypeArgs - TResult when({ - required TResult Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimplePlaylistObject? collection, - int? initialIndex) - playlist, - required TResult Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimpleAlbumObject? collection, - int? initialIndex) - album, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimplePlaylistObject? collection, - int? initialIndex)? - playlist, - TResult? Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimpleAlbumObject? collection, - int? initialIndex)? - album, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeWhen({ - TResult Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimplePlaylistObject? collection, - int? initialIndex)? - playlist, - TResult Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimpleAlbumObject? collection, - int? initialIndex)? - album, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult map({ - required TResult Function(WebSocketLoadEventDataPlaylist value) playlist, - required TResult Function(WebSocketLoadEventDataAlbum value) album, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist, - TResult? Function(WebSocketLoadEventDataAlbum value)? album, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeMap({ - TResult Function(WebSocketLoadEventDataPlaylist value)? playlist, - TResult Function(WebSocketLoadEventDataAlbum value)? album, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - - /// Serializes this WebSocketLoadEventData to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of WebSocketLoadEventData - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $WebSocketLoadEventDataCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $WebSocketLoadEventDataCopyWith<$Res> { - factory $WebSocketLoadEventDataCopyWith(WebSocketLoadEventData value, - $Res Function(WebSocketLoadEventData) then) = - _$WebSocketLoadEventDataCopyWithImpl<$Res, WebSocketLoadEventData>; - @useResult - $Res call( - {@Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - int? initialIndex}); -} - -/// @nodoc -class _$WebSocketLoadEventDataCopyWithImpl<$Res, - $Val extends WebSocketLoadEventData> - implements $WebSocketLoadEventDataCopyWith<$Res> { - _$WebSocketLoadEventDataCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of WebSocketLoadEventData - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? tracks = null, - Object? initialIndex = freezed, - }) { - return _then(_value.copyWith( - tracks: null == tracks - ? _value.tracks - : tracks // ignore: cast_nullable_to_non_nullable - as List, - initialIndex: freezed == initialIndex - ? _value.initialIndex - : initialIndex // ignore: cast_nullable_to_non_nullable - as int?, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$WebSocketLoadEventDataPlaylistImplCopyWith<$Res> - implements $WebSocketLoadEventDataCopyWith<$Res> { - factory _$$WebSocketLoadEventDataPlaylistImplCopyWith( - _$WebSocketLoadEventDataPlaylistImpl value, - $Res Function(_$WebSocketLoadEventDataPlaylistImpl) then) = - __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {@Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimplePlaylistObject? collection, - int? initialIndex}); - - $SpotubeSimplePlaylistObjectCopyWith<$Res>? get collection; -} - -/// @nodoc -class __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res> - extends _$WebSocketLoadEventDataCopyWithImpl<$Res, - _$WebSocketLoadEventDataPlaylistImpl> - implements _$$WebSocketLoadEventDataPlaylistImplCopyWith<$Res> { - __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl( - _$WebSocketLoadEventDataPlaylistImpl _value, - $Res Function(_$WebSocketLoadEventDataPlaylistImpl) _then) - : super(_value, _then); - - /// Create a copy of WebSocketLoadEventData - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? tracks = null, - Object? collection = freezed, - Object? initialIndex = freezed, - }) { - return _then(_$WebSocketLoadEventDataPlaylistImpl( - tracks: null == tracks - ? _value._tracks - : tracks // ignore: cast_nullable_to_non_nullable - as List, - collection: freezed == collection - ? _value.collection - : collection // ignore: cast_nullable_to_non_nullable - as SpotubeSimplePlaylistObject?, - initialIndex: freezed == initialIndex - ? _value.initialIndex - : initialIndex // ignore: cast_nullable_to_non_nullable - as int?, - )); - } - - /// Create a copy of WebSocketLoadEventData - /// with the given fields replaced by the non-null parameter values. - @override - @pragma('vm:prefer-inline') - $SpotubeSimplePlaylistObjectCopyWith<$Res>? get collection { - if (_value.collection == null) { - return null; - } - - return $SpotubeSimplePlaylistObjectCopyWith<$Res>(_value.collection!, - (value) { - return _then(_value.copyWith(collection: value)); - }); - } -} - -/// @nodoc -@JsonSerializable() -class _$WebSocketLoadEventDataPlaylistImpl - extends WebSocketLoadEventDataPlaylist { - _$WebSocketLoadEventDataPlaylistImpl( - {@Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - required final List tracks, - this.collection, - this.initialIndex, - final String? $type}) - : _tracks = tracks, - $type = $type ?? 'playlist', - super._(); - - factory _$WebSocketLoadEventDataPlaylistImpl.fromJson( - Map json) => - _$$WebSocketLoadEventDataPlaylistImplFromJson(json); - - final List _tracks; - @override - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List get tracks { - if (_tracks is EqualUnmodifiableListView) return _tracks; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_tracks); - } - - @override - final SpotubeSimplePlaylistObject? collection; - @override - final int? initialIndex; - - @JsonKey(name: 'runtimeType') - final String $type; - - @override - String toString() { - return 'WebSocketLoadEventData.playlist(tracks: $tracks, collection: $collection, initialIndex: $initialIndex)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$WebSocketLoadEventDataPlaylistImpl && - const DeepCollectionEquality().equals(other._tracks, _tracks) && - (identical(other.collection, collection) || - other.collection == collection) && - (identical(other.initialIndex, initialIndex) || - other.initialIndex == initialIndex)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, - const DeepCollectionEquality().hash(_tracks), collection, initialIndex); - - /// Create a copy of WebSocketLoadEventData - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$WebSocketLoadEventDataPlaylistImplCopyWith< - _$WebSocketLoadEventDataPlaylistImpl> - get copyWith => __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl< - _$WebSocketLoadEventDataPlaylistImpl>(this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimplePlaylistObject? collection, - int? initialIndex) - playlist, - required TResult Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimpleAlbumObject? collection, - int? initialIndex) - album, - }) { - return playlist(tracks, collection, initialIndex); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimplePlaylistObject? collection, - int? initialIndex)? - playlist, - TResult? Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimpleAlbumObject? collection, - int? initialIndex)? - album, - }) { - return playlist?.call(tracks, collection, initialIndex); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimplePlaylistObject? collection, - int? initialIndex)? - playlist, - TResult Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimpleAlbumObject? collection, - int? initialIndex)? - album, - required TResult orElse(), - }) { - if (playlist != null) { - return playlist(tracks, collection, initialIndex); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(WebSocketLoadEventDataPlaylist value) playlist, - required TResult Function(WebSocketLoadEventDataAlbum value) album, - }) { - return playlist(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist, - TResult? Function(WebSocketLoadEventDataAlbum value)? album, - }) { - return playlist?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(WebSocketLoadEventDataPlaylist value)? playlist, - TResult Function(WebSocketLoadEventDataAlbum value)? album, - required TResult orElse(), - }) { - if (playlist != null) { - return playlist(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$WebSocketLoadEventDataPlaylistImplToJson( - this, - ); - } -} - -abstract class WebSocketLoadEventDataPlaylist extends WebSocketLoadEventData { - factory WebSocketLoadEventDataPlaylist( - {@Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - required final List tracks, - final SpotubeSimplePlaylistObject? collection, - final int? initialIndex}) = _$WebSocketLoadEventDataPlaylistImpl; - WebSocketLoadEventDataPlaylist._() : super._(); - - factory WebSocketLoadEventDataPlaylist.fromJson(Map json) = - _$WebSocketLoadEventDataPlaylistImpl.fromJson; - - @override - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List get tracks; - @override - SpotubeSimplePlaylistObject? get collection; - @override - int? get initialIndex; - - /// Create a copy of WebSocketLoadEventData - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$WebSocketLoadEventDataPlaylistImplCopyWith< - _$WebSocketLoadEventDataPlaylistImpl> - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class _$$WebSocketLoadEventDataAlbumImplCopyWith<$Res> - implements $WebSocketLoadEventDataCopyWith<$Res> { - factory _$$WebSocketLoadEventDataAlbumImplCopyWith( - _$WebSocketLoadEventDataAlbumImpl value, - $Res Function(_$WebSocketLoadEventDataAlbumImpl) then) = - __$$WebSocketLoadEventDataAlbumImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {@Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimpleAlbumObject? collection, - int? initialIndex}); - - $SpotubeSimpleAlbumObjectCopyWith<$Res>? get collection; -} - -/// @nodoc -class __$$WebSocketLoadEventDataAlbumImplCopyWithImpl<$Res> - extends _$WebSocketLoadEventDataCopyWithImpl<$Res, - _$WebSocketLoadEventDataAlbumImpl> - implements _$$WebSocketLoadEventDataAlbumImplCopyWith<$Res> { - __$$WebSocketLoadEventDataAlbumImplCopyWithImpl( - _$WebSocketLoadEventDataAlbumImpl _value, - $Res Function(_$WebSocketLoadEventDataAlbumImpl) _then) - : super(_value, _then); - - /// Create a copy of WebSocketLoadEventData - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? tracks = null, - Object? collection = freezed, - Object? initialIndex = freezed, - }) { - return _then(_$WebSocketLoadEventDataAlbumImpl( - tracks: null == tracks - ? _value._tracks - : tracks // ignore: cast_nullable_to_non_nullable - as List, - collection: freezed == collection - ? _value.collection - : collection // ignore: cast_nullable_to_non_nullable - as SpotubeSimpleAlbumObject?, - initialIndex: freezed == initialIndex - ? _value.initialIndex - : initialIndex // ignore: cast_nullable_to_non_nullable - as int?, - )); - } - - /// Create a copy of WebSocketLoadEventData - /// with the given fields replaced by the non-null parameter values. - @override - @pragma('vm:prefer-inline') - $SpotubeSimpleAlbumObjectCopyWith<$Res>? get collection { - if (_value.collection == null) { - return null; - } - - return $SpotubeSimpleAlbumObjectCopyWith<$Res>(_value.collection!, (value) { - return _then(_value.copyWith(collection: value)); - }); - } -} - -/// @nodoc -@JsonSerializable() -class _$WebSocketLoadEventDataAlbumImpl extends WebSocketLoadEventDataAlbum { - _$WebSocketLoadEventDataAlbumImpl( - {@Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - required final List tracks, - this.collection, - this.initialIndex, - final String? $type}) - : _tracks = tracks, - $type = $type ?? 'album', - super._(); - - factory _$WebSocketLoadEventDataAlbumImpl.fromJson( - Map json) => - _$$WebSocketLoadEventDataAlbumImplFromJson(json); - - final List _tracks; - @override - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List get tracks { - if (_tracks is EqualUnmodifiableListView) return _tracks; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_tracks); - } - - @override - final SpotubeSimpleAlbumObject? collection; - @override - final int? initialIndex; - - @JsonKey(name: 'runtimeType') - final String $type; - - @override - String toString() { - return 'WebSocketLoadEventData.album(tracks: $tracks, collection: $collection, initialIndex: $initialIndex)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$WebSocketLoadEventDataAlbumImpl && - const DeepCollectionEquality().equals(other._tracks, _tracks) && - (identical(other.collection, collection) || - other.collection == collection) && - (identical(other.initialIndex, initialIndex) || - other.initialIndex == initialIndex)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, - const DeepCollectionEquality().hash(_tracks), collection, initialIndex); - - /// Create a copy of WebSocketLoadEventData - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$WebSocketLoadEventDataAlbumImplCopyWith<_$WebSocketLoadEventDataAlbumImpl> - get copyWith => __$$WebSocketLoadEventDataAlbumImplCopyWithImpl< - _$WebSocketLoadEventDataAlbumImpl>(this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimplePlaylistObject? collection, - int? initialIndex) - playlist, - required TResult Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimpleAlbumObject? collection, - int? initialIndex) - album, - }) { - return album(tracks, collection, initialIndex); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimplePlaylistObject? collection, - int? initialIndex)? - playlist, - TResult? Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimpleAlbumObject? collection, - int? initialIndex)? - album, - }) { - return album?.call(tracks, collection, initialIndex); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimplePlaylistObject? collection, - int? initialIndex)? - playlist, - TResult Function( - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List tracks, - SpotubeSimpleAlbumObject? collection, - int? initialIndex)? - album, - required TResult orElse(), - }) { - if (album != null) { - return album(tracks, collection, initialIndex); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(WebSocketLoadEventDataPlaylist value) playlist, - required TResult Function(WebSocketLoadEventDataAlbum value) album, - }) { - return album(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist, - TResult? Function(WebSocketLoadEventDataAlbum value)? album, - }) { - return album?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(WebSocketLoadEventDataPlaylist value)? playlist, - TResult Function(WebSocketLoadEventDataAlbum value)? album, - required TResult orElse(), - }) { - if (album != null) { - return album(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$WebSocketLoadEventDataAlbumImplToJson( - this, - ); - } -} - -abstract class WebSocketLoadEventDataAlbum extends WebSocketLoadEventData { - factory WebSocketLoadEventDataAlbum( - {@Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - required final List tracks, - final SpotubeSimpleAlbumObject? collection, - final int? initialIndex}) = _$WebSocketLoadEventDataAlbumImpl; - WebSocketLoadEventDataAlbum._() : super._(); - - factory WebSocketLoadEventDataAlbum.fromJson(Map json) = - _$WebSocketLoadEventDataAlbumImpl.fromJson; - - @override - @Assert("tracks is List", - "tracks must be a list of SpotubeFullTrackObject") - List get tracks; - @override - SpotubeSimpleAlbumObject? get collection; - @override - int? get initialIndex; - - /// Create a copy of WebSocketLoadEventData - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$WebSocketLoadEventDataAlbumImplCopyWith<_$WebSocketLoadEventDataAlbumImpl> - get copyWith => throw _privateConstructorUsedError; -} diff --git a/lib/models/connect/connect.g.dart b/lib/models/connect/connect.g.dart deleted file mode 100644 index 2da8f9b0..00000000 --- a/lib/models/connect/connect.g.dart +++ /dev/null @@ -1,55 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'connect.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$WebSocketLoadEventDataPlaylistImpl - _$$WebSocketLoadEventDataPlaylistImplFromJson(Map json) => - _$WebSocketLoadEventDataPlaylistImpl( - tracks: (json['tracks'] as List) - .map((e) => SpotubeTrackObject.fromJson( - Map.from(e as Map))) - .toList(), - collection: json['collection'] == null - ? null - : SpotubeSimplePlaylistObject.fromJson( - Map.from(json['collection'] as Map)), - initialIndex: (json['initialIndex'] as num?)?.toInt(), - $type: json['runtimeType'] as String?, - ); - -Map _$$WebSocketLoadEventDataPlaylistImplToJson( - _$WebSocketLoadEventDataPlaylistImpl instance) => - { - 'tracks': instance.tracks.map((e) => e.toJson()).toList(), - 'collection': instance.collection?.toJson(), - 'initialIndex': instance.initialIndex, - 'runtimeType': instance.$type, - }; - -_$WebSocketLoadEventDataAlbumImpl _$$WebSocketLoadEventDataAlbumImplFromJson( - Map json) => - _$WebSocketLoadEventDataAlbumImpl( - tracks: (json['tracks'] as List) - .map((e) => - SpotubeTrackObject.fromJson(Map.from(e as Map))) - .toList(), - collection: json['collection'] == null - ? null - : SpotubeSimpleAlbumObject.fromJson( - Map.from(json['collection'] as Map)), - initialIndex: (json['initialIndex'] as num?)?.toInt(), - $type: json['runtimeType'] as String?, - ); - -Map _$$WebSocketLoadEventDataAlbumImplToJson( - _$WebSocketLoadEventDataAlbumImpl instance) => - { - 'tracks': instance.tracks.map((e) => e.toJson()).toList(), - 'collection': instance.collection?.toJson(), - 'initialIndex': instance.initialIndex, - 'runtimeType': instance.$type, - }; diff --git a/lib/models/connect/load.dart b/lib/models/connect/load.dart deleted file mode 100644 index d61e0f1e..00000000 --- a/lib/models/connect/load.dart +++ /dev/null @@ -1,44 +0,0 @@ -part of 'connect.dart'; - -@freezed -class WebSocketLoadEventData with _$WebSocketLoadEventData { - const WebSocketLoadEventData._(); - - factory WebSocketLoadEventData.playlist({ - @Assert( - "tracks is List", - "tracks must be a list of SpotubeFullTrackObject", - ) - required List tracks, - SpotubeSimplePlaylistObject? collection, - int? initialIndex, - }) = WebSocketLoadEventDataPlaylist; - - factory WebSocketLoadEventData.album({ - @Assert( - "tracks is List", - "tracks must be a list of SpotubeFullTrackObject", - ) - required List tracks, - SpotubeSimpleAlbumObject? collection, - int? initialIndex, - }) = WebSocketLoadEventDataAlbum; - - factory WebSocketLoadEventData.fromJson(Map json) => - _$WebSocketLoadEventDataFromJson(json); - - String? get collectionId => when( - playlist: (tracks, collection, _) => collection?.id, - album: (tracks, collection, _) => collection?.id, - ); -} - -class WebSocketLoadEvent extends WebSocketEvent { - WebSocketLoadEvent(WebSocketLoadEventData data) : super(WsEvent.load, data); - - factory WebSocketLoadEvent.fromJson(Map json) { - return WebSocketLoadEvent( - WebSocketLoadEventData.fromJson(json['data'] as Map), - ); - } -} diff --git a/lib/models/connect/ws_event.dart b/lib/models/connect/ws_event.dart deleted file mode 100644 index 7867f686..00000000 --- a/lib/models/connect/ws_event.dart +++ /dev/null @@ -1,381 +0,0 @@ -part of 'connect.dart'; - -enum WsEvent { - error, - volume, - removeTrack, - addTrack, - reorder, - shuffle, - loop, - seek, - duration, - queue, - position, - playing, - resume, - pause, - load, - next, - previous, - jump, - stop; - - static WsEvent fromString(String value) { - return WsEvent.values.firstWhere((e) => e.name == value); - } -} - -typedef EventCallback = FutureOr Function(T event); - -class WebSocketEvent { - final WsEvent type; - final T data; - - WebSocketEvent(this.type, this.data); - - factory WebSocketEvent.fromJson( - Map json, - T Function(dynamic) fromJson, - ) { - return WebSocketEvent( - WsEvent.fromString(json["type"]), - fromJson(json["data"]), - ); - } - - String toJson() { - return jsonEncode({ - "type": type.name, - "data": data, - }); - } - - Future onPosition( - EventCallback callback, - ) async { - if (type == WsEvent.position) { - await callback(WebSocketPositionEvent.fromJson({"data": data})); - } - } - - Future onPlaying( - EventCallback callback, - ) async { - if (type == WsEvent.playing) { - await callback(WebSocketPlayingEvent(data as bool)); - } - } - - Future onResume( - EventCallback callback, - ) async { - if (type == WsEvent.resume) { - await callback(WebSocketResumeEvent()); - } - } - - Future onPause( - EventCallback callback, - ) async { - if (type == WsEvent.pause) { - await callback(WebSocketPauseEvent()); - } - } - - Future onStop( - EventCallback callback, - ) async { - if (type == WsEvent.stop) { - await callback(WebSocketStopEvent()); - } - } - - Future onLoad( - EventCallback callback, - ) async { - if (type == WsEvent.load) { - await callback( - WebSocketLoadEvent( - WebSocketLoadEventData.fromJson(data as Map), - ), - ); - } - } - - Future onNext( - EventCallback callback, - ) async { - if (type == WsEvent.next) { - await callback(WebSocketNextEvent()); - } - } - - Future onPrevious( - EventCallback callback, - ) async { - if (type == WsEvent.previous) { - await callback(WebSocketPreviousEvent()); - } - } - - Future onJump( - EventCallback callback, - ) async { - if (type == WsEvent.jump) { - await callback(WebSocketJumpEvent(data as int)); - } - } - - Future onError( - EventCallback callback, - ) async { - if (type == WsEvent.error) { - await callback(WebSocketErrorEvent(data as String)); - } - } - - Future onQueue( - EventCallback callback, - ) async { - if (type == WsEvent.queue) { - await callback( - WebSocketQueueEvent.fromJson(data as Map), - ); - } - } - - Future onDuration( - EventCallback callback, - ) async { - if (type == WsEvent.duration) { - await callback( - WebSocketDurationEvent( - Duration(seconds: data as int), - ), - ); - } - } - - Future onSeek( - EventCallback callback, - ) async { - if (type == WsEvent.seek) { - await callback( - WebSocketSeekEvent( - Duration(seconds: data as int), - ), - ); - } - } - - Future onShuffle( - EventCallback callback, - ) async { - if (type == WsEvent.shuffle) { - await callback(WebSocketShuffleEvent(data as bool)); - } - } - - Future onLoop( - EventCallback callback, - ) async { - if (type == WsEvent.loop) { - await callback( - WebSocketLoopEvent( - PlaylistMode.values.firstWhere((e) => e.name == data as String), - ), - ); - } - } - - Future onRemoveTrack( - EventCallback callback, - ) async { - if (type == WsEvent.removeTrack) { - await callback(WebSocketRemoveTrackEvent(data as String)); - } - } - - Future onAddTrack( - EventCallback callback, - ) async { - if (type == WsEvent.addTrack) { - await callback( - WebSocketAddTrackEvent.fromJson(data as Map)); - } - } - - Future onReorder( - EventCallback callback, - ) async { - if (type == WsEvent.reorder) { - await callback( - WebSocketReorderEvent.fromJson(data as Map)); - } - } - - Future onVolume( - EventCallback callback, - ) async { - if (type == WsEvent.volume) { - await callback(WebSocketVolumeEvent(data as double)); - } - } -} - -class WebSocketLoopEvent extends WebSocketEvent { - WebSocketLoopEvent(PlaylistMode data) : super(WsEvent.loop, data); - - WebSocketLoopEvent.fromJson(Map json) - : super( - WsEvent.loop, - PlaylistMode.values.firstWhere( - (e) => e.name == json["data"] as String, - ), - ); - - @override - String toJson() { - return jsonEncode({ - "type": type.name, - "data": data.name, - }); - } -} - -class WebSocketPositionEvent extends WebSocketEvent { - WebSocketPositionEvent(Duration data) : super(WsEvent.position, data); - - WebSocketPositionEvent.fromJson(Map json) - : super(WsEvent.position, Duration(seconds: json["data"] as int)); - - @override - String toJson() { - return jsonEncode({ - "type": type.name, - "data": data.inSeconds, - }); - } -} - -class WebSocketDurationEvent extends WebSocketEvent { - WebSocketDurationEvent(Duration data) : super(WsEvent.duration, data); - - WebSocketDurationEvent.fromJson(Map json) - : super(WsEvent.duration, Duration(seconds: json["data"] as int)); - - @override - String toJson() { - return jsonEncode({ - "type": type.name, - "data": data.inSeconds, - }); - } -} - -class WebSocketSeekEvent extends WebSocketEvent { - WebSocketSeekEvent(Duration data) : super(WsEvent.seek, data); - - WebSocketSeekEvent.fromJson(Map json) - : super(WsEvent.seek, Duration(seconds: json["data"] as int)); - - @override - String toJson() { - return jsonEncode({ - "type": type.name, - "data": data.inSeconds, - }); - } -} - -class WebSocketShuffleEvent extends WebSocketEvent { - WebSocketShuffleEvent(bool data) : super(WsEvent.shuffle, data); -} - -class WebSocketPlayingEvent extends WebSocketEvent { - WebSocketPlayingEvent(bool data) : super(WsEvent.playing, data); -} - -class WebSocketResumeEvent extends WebSocketEvent { - WebSocketResumeEvent() : super(WsEvent.resume, null); -} - -class WebSocketPauseEvent extends WebSocketEvent { - WebSocketPauseEvent() : super(WsEvent.pause, null); -} - -class WebSocketStopEvent extends WebSocketEvent { - WebSocketStopEvent() : super(WsEvent.stop, null); -} - -class WebSocketNextEvent extends WebSocketEvent { - WebSocketNextEvent() : super(WsEvent.next, null); -} - -class WebSocketPreviousEvent extends WebSocketEvent { - WebSocketPreviousEvent() : super(WsEvent.previous, null); -} - -class WebSocketJumpEvent extends WebSocketEvent { - WebSocketJumpEvent(int data) : super(WsEvent.jump, data); -} - -class WebSocketErrorEvent extends WebSocketEvent { - WebSocketErrorEvent(String data) : super(WsEvent.error, data); -} - -class WebSocketQueueEvent extends WebSocketEvent { - WebSocketQueueEvent(AudioPlayerState data) : super(WsEvent.queue, data); - - factory WebSocketQueueEvent.fromJson(Map json) => - WebSocketQueueEvent( - AudioPlayerState.fromJson(json), - ); -} - -class WebSocketRemoveTrackEvent extends WebSocketEvent { - WebSocketRemoveTrackEvent(String data) : super(WsEvent.removeTrack, data); -} - -class WebSocketAddTrackEvent extends WebSocketEvent { - WebSocketAddTrackEvent(SpotubeFullTrackObject data) - : super(WsEvent.addTrack, data); - - WebSocketAddTrackEvent.fromJson(Map json) - : super( - WsEvent.addTrack, - SpotubeFullTrackObject.fromJson( - json["data"] as Map, - ), - ); -} - -typedef ReorderData = ({int oldIndex, int newIndex}); - -class WebSocketReorderEvent extends WebSocketEvent { - WebSocketReorderEvent(ReorderData data) : super(WsEvent.reorder, data); - - factory WebSocketReorderEvent.fromJson(Map json) => - WebSocketReorderEvent( - ( - oldIndex: json["oldIndex"] as int, - newIndex: json["newIndex"] as int, - ), - ); - - @override - String toJson() { - return jsonEncode({ - "type": type.name, - "data": { - "oldIndex": data.oldIndex, - "newIndex": data.newIndex, - }, - }); - } -} - -class WebSocketVolumeEvent extends WebSocketEvent { - WebSocketVolumeEvent(double data) : super(WsEvent.volume, data); -} diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart deleted file mode 100644 index f1c66c1a..00000000 --- a/lib/models/database/database.dart +++ /dev/null @@ -1,267 +0,0 @@ -library database; - -import 'dart:convert'; -import 'dart:io'; - -import 'package:drift/drift.dart'; -import 'package:drift/remote.dart'; -import 'package:encrypt/encrypt.dart'; -import 'package:media_kit/media_kit.dart' hide Track; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' show ThemeMode, Colors; -import 'package:spotube/models/database/database.steps.dart'; -import 'package:spotube/models/lyrics.dart'; -import 'package:spotube/models/metadata/market.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; -import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:flutter/widgets.dart' hide Table, Key, View; -import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; -import 'package:drift/native.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:spotube/services/youtube_engine/newpipe_engine.dart'; -import 'package:spotube/services/youtube_engine/youtube_explode_engine.dart'; -import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:sqlite3/sqlite3.dart'; -import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; - -part 'database.g.dart'; - -part 'tables/authentication.dart'; -part 'tables/blacklist.dart'; -part 'tables/preferences.dart'; -part 'tables/scrobbler.dart'; -part 'tables/skip_segment.dart'; -part 'tables/source_match.dart'; -part 'tables/audio_player_state.dart'; -part 'tables/history.dart'; -part 'tables/lyrics.dart'; -part 'tables/metadata_plugins.dart'; - -part 'typeconverters/color.dart'; -part 'typeconverters/locale.dart'; -part 'typeconverters/string_list.dart'; -part 'typeconverters/encrypted_text.dart'; -part 'typeconverters/map.dart'; -part 'typeconverters/map_list.dart'; -part 'typeconverters/subtitle.dart'; - -@DriftDatabase( - tables: [ - AuthenticationTable, - BlacklistTable, - PreferencesTable, - ScrobblerTable, - SkipSegmentTable, - SourceMatchTable, - AudioPlayerStateTable, - HistoryTable, - LyricsTable, - PluginsTable, - ], -) -class AppDatabase extends _$AppDatabase { - AppDatabase() : super(_openConnection()); - - @override - int get schemaVersion => 10; - - @override - MigrationStrategy get migration { - return MigrationStrategy( - onUpgrade: stepByStep( - from1To2: (m, schema) async { - // Add invidiousInstance column to preferences table - await m.addColumn( - schema.preferencesTable, - schema.preferencesTable.invidiousInstance, - ); - }, - from2To3: (m, schema) async { - await m.addColumn( - schema.preferencesTable, - schema.preferencesTable.cacheMusic, - ); - }, - from3To4: (m, schema) async { - await m.addColumn( - schema.preferencesTable, - schema.preferencesTable.youtubeClientEngine, - ); - }, - from4To5: (m, schema) async { - final columnName = schema.preferencesTable.accentColorScheme - .escapedNameFor(SqlDialect.sqlite); - final columnNameOld = - '"${schema.preferencesTable.accentColorScheme.name}_old"'; - final tableName = schema.preferencesTable.actualTableName; - await customStatement( - "ALTER TABLE $tableName " - "RENAME COLUMN $columnName to $columnNameOld", - ); - await customStatement( - "ALTER TABLE $tableName " - "ADD COLUMN $columnName TEXT NOT NULL DEFAULT 'Slate:0xff64748b'", - ); - await customStatement( - "UPDATE $tableName " - "SET $columnName = $columnNameOld", - ); - await customStatement( - "ALTER TABLE $tableName " - "DROP COLUMN $columnNameOld", - ); - await customStatement( - "UPDATE $tableName " - "SET $columnName = 'Slate:0xff64748b' WHERE $columnName = 'Blue:0xFF2196F3'", - ); - }, - from5To6: (m, schema) async { - try { - await m.addColumn( - schema.preferencesTable, - schema.preferencesTable.connectPort, - ); - } on DriftRemoteException catch (e) { - // If the column already exists, ignore the error - if (e.remoteCause != - 'duplicate column name: ${schema.preferencesTable.connectPort.name}') { - rethrow; - } - } - }, - from6To7: (m, schema) async { - await m.createTable(schema.metadataPluginsTable); - await m.addColumn( - schema.audioPlayerStateTable, - schema.audioPlayerStateTable.currentIndex, - ); - await m.addColumn( - schema.audioPlayerStateTable, - schema.audioPlayerStateTable.tracks, - ); - }, - from7To8: (m, schema) async { - await m - .addColumn( - schema.metadataPluginsTable, - schema.metadataPluginsTable.entryPoint, - ) - .catchError((error, stackTrace) { - // If the column already exists, ignore the error - if (!error.toString().contains('duplicate column name')) { - throw error; - } - }); - await m - .addColumn( - schema.metadataPluginsTable, - schema.metadataPluginsTable.apis, - ) - .catchError((error, stackTrace) { - // If the column already exists, ignore the error - if (!error.toString().contains('duplicate column name')) { - throw error; - } - }); - await m - .addColumn( - schema.metadataPluginsTable, - schema.metadataPluginsTable.abilities, - ) - .catchError((error, stackTrace) { - // If the column already exists, ignore the error - if (!error.toString().contains('duplicate column name')) { - throw error; - } - }); - await m - .addColumn( - schema.metadataPluginsTable, - schema.metadataPluginsTable.repository, - ) - .catchError((error, stackTrace) { - // If the column already exists, ignore the error - if (!error.toString().contains('duplicate column name')) { - throw error; - } - }); - await m - .addColumn( - schema.metadataPluginsTable, - schema.metadataPluginsTable.pluginApiVersion, - ) - .catchError((error, stackTrace) { - // If the column already exists, ignore the error - if (!error.toString().contains('duplicate column name')) { - throw error; - } - }); - }, - from8To9: (m, schema) async { - await m - .renameTable(schema.pluginsTable, "metadata_plugins_table") - .catchError((e, stack) => AppLogger.reportError(e, stack)); - await m - .renameColumn( - schema.pluginsTable, - "selected", - pluginsTable.selectedForMetadata, - ) - .catchError((e, stack) => AppLogger.reportError(e, stack)); - await m - .addColumn( - schema.pluginsTable, - pluginsTable.selectedForAudioSource, - ) - .catchError((e, stack) => AppLogger.reportError(e, stack)); - }, - from9To10: (m, schema) async { - await m - .dropColumn(schema.preferencesTable, "piped_instance") - .catchError((e, stack) => AppLogger.reportError(e, stack)); - await m - .dropColumn(schema.preferencesTable, "invidious_instance") - .catchError((e, stack) => AppLogger.reportError(e, stack)); - await m - .addColumn( - schema.sourceMatchTable, - sourceMatchTable.sourceInfo, - ) - .catchError((e, stack) => AppLogger.reportError(e, stack)); - await customStatement("DROP INDEX IF EXISTS uniq_track_match;") - .catchError((e, stack) => AppLogger.reportError(e, stack)); - await m - .dropColumn(schema.sourceMatchTable, "source_id") - .catchError((e, stack) => AppLogger.reportError(e, stack)); - }, - ), - ); - } -} - -LazyDatabase _openConnection() { - // the LazyDatabase util lets us find the right location for the file async. - return LazyDatabase(() async { - // put the database file, called db.sqlite here, into the documents folder - // for your app. - final dbFolder = await getApplicationSupportDirectory(); - final file = File(join(dbFolder.path, 'db.sqlite')); - - // Also work around limitations on old Android versions - if (Platform.isAndroid) { - await applyWorkaroundToOpenSqlite3OnOldAndroidVersions(); - } - - // Make sqlite3 pick a more suitable location for temporary files - the - // one from the system may be inaccessible due to sandboxing. - final cacheBase = (await getTemporaryDirectory()).path; - // We can't access /tmp on Android, which sqlite3 would try by default. - // Explicitly tell it about the correct temporary directory. - sqlite3.tempDirectory = cacheBase; - - return NativeDatabase.createInBackground(file); - }); -} diff --git a/lib/models/database/database.g.dart b/lib/models/database/database.g.dart deleted file mode 100644 index 8aa14899..00000000 --- a/lib/models/database/database.g.dart +++ /dev/null @@ -1,6316 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'database.dart'; - -// ignore_for_file: type=lint -class $AuthenticationTableTable extends AuthenticationTable - with TableInfo<$AuthenticationTableTable, AuthenticationTableData> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $AuthenticationTableTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); - @override - late final GeneratedColumnWithTypeConverter cookie = - GeneratedColumn('cookie', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter( - $AuthenticationTableTable.$convertercookie); - @override - late final GeneratedColumnWithTypeConverter - accessToken = GeneratedColumn('access_token', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter( - $AuthenticationTableTable.$converteraccessToken); - static const VerificationMeta _expirationMeta = - const VerificationMeta('expiration'); - @override - late final GeneratedColumn expiration = GeneratedColumn( - 'expiration', aliasedName, false, - type: DriftSqlType.dateTime, requiredDuringInsert: true); - @override - List get $columns => [id, cookie, accessToken, expiration]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'authentication_table'; - @override - VerificationContext validateIntegrity( - Insertable instance, - {bool isInserting = false}) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } - if (data.containsKey('expiration')) { - context.handle( - _expirationMeta, - expiration.isAcceptableOrUnknown( - data['expiration']!, _expirationMeta)); - } else if (isInserting) { - context.missing(_expirationMeta); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - AuthenticationTableData map(Map data, - {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return AuthenticationTableData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - cookie: $AuthenticationTableTable.$convertercookie.fromSql( - attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}cookie'])!), - accessToken: $AuthenticationTableTable.$converteraccessToken.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}access_token'])!), - expiration: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}expiration'])!, - ); - } - - @override - $AuthenticationTableTable createAlias(String alias) { - return $AuthenticationTableTable(attachedDatabase, alias); - } - - static TypeConverter $convertercookie = - EncryptedTextConverter(); - static TypeConverter $converteraccessToken = - EncryptedTextConverter(); -} - -class AuthenticationTableData extends DataClass - implements Insertable { - final int id; - final DecryptedText cookie; - final DecryptedText accessToken; - final DateTime expiration; - const AuthenticationTableData( - {required this.id, - required this.cookie, - required this.accessToken, - required this.expiration}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - { - map['cookie'] = Variable( - $AuthenticationTableTable.$convertercookie.toSql(cookie)); - } - { - map['access_token'] = Variable( - $AuthenticationTableTable.$converteraccessToken.toSql(accessToken)); - } - map['expiration'] = Variable(expiration); - return map; - } - - AuthenticationTableCompanion toCompanion(bool nullToAbsent) { - return AuthenticationTableCompanion( - id: Value(id), - cookie: Value(cookie), - accessToken: Value(accessToken), - expiration: Value(expiration), - ); - } - - factory AuthenticationTableData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return AuthenticationTableData( - id: serializer.fromJson(json['id']), - cookie: serializer.fromJson(json['cookie']), - accessToken: serializer.fromJson(json['accessToken']), - expiration: serializer.fromJson(json['expiration']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'cookie': serializer.toJson(cookie), - 'accessToken': serializer.toJson(accessToken), - 'expiration': serializer.toJson(expiration), - }; - } - - AuthenticationTableData copyWith( - {int? id, - DecryptedText? cookie, - DecryptedText? accessToken, - DateTime? expiration}) => - AuthenticationTableData( - id: id ?? this.id, - cookie: cookie ?? this.cookie, - accessToken: accessToken ?? this.accessToken, - expiration: expiration ?? this.expiration, - ); - AuthenticationTableData copyWithCompanion(AuthenticationTableCompanion data) { - return AuthenticationTableData( - id: data.id.present ? data.id.value : this.id, - cookie: data.cookie.present ? data.cookie.value : this.cookie, - accessToken: - data.accessToken.present ? data.accessToken.value : this.accessToken, - expiration: - data.expiration.present ? data.expiration.value : this.expiration, - ); - } - - @override - String toString() { - return (StringBuffer('AuthenticationTableData(') - ..write('id: $id, ') - ..write('cookie: $cookie, ') - ..write('accessToken: $accessToken, ') - ..write('expiration: $expiration') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash(id, cookie, accessToken, expiration); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is AuthenticationTableData && - other.id == this.id && - other.cookie == this.cookie && - other.accessToken == this.accessToken && - other.expiration == this.expiration); -} - -class AuthenticationTableCompanion - extends UpdateCompanion { - final Value id; - final Value cookie; - final Value accessToken; - final Value expiration; - const AuthenticationTableCompanion({ - this.id = const Value.absent(), - this.cookie = const Value.absent(), - this.accessToken = const Value.absent(), - this.expiration = const Value.absent(), - }); - AuthenticationTableCompanion.insert({ - this.id = const Value.absent(), - required DecryptedText cookie, - required DecryptedText accessToken, - required DateTime expiration, - }) : cookie = Value(cookie), - accessToken = Value(accessToken), - expiration = Value(expiration); - static Insertable custom({ - Expression? id, - Expression? cookie, - Expression? accessToken, - Expression? expiration, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (cookie != null) 'cookie': cookie, - if (accessToken != null) 'access_token': accessToken, - if (expiration != null) 'expiration': expiration, - }); - } - - AuthenticationTableCompanion copyWith( - {Value? id, - Value? cookie, - Value? accessToken, - Value? expiration}) { - return AuthenticationTableCompanion( - id: id ?? this.id, - cookie: cookie ?? this.cookie, - accessToken: accessToken ?? this.accessToken, - expiration: expiration ?? this.expiration, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (cookie.present) { - map['cookie'] = Variable( - $AuthenticationTableTable.$convertercookie.toSql(cookie.value)); - } - if (accessToken.present) { - map['access_token'] = Variable($AuthenticationTableTable - .$converteraccessToken - .toSql(accessToken.value)); - } - if (expiration.present) { - map['expiration'] = Variable(expiration.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('AuthenticationTableCompanion(') - ..write('id: $id, ') - ..write('cookie: $cookie, ') - ..write('accessToken: $accessToken, ') - ..write('expiration: $expiration') - ..write(')')) - .toString(); - } -} - -class $BlacklistTableTable extends BlacklistTable - with TableInfo<$BlacklistTableTable, BlacklistTableData> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $BlacklistTableTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); - static const VerificationMeta _nameMeta = const VerificationMeta('name'); - @override - late final GeneratedColumn name = GeneratedColumn( - 'name', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - @override - late final GeneratedColumnWithTypeConverter - elementType = GeneratedColumn('element_type', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter( - $BlacklistTableTable.$converterelementType); - static const VerificationMeta _elementIdMeta = - const VerificationMeta('elementId'); - @override - late final GeneratedColumn elementId = GeneratedColumn( - 'element_id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - @override - List get $columns => [id, name, elementType, elementId]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'blacklist_table'; - @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } - if (data.containsKey('name')) { - context.handle( - _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); - } else if (isInserting) { - context.missing(_nameMeta); - } - if (data.containsKey('element_id')) { - context.handle(_elementIdMeta, - elementId.isAcceptableOrUnknown(data['element_id']!, _elementIdMeta)); - } else if (isInserting) { - context.missing(_elementIdMeta); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - BlacklistTableData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return BlacklistTableData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - name: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}name'])!, - elementType: $BlacklistTableTable.$converterelementType.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}element_type'])!), - elementId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}element_id'])!, - ); - } - - @override - $BlacklistTableTable createAlias(String alias) { - return $BlacklistTableTable(attachedDatabase, alias); - } - - static JsonTypeConverter2 - $converterelementType = - const EnumNameConverter(BlacklistedType.values); -} - -class BlacklistTableData extends DataClass - implements Insertable { - final int id; - final String name; - final BlacklistedType elementType; - final String elementId; - const BlacklistTableData( - {required this.id, - required this.name, - required this.elementType, - required this.elementId}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['name'] = Variable(name); - { - map['element_type'] = Variable( - $BlacklistTableTable.$converterelementType.toSql(elementType)); - } - map['element_id'] = Variable(elementId); - return map; - } - - BlacklistTableCompanion toCompanion(bool nullToAbsent) { - return BlacklistTableCompanion( - id: Value(id), - name: Value(name), - elementType: Value(elementType), - elementId: Value(elementId), - ); - } - - factory BlacklistTableData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return BlacklistTableData( - id: serializer.fromJson(json['id']), - name: serializer.fromJson(json['name']), - elementType: $BlacklistTableTable.$converterelementType - .fromJson(serializer.fromJson(json['elementType'])), - elementId: serializer.fromJson(json['elementId']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'name': serializer.toJson(name), - 'elementType': serializer.toJson( - $BlacklistTableTable.$converterelementType.toJson(elementType)), - 'elementId': serializer.toJson(elementId), - }; - } - - BlacklistTableData copyWith( - {int? id, - String? name, - BlacklistedType? elementType, - String? elementId}) => - BlacklistTableData( - id: id ?? this.id, - name: name ?? this.name, - elementType: elementType ?? this.elementType, - elementId: elementId ?? this.elementId, - ); - BlacklistTableData copyWithCompanion(BlacklistTableCompanion data) { - return BlacklistTableData( - id: data.id.present ? data.id.value : this.id, - name: data.name.present ? data.name.value : this.name, - elementType: - data.elementType.present ? data.elementType.value : this.elementType, - elementId: data.elementId.present ? data.elementId.value : this.elementId, - ); - } - - @override - String toString() { - return (StringBuffer('BlacklistTableData(') - ..write('id: $id, ') - ..write('name: $name, ') - ..write('elementType: $elementType, ') - ..write('elementId: $elementId') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash(id, name, elementType, elementId); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is BlacklistTableData && - other.id == this.id && - other.name == this.name && - other.elementType == this.elementType && - other.elementId == this.elementId); -} - -class BlacklistTableCompanion extends UpdateCompanion { - final Value id; - final Value name; - final Value elementType; - final Value elementId; - const BlacklistTableCompanion({ - this.id = const Value.absent(), - this.name = const Value.absent(), - this.elementType = const Value.absent(), - this.elementId = const Value.absent(), - }); - BlacklistTableCompanion.insert({ - this.id = const Value.absent(), - required String name, - required BlacklistedType elementType, - required String elementId, - }) : name = Value(name), - elementType = Value(elementType), - elementId = Value(elementId); - static Insertable custom({ - Expression? id, - Expression? name, - Expression? elementType, - Expression? elementId, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (name != null) 'name': name, - if (elementType != null) 'element_type': elementType, - if (elementId != null) 'element_id': elementId, - }); - } - - BlacklistTableCompanion copyWith( - {Value? id, - Value? name, - Value? elementType, - Value? elementId}) { - return BlacklistTableCompanion( - id: id ?? this.id, - name: name ?? this.name, - elementType: elementType ?? this.elementType, - elementId: elementId ?? this.elementId, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (name.present) { - map['name'] = Variable(name.value); - } - if (elementType.present) { - map['element_type'] = Variable( - $BlacklistTableTable.$converterelementType.toSql(elementType.value)); - } - if (elementId.present) { - map['element_id'] = Variable(elementId.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('BlacklistTableCompanion(') - ..write('id: $id, ') - ..write('name: $name, ') - ..write('elementType: $elementType, ') - ..write('elementId: $elementId') - ..write(')')) - .toString(); - } -} - -class $PreferencesTableTable extends PreferencesTable - with TableInfo<$PreferencesTableTable, PreferencesTableData> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $PreferencesTableTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); - static const VerificationMeta _albumColorSyncMeta = - const VerificationMeta('albumColorSync'); - @override - late final GeneratedColumn albumColorSync = GeneratedColumn( - 'album_color_sync', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("album_color_sync" IN (0, 1))'), - defaultValue: const Constant(true)); - static const VerificationMeta _amoledDarkThemeMeta = - const VerificationMeta('amoledDarkTheme'); - @override - late final GeneratedColumn amoledDarkTheme = GeneratedColumn( - 'amoled_dark_theme', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("amoled_dark_theme" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _checkUpdateMeta = - const VerificationMeta('checkUpdate'); - @override - late final GeneratedColumn checkUpdate = GeneratedColumn( - 'check_update', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("check_update" IN (0, 1))'), - defaultValue: const Constant(true)); - static const VerificationMeta _normalizeAudioMeta = - const VerificationMeta('normalizeAudio'); - @override - late final GeneratedColumn normalizeAudio = GeneratedColumn( - 'normalize_audio', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("normalize_audio" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _showSystemTrayIconMeta = - const VerificationMeta('showSystemTrayIcon'); - @override - late final GeneratedColumn showSystemTrayIcon = GeneratedColumn( - 'show_system_tray_icon', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("show_system_tray_icon" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _systemTitleBarMeta = - const VerificationMeta('systemTitleBar'); - @override - late final GeneratedColumn systemTitleBar = GeneratedColumn( - 'system_title_bar', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("system_title_bar" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _skipNonMusicMeta = - const VerificationMeta('skipNonMusic'); - @override - late final GeneratedColumn skipNonMusic = GeneratedColumn( - 'skip_non_music', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("skip_non_music" IN (0, 1))'), - defaultValue: const Constant(false)); - @override - late final GeneratedColumnWithTypeConverter - closeBehavior = GeneratedColumn( - 'close_behavior', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: Constant(CloseBehavior.close.name)) - .withConverter( - $PreferencesTableTable.$convertercloseBehavior); - @override - late final GeneratedColumnWithTypeConverter - accentColorScheme = GeneratedColumn( - 'accent_color_scheme', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: const Constant("Slate:0xff64748b")) - .withConverter( - $PreferencesTableTable.$converteraccentColorScheme); - @override - late final GeneratedColumnWithTypeConverter layoutMode = - GeneratedColumn('layout_mode', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: Constant(LayoutMode.adaptive.name)) - .withConverter( - $PreferencesTableTable.$converterlayoutMode); - @override - late final GeneratedColumnWithTypeConverter locale = - GeneratedColumn('locale', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: const Constant( - '{"languageCode":"system","countryCode":"system"}')) - .withConverter($PreferencesTableTable.$converterlocale); - @override - late final GeneratedColumnWithTypeConverter market = - GeneratedColumn('market', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: Constant(Market.US.name)) - .withConverter($PreferencesTableTable.$convertermarket); - @override - late final GeneratedColumnWithTypeConverter searchMode = - GeneratedColumn('search_mode', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: Constant(SearchMode.youtube.name)) - .withConverter( - $PreferencesTableTable.$convertersearchMode); - static const VerificationMeta _downloadLocationMeta = - const VerificationMeta('downloadLocation'); - @override - late final GeneratedColumn downloadLocation = GeneratedColumn( - 'download_location', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: const Constant("")); - @override - late final GeneratedColumnWithTypeConverter, String> - localLibraryLocation = GeneratedColumn( - 'local_library_location', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: const Constant("")) - .withConverter>( - $PreferencesTableTable.$converterlocalLibraryLocation); - @override - late final GeneratedColumnWithTypeConverter themeMode = - GeneratedColumn('theme_mode', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: Constant(ThemeMode.system.name)) - .withConverter($PreferencesTableTable.$converterthemeMode); - static const VerificationMeta _audioSourceIdMeta = - const VerificationMeta('audioSourceId'); - @override - late final GeneratedColumn audioSourceId = GeneratedColumn( - 'audio_source_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - @override - late final GeneratedColumnWithTypeConverter - youtubeClientEngine = GeneratedColumn( - 'youtube_client_engine', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: Constant(YoutubeClientEngine.youtubeExplode.name)) - .withConverter( - $PreferencesTableTable.$converteryoutubeClientEngine); - static const VerificationMeta _discordPresenceMeta = - const VerificationMeta('discordPresence'); - @override - late final GeneratedColumn discordPresence = GeneratedColumn( - 'discord_presence', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("discord_presence" IN (0, 1))'), - defaultValue: const Constant(true)); - static const VerificationMeta _endlessPlaybackMeta = - const VerificationMeta('endlessPlayback'); - @override - late final GeneratedColumn endlessPlayback = GeneratedColumn( - 'endless_playback', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("endless_playback" IN (0, 1))'), - defaultValue: const Constant(true)); - static const VerificationMeta _enableConnectMeta = - const VerificationMeta('enableConnect'); - @override - late final GeneratedColumn enableConnect = GeneratedColumn( - 'enable_connect', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("enable_connect" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _connectPortMeta = - const VerificationMeta('connectPort'); - @override - late final GeneratedColumn connectPort = GeneratedColumn( - 'connect_port', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(-1)); - static const VerificationMeta _cacheMusicMeta = - const VerificationMeta('cacheMusic'); - @override - late final GeneratedColumn cacheMusic = GeneratedColumn( - 'cache_music', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('CHECK ("cache_music" IN (0, 1))'), - defaultValue: const Constant(true)); - @override - List get $columns => [ - id, - albumColorSync, - amoledDarkTheme, - checkUpdate, - normalizeAudio, - showSystemTrayIcon, - systemTitleBar, - skipNonMusic, - closeBehavior, - accentColorScheme, - layoutMode, - locale, - market, - searchMode, - downloadLocation, - localLibraryLocation, - themeMode, - audioSourceId, - youtubeClientEngine, - discordPresence, - endlessPlayback, - enableConnect, - connectPort, - cacheMusic - ]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'preferences_table'; - @override - VerificationContext validateIntegrity( - Insertable instance, - {bool isInserting = false}) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } - if (data.containsKey('album_color_sync')) { - context.handle( - _albumColorSyncMeta, - albumColorSync.isAcceptableOrUnknown( - data['album_color_sync']!, _albumColorSyncMeta)); - } - if (data.containsKey('amoled_dark_theme')) { - context.handle( - _amoledDarkThemeMeta, - amoledDarkTheme.isAcceptableOrUnknown( - data['amoled_dark_theme']!, _amoledDarkThemeMeta)); - } - if (data.containsKey('check_update')) { - context.handle( - _checkUpdateMeta, - checkUpdate.isAcceptableOrUnknown( - data['check_update']!, _checkUpdateMeta)); - } - if (data.containsKey('normalize_audio')) { - context.handle( - _normalizeAudioMeta, - normalizeAudio.isAcceptableOrUnknown( - data['normalize_audio']!, _normalizeAudioMeta)); - } - if (data.containsKey('show_system_tray_icon')) { - context.handle( - _showSystemTrayIconMeta, - showSystemTrayIcon.isAcceptableOrUnknown( - data['show_system_tray_icon']!, _showSystemTrayIconMeta)); - } - if (data.containsKey('system_title_bar')) { - context.handle( - _systemTitleBarMeta, - systemTitleBar.isAcceptableOrUnknown( - data['system_title_bar']!, _systemTitleBarMeta)); - } - if (data.containsKey('skip_non_music')) { - context.handle( - _skipNonMusicMeta, - skipNonMusic.isAcceptableOrUnknown( - data['skip_non_music']!, _skipNonMusicMeta)); - } - if (data.containsKey('download_location')) { - context.handle( - _downloadLocationMeta, - downloadLocation.isAcceptableOrUnknown( - data['download_location']!, _downloadLocationMeta)); - } - if (data.containsKey('audio_source_id')) { - context.handle( - _audioSourceIdMeta, - audioSourceId.isAcceptableOrUnknown( - data['audio_source_id']!, _audioSourceIdMeta)); - } - if (data.containsKey('discord_presence')) { - context.handle( - _discordPresenceMeta, - discordPresence.isAcceptableOrUnknown( - data['discord_presence']!, _discordPresenceMeta)); - } - if (data.containsKey('endless_playback')) { - context.handle( - _endlessPlaybackMeta, - endlessPlayback.isAcceptableOrUnknown( - data['endless_playback']!, _endlessPlaybackMeta)); - } - if (data.containsKey('enable_connect')) { - context.handle( - _enableConnectMeta, - enableConnect.isAcceptableOrUnknown( - data['enable_connect']!, _enableConnectMeta)); - } - if (data.containsKey('connect_port')) { - context.handle( - _connectPortMeta, - connectPort.isAcceptableOrUnknown( - data['connect_port']!, _connectPortMeta)); - } - if (data.containsKey('cache_music')) { - context.handle( - _cacheMusicMeta, - cacheMusic.isAcceptableOrUnknown( - data['cache_music']!, _cacheMusicMeta)); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - PreferencesTableData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return PreferencesTableData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - albumColorSync: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}album_color_sync'])!, - amoledDarkTheme: attachedDatabase.typeMapping.read( - DriftSqlType.bool, data['${effectivePrefix}amoled_dark_theme'])!, - checkUpdate: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}check_update'])!, - normalizeAudio: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}normalize_audio'])!, - showSystemTrayIcon: attachedDatabase.typeMapping.read( - DriftSqlType.bool, data['${effectivePrefix}show_system_tray_icon'])!, - systemTitleBar: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}system_title_bar'])!, - skipNonMusic: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}skip_non_music'])!, - closeBehavior: $PreferencesTableTable.$convertercloseBehavior.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}close_behavior'])!), - accentColorScheme: $PreferencesTableTable.$converteraccentColorScheme - .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, - data['${effectivePrefix}accent_color_scheme'])!), - layoutMode: $PreferencesTableTable.$converterlayoutMode.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}layout_mode'])!), - locale: $PreferencesTableTable.$converterlocale.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}locale'])!), - market: $PreferencesTableTable.$convertermarket.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}market'])!), - searchMode: $PreferencesTableTable.$convertersearchMode.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}search_mode'])!), - downloadLocation: attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}download_location'])!, - localLibraryLocation: $PreferencesTableTable - .$converterlocalLibraryLocation - .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, - data['${effectivePrefix}local_library_location'])!), - themeMode: $PreferencesTableTable.$converterthemeMode.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}theme_mode'])!), - audioSourceId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}audio_source_id']), - youtubeClientEngine: $PreferencesTableTable.$converteryoutubeClientEngine - .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, - data['${effectivePrefix}youtube_client_engine'])!), - discordPresence: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}discord_presence'])!, - endlessPlayback: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}endless_playback'])!, - enableConnect: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}enable_connect'])!, - connectPort: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}connect_port'])!, - cacheMusic: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}cache_music'])!, - ); - } - - @override - $PreferencesTableTable createAlias(String alias) { - return $PreferencesTableTable(attachedDatabase, alias); - } - - static JsonTypeConverter2 - $convertercloseBehavior = - const EnumNameConverter(CloseBehavior.values); - static TypeConverter $converteraccentColorScheme = - const SpotubeColorConverter(); - static JsonTypeConverter2 $converterlayoutMode = - const EnumNameConverter(LayoutMode.values); - static TypeConverter $converterlocale = - const LocaleConverter(); - static JsonTypeConverter2 $convertermarket = - const EnumNameConverter(Market.values); - static JsonTypeConverter2 $convertersearchMode = - const EnumNameConverter(SearchMode.values); - static TypeConverter, String> $converterlocalLibraryLocation = - const StringListConverter(); - static JsonTypeConverter2 $converterthemeMode = - const EnumNameConverter(ThemeMode.values); - static JsonTypeConverter2 - $converteryoutubeClientEngine = - const EnumNameConverter(YoutubeClientEngine.values); -} - -class PreferencesTableData extends DataClass - implements Insertable { - final int id; - final bool albumColorSync; - final bool amoledDarkTheme; - final bool checkUpdate; - final bool normalizeAudio; - final bool showSystemTrayIcon; - final bool systemTitleBar; - final bool skipNonMusic; - final CloseBehavior closeBehavior; - final SpotubeColor accentColorScheme; - final LayoutMode layoutMode; - final Locale locale; - final Market market; - final SearchMode searchMode; - final String downloadLocation; - final List localLibraryLocation; - final ThemeMode themeMode; - final String? audioSourceId; - final YoutubeClientEngine youtubeClientEngine; - final bool discordPresence; - final bool endlessPlayback; - final bool enableConnect; - final int connectPort; - final bool cacheMusic; - const PreferencesTableData( - {required this.id, - required this.albumColorSync, - required this.amoledDarkTheme, - required this.checkUpdate, - required this.normalizeAudio, - required this.showSystemTrayIcon, - required this.systemTitleBar, - required this.skipNonMusic, - required this.closeBehavior, - required this.accentColorScheme, - required this.layoutMode, - required this.locale, - required this.market, - required this.searchMode, - required this.downloadLocation, - required this.localLibraryLocation, - required this.themeMode, - this.audioSourceId, - required this.youtubeClientEngine, - required this.discordPresence, - required this.endlessPlayback, - required this.enableConnect, - required this.connectPort, - required this.cacheMusic}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['album_color_sync'] = Variable(albumColorSync); - map['amoled_dark_theme'] = Variable(amoledDarkTheme); - map['check_update'] = Variable(checkUpdate); - map['normalize_audio'] = Variable(normalizeAudio); - map['show_system_tray_icon'] = Variable(showSystemTrayIcon); - map['system_title_bar'] = Variable(systemTitleBar); - map['skip_non_music'] = Variable(skipNonMusic); - { - map['close_behavior'] = Variable( - $PreferencesTableTable.$convertercloseBehavior.toSql(closeBehavior)); - } - { - map['accent_color_scheme'] = Variable($PreferencesTableTable - .$converteraccentColorScheme - .toSql(accentColorScheme)); - } - { - map['layout_mode'] = Variable( - $PreferencesTableTable.$converterlayoutMode.toSql(layoutMode)); - } - { - map['locale'] = Variable( - $PreferencesTableTable.$converterlocale.toSql(locale)); - } - { - map['market'] = Variable( - $PreferencesTableTable.$convertermarket.toSql(market)); - } - { - map['search_mode'] = Variable( - $PreferencesTableTable.$convertersearchMode.toSql(searchMode)); - } - map['download_location'] = Variable(downloadLocation); - { - map['local_library_location'] = Variable($PreferencesTableTable - .$converterlocalLibraryLocation - .toSql(localLibraryLocation)); - } - { - map['theme_mode'] = Variable( - $PreferencesTableTable.$converterthemeMode.toSql(themeMode)); - } - if (!nullToAbsent || audioSourceId != null) { - map['audio_source_id'] = Variable(audioSourceId); - } - { - map['youtube_client_engine'] = Variable($PreferencesTableTable - .$converteryoutubeClientEngine - .toSql(youtubeClientEngine)); - } - map['discord_presence'] = Variable(discordPresence); - map['endless_playback'] = Variable(endlessPlayback); - map['enable_connect'] = Variable(enableConnect); - map['connect_port'] = Variable(connectPort); - map['cache_music'] = Variable(cacheMusic); - return map; - } - - PreferencesTableCompanion toCompanion(bool nullToAbsent) { - return PreferencesTableCompanion( - id: Value(id), - albumColorSync: Value(albumColorSync), - amoledDarkTheme: Value(amoledDarkTheme), - checkUpdate: Value(checkUpdate), - normalizeAudio: Value(normalizeAudio), - showSystemTrayIcon: Value(showSystemTrayIcon), - systemTitleBar: Value(systemTitleBar), - skipNonMusic: Value(skipNonMusic), - closeBehavior: Value(closeBehavior), - accentColorScheme: Value(accentColorScheme), - layoutMode: Value(layoutMode), - locale: Value(locale), - market: Value(market), - searchMode: Value(searchMode), - downloadLocation: Value(downloadLocation), - localLibraryLocation: Value(localLibraryLocation), - themeMode: Value(themeMode), - audioSourceId: audioSourceId == null && nullToAbsent - ? const Value.absent() - : Value(audioSourceId), - youtubeClientEngine: Value(youtubeClientEngine), - discordPresence: Value(discordPresence), - endlessPlayback: Value(endlessPlayback), - enableConnect: Value(enableConnect), - connectPort: Value(connectPort), - cacheMusic: Value(cacheMusic), - ); - } - - factory PreferencesTableData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return PreferencesTableData( - id: serializer.fromJson(json['id']), - albumColorSync: serializer.fromJson(json['albumColorSync']), - amoledDarkTheme: serializer.fromJson(json['amoledDarkTheme']), - checkUpdate: serializer.fromJson(json['checkUpdate']), - normalizeAudio: serializer.fromJson(json['normalizeAudio']), - showSystemTrayIcon: serializer.fromJson(json['showSystemTrayIcon']), - systemTitleBar: serializer.fromJson(json['systemTitleBar']), - skipNonMusic: serializer.fromJson(json['skipNonMusic']), - closeBehavior: $PreferencesTableTable.$convertercloseBehavior - .fromJson(serializer.fromJson(json['closeBehavior'])), - accentColorScheme: - serializer.fromJson(json['accentColorScheme']), - layoutMode: $PreferencesTableTable.$converterlayoutMode - .fromJson(serializer.fromJson(json['layoutMode'])), - locale: serializer.fromJson(json['locale']), - market: $PreferencesTableTable.$convertermarket - .fromJson(serializer.fromJson(json['market'])), - searchMode: $PreferencesTableTable.$convertersearchMode - .fromJson(serializer.fromJson(json['searchMode'])), - downloadLocation: serializer.fromJson(json['downloadLocation']), - localLibraryLocation: - serializer.fromJson>(json['localLibraryLocation']), - themeMode: $PreferencesTableTable.$converterthemeMode - .fromJson(serializer.fromJson(json['themeMode'])), - audioSourceId: serializer.fromJson(json['audioSourceId']), - youtubeClientEngine: $PreferencesTableTable.$converteryoutubeClientEngine - .fromJson(serializer.fromJson(json['youtubeClientEngine'])), - discordPresence: serializer.fromJson(json['discordPresence']), - endlessPlayback: serializer.fromJson(json['endlessPlayback']), - enableConnect: serializer.fromJson(json['enableConnect']), - connectPort: serializer.fromJson(json['connectPort']), - cacheMusic: serializer.fromJson(json['cacheMusic']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'albumColorSync': serializer.toJson(albumColorSync), - 'amoledDarkTheme': serializer.toJson(amoledDarkTheme), - 'checkUpdate': serializer.toJson(checkUpdate), - 'normalizeAudio': serializer.toJson(normalizeAudio), - 'showSystemTrayIcon': serializer.toJson(showSystemTrayIcon), - 'systemTitleBar': serializer.toJson(systemTitleBar), - 'skipNonMusic': serializer.toJson(skipNonMusic), - 'closeBehavior': serializer.toJson( - $PreferencesTableTable.$convertercloseBehavior.toJson(closeBehavior)), - 'accentColorScheme': serializer.toJson(accentColorScheme), - 'layoutMode': serializer.toJson( - $PreferencesTableTable.$converterlayoutMode.toJson(layoutMode)), - 'locale': serializer.toJson(locale), - 'market': serializer.toJson( - $PreferencesTableTable.$convertermarket.toJson(market)), - 'searchMode': serializer.toJson( - $PreferencesTableTable.$convertersearchMode.toJson(searchMode)), - 'downloadLocation': serializer.toJson(downloadLocation), - 'localLibraryLocation': - serializer.toJson>(localLibraryLocation), - 'themeMode': serializer.toJson( - $PreferencesTableTable.$converterthemeMode.toJson(themeMode)), - 'audioSourceId': serializer.toJson(audioSourceId), - 'youtubeClientEngine': serializer.toJson($PreferencesTableTable - .$converteryoutubeClientEngine - .toJson(youtubeClientEngine)), - 'discordPresence': serializer.toJson(discordPresence), - 'endlessPlayback': serializer.toJson(endlessPlayback), - 'enableConnect': serializer.toJson(enableConnect), - 'connectPort': serializer.toJson(connectPort), - 'cacheMusic': serializer.toJson(cacheMusic), - }; - } - - PreferencesTableData copyWith( - {int? id, - bool? albumColorSync, - bool? amoledDarkTheme, - bool? checkUpdate, - bool? normalizeAudio, - bool? showSystemTrayIcon, - bool? systemTitleBar, - bool? skipNonMusic, - CloseBehavior? closeBehavior, - SpotubeColor? accentColorScheme, - LayoutMode? layoutMode, - Locale? locale, - Market? market, - SearchMode? searchMode, - String? downloadLocation, - List? localLibraryLocation, - ThemeMode? themeMode, - Value audioSourceId = const Value.absent(), - YoutubeClientEngine? youtubeClientEngine, - bool? discordPresence, - bool? endlessPlayback, - bool? enableConnect, - int? connectPort, - bool? cacheMusic}) => - PreferencesTableData( - id: id ?? this.id, - albumColorSync: albumColorSync ?? this.albumColorSync, - amoledDarkTheme: amoledDarkTheme ?? this.amoledDarkTheme, - checkUpdate: checkUpdate ?? this.checkUpdate, - normalizeAudio: normalizeAudio ?? this.normalizeAudio, - showSystemTrayIcon: showSystemTrayIcon ?? this.showSystemTrayIcon, - systemTitleBar: systemTitleBar ?? this.systemTitleBar, - skipNonMusic: skipNonMusic ?? this.skipNonMusic, - closeBehavior: closeBehavior ?? this.closeBehavior, - accentColorScheme: accentColorScheme ?? this.accentColorScheme, - layoutMode: layoutMode ?? this.layoutMode, - locale: locale ?? this.locale, - market: market ?? this.market, - searchMode: searchMode ?? this.searchMode, - downloadLocation: downloadLocation ?? this.downloadLocation, - localLibraryLocation: localLibraryLocation ?? this.localLibraryLocation, - themeMode: themeMode ?? this.themeMode, - audioSourceId: - audioSourceId.present ? audioSourceId.value : this.audioSourceId, - youtubeClientEngine: youtubeClientEngine ?? this.youtubeClientEngine, - discordPresence: discordPresence ?? this.discordPresence, - endlessPlayback: endlessPlayback ?? this.endlessPlayback, - enableConnect: enableConnect ?? this.enableConnect, - connectPort: connectPort ?? this.connectPort, - cacheMusic: cacheMusic ?? this.cacheMusic, - ); - PreferencesTableData copyWithCompanion(PreferencesTableCompanion data) { - return PreferencesTableData( - id: data.id.present ? data.id.value : this.id, - albumColorSync: data.albumColorSync.present - ? data.albumColorSync.value - : this.albumColorSync, - amoledDarkTheme: data.amoledDarkTheme.present - ? data.amoledDarkTheme.value - : this.amoledDarkTheme, - checkUpdate: - data.checkUpdate.present ? data.checkUpdate.value : this.checkUpdate, - normalizeAudio: data.normalizeAudio.present - ? data.normalizeAudio.value - : this.normalizeAudio, - showSystemTrayIcon: data.showSystemTrayIcon.present - ? data.showSystemTrayIcon.value - : this.showSystemTrayIcon, - systemTitleBar: data.systemTitleBar.present - ? data.systemTitleBar.value - : this.systemTitleBar, - skipNonMusic: data.skipNonMusic.present - ? data.skipNonMusic.value - : this.skipNonMusic, - closeBehavior: data.closeBehavior.present - ? data.closeBehavior.value - : this.closeBehavior, - accentColorScheme: data.accentColorScheme.present - ? data.accentColorScheme.value - : this.accentColorScheme, - layoutMode: - data.layoutMode.present ? data.layoutMode.value : this.layoutMode, - locale: data.locale.present ? data.locale.value : this.locale, - market: data.market.present ? data.market.value : this.market, - searchMode: - data.searchMode.present ? data.searchMode.value : this.searchMode, - downloadLocation: data.downloadLocation.present - ? data.downloadLocation.value - : this.downloadLocation, - localLibraryLocation: data.localLibraryLocation.present - ? data.localLibraryLocation.value - : this.localLibraryLocation, - themeMode: data.themeMode.present ? data.themeMode.value : this.themeMode, - audioSourceId: data.audioSourceId.present - ? data.audioSourceId.value - : this.audioSourceId, - youtubeClientEngine: data.youtubeClientEngine.present - ? data.youtubeClientEngine.value - : this.youtubeClientEngine, - discordPresence: data.discordPresence.present - ? data.discordPresence.value - : this.discordPresence, - endlessPlayback: data.endlessPlayback.present - ? data.endlessPlayback.value - : this.endlessPlayback, - enableConnect: data.enableConnect.present - ? data.enableConnect.value - : this.enableConnect, - connectPort: - data.connectPort.present ? data.connectPort.value : this.connectPort, - cacheMusic: - data.cacheMusic.present ? data.cacheMusic.value : this.cacheMusic, - ); - } - - @override - String toString() { - return (StringBuffer('PreferencesTableData(') - ..write('id: $id, ') - ..write('albumColorSync: $albumColorSync, ') - ..write('amoledDarkTheme: $amoledDarkTheme, ') - ..write('checkUpdate: $checkUpdate, ') - ..write('normalizeAudio: $normalizeAudio, ') - ..write('showSystemTrayIcon: $showSystemTrayIcon, ') - ..write('systemTitleBar: $systemTitleBar, ') - ..write('skipNonMusic: $skipNonMusic, ') - ..write('closeBehavior: $closeBehavior, ') - ..write('accentColorScheme: $accentColorScheme, ') - ..write('layoutMode: $layoutMode, ') - ..write('locale: $locale, ') - ..write('market: $market, ') - ..write('searchMode: $searchMode, ') - ..write('downloadLocation: $downloadLocation, ') - ..write('localLibraryLocation: $localLibraryLocation, ') - ..write('themeMode: $themeMode, ') - ..write('audioSourceId: $audioSourceId, ') - ..write('youtubeClientEngine: $youtubeClientEngine, ') - ..write('discordPresence: $discordPresence, ') - ..write('endlessPlayback: $endlessPlayback, ') - ..write('enableConnect: $enableConnect, ') - ..write('connectPort: $connectPort, ') - ..write('cacheMusic: $cacheMusic') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hashAll([ - id, - albumColorSync, - amoledDarkTheme, - checkUpdate, - normalizeAudio, - showSystemTrayIcon, - systemTitleBar, - skipNonMusic, - closeBehavior, - accentColorScheme, - layoutMode, - locale, - market, - searchMode, - downloadLocation, - localLibraryLocation, - themeMode, - audioSourceId, - youtubeClientEngine, - discordPresence, - endlessPlayback, - enableConnect, - connectPort, - cacheMusic - ]); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is PreferencesTableData && - other.id == this.id && - other.albumColorSync == this.albumColorSync && - other.amoledDarkTheme == this.amoledDarkTheme && - other.checkUpdate == this.checkUpdate && - other.normalizeAudio == this.normalizeAudio && - other.showSystemTrayIcon == this.showSystemTrayIcon && - other.systemTitleBar == this.systemTitleBar && - other.skipNonMusic == this.skipNonMusic && - other.closeBehavior == this.closeBehavior && - other.accentColorScheme == this.accentColorScheme && - other.layoutMode == this.layoutMode && - other.locale == this.locale && - other.market == this.market && - other.searchMode == this.searchMode && - other.downloadLocation == this.downloadLocation && - other.localLibraryLocation == this.localLibraryLocation && - other.themeMode == this.themeMode && - other.audioSourceId == this.audioSourceId && - other.youtubeClientEngine == this.youtubeClientEngine && - other.discordPresence == this.discordPresence && - other.endlessPlayback == this.endlessPlayback && - other.enableConnect == this.enableConnect && - other.connectPort == this.connectPort && - other.cacheMusic == this.cacheMusic); -} - -class PreferencesTableCompanion extends UpdateCompanion { - final Value id; - final Value albumColorSync; - final Value amoledDarkTheme; - final Value checkUpdate; - final Value normalizeAudio; - final Value showSystemTrayIcon; - final Value systemTitleBar; - final Value skipNonMusic; - final Value closeBehavior; - final Value accentColorScheme; - final Value layoutMode; - final Value locale; - final Value market; - final Value searchMode; - final Value downloadLocation; - final Value> localLibraryLocation; - final Value themeMode; - final Value audioSourceId; - final Value youtubeClientEngine; - final Value discordPresence; - final Value endlessPlayback; - final Value enableConnect; - final Value connectPort; - final Value cacheMusic; - const PreferencesTableCompanion({ - this.id = const Value.absent(), - this.albumColorSync = const Value.absent(), - this.amoledDarkTheme = const Value.absent(), - this.checkUpdate = const Value.absent(), - this.normalizeAudio = const Value.absent(), - this.showSystemTrayIcon = const Value.absent(), - this.systemTitleBar = const Value.absent(), - this.skipNonMusic = const Value.absent(), - this.closeBehavior = const Value.absent(), - this.accentColorScheme = const Value.absent(), - this.layoutMode = const Value.absent(), - this.locale = const Value.absent(), - this.market = const Value.absent(), - this.searchMode = const Value.absent(), - this.downloadLocation = const Value.absent(), - this.localLibraryLocation = const Value.absent(), - this.themeMode = const Value.absent(), - this.audioSourceId = const Value.absent(), - this.youtubeClientEngine = const Value.absent(), - this.discordPresence = const Value.absent(), - this.endlessPlayback = const Value.absent(), - this.enableConnect = const Value.absent(), - this.connectPort = const Value.absent(), - this.cacheMusic = const Value.absent(), - }); - PreferencesTableCompanion.insert({ - this.id = const Value.absent(), - this.albumColorSync = const Value.absent(), - this.amoledDarkTheme = const Value.absent(), - this.checkUpdate = const Value.absent(), - this.normalizeAudio = const Value.absent(), - this.showSystemTrayIcon = const Value.absent(), - this.systemTitleBar = const Value.absent(), - this.skipNonMusic = const Value.absent(), - this.closeBehavior = const Value.absent(), - this.accentColorScheme = const Value.absent(), - this.layoutMode = const Value.absent(), - this.locale = const Value.absent(), - this.market = const Value.absent(), - this.searchMode = const Value.absent(), - this.downloadLocation = const Value.absent(), - this.localLibraryLocation = const Value.absent(), - this.themeMode = const Value.absent(), - this.audioSourceId = const Value.absent(), - this.youtubeClientEngine = const Value.absent(), - this.discordPresence = const Value.absent(), - this.endlessPlayback = const Value.absent(), - this.enableConnect = const Value.absent(), - this.connectPort = const Value.absent(), - this.cacheMusic = const Value.absent(), - }); - static Insertable custom({ - Expression? id, - Expression? albumColorSync, - Expression? amoledDarkTheme, - Expression? checkUpdate, - Expression? normalizeAudio, - Expression? showSystemTrayIcon, - Expression? systemTitleBar, - Expression? skipNonMusic, - Expression? closeBehavior, - Expression? accentColorScheme, - Expression? layoutMode, - Expression? locale, - Expression? market, - Expression? searchMode, - Expression? downloadLocation, - Expression? localLibraryLocation, - Expression? themeMode, - Expression? audioSourceId, - Expression? youtubeClientEngine, - Expression? discordPresence, - Expression? endlessPlayback, - Expression? enableConnect, - Expression? connectPort, - Expression? cacheMusic, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (albumColorSync != null) 'album_color_sync': albumColorSync, - if (amoledDarkTheme != null) 'amoled_dark_theme': amoledDarkTheme, - if (checkUpdate != null) 'check_update': checkUpdate, - if (normalizeAudio != null) 'normalize_audio': normalizeAudio, - if (showSystemTrayIcon != null) - 'show_system_tray_icon': showSystemTrayIcon, - if (systemTitleBar != null) 'system_title_bar': systemTitleBar, - if (skipNonMusic != null) 'skip_non_music': skipNonMusic, - if (closeBehavior != null) 'close_behavior': closeBehavior, - if (accentColorScheme != null) 'accent_color_scheme': accentColorScheme, - if (layoutMode != null) 'layout_mode': layoutMode, - if (locale != null) 'locale': locale, - if (market != null) 'market': market, - if (searchMode != null) 'search_mode': searchMode, - if (downloadLocation != null) 'download_location': downloadLocation, - if (localLibraryLocation != null) - 'local_library_location': localLibraryLocation, - if (themeMode != null) 'theme_mode': themeMode, - if (audioSourceId != null) 'audio_source_id': audioSourceId, - if (youtubeClientEngine != null) - 'youtube_client_engine': youtubeClientEngine, - if (discordPresence != null) 'discord_presence': discordPresence, - if (endlessPlayback != null) 'endless_playback': endlessPlayback, - if (enableConnect != null) 'enable_connect': enableConnect, - if (connectPort != null) 'connect_port': connectPort, - if (cacheMusic != null) 'cache_music': cacheMusic, - }); - } - - PreferencesTableCompanion copyWith( - {Value? id, - Value? albumColorSync, - Value? amoledDarkTheme, - Value? checkUpdate, - Value? normalizeAudio, - Value? showSystemTrayIcon, - Value? systemTitleBar, - Value? skipNonMusic, - Value? closeBehavior, - Value? accentColorScheme, - Value? layoutMode, - Value? locale, - Value? market, - Value? searchMode, - Value? downloadLocation, - Value>? localLibraryLocation, - Value? themeMode, - Value? audioSourceId, - Value? youtubeClientEngine, - Value? discordPresence, - Value? endlessPlayback, - Value? enableConnect, - Value? connectPort, - Value? cacheMusic}) { - return PreferencesTableCompanion( - id: id ?? this.id, - albumColorSync: albumColorSync ?? this.albumColorSync, - amoledDarkTheme: amoledDarkTheme ?? this.amoledDarkTheme, - checkUpdate: checkUpdate ?? this.checkUpdate, - normalizeAudio: normalizeAudio ?? this.normalizeAudio, - showSystemTrayIcon: showSystemTrayIcon ?? this.showSystemTrayIcon, - systemTitleBar: systemTitleBar ?? this.systemTitleBar, - skipNonMusic: skipNonMusic ?? this.skipNonMusic, - closeBehavior: closeBehavior ?? this.closeBehavior, - accentColorScheme: accentColorScheme ?? this.accentColorScheme, - layoutMode: layoutMode ?? this.layoutMode, - locale: locale ?? this.locale, - market: market ?? this.market, - searchMode: searchMode ?? this.searchMode, - downloadLocation: downloadLocation ?? this.downloadLocation, - localLibraryLocation: localLibraryLocation ?? this.localLibraryLocation, - themeMode: themeMode ?? this.themeMode, - audioSourceId: audioSourceId ?? this.audioSourceId, - youtubeClientEngine: youtubeClientEngine ?? this.youtubeClientEngine, - discordPresence: discordPresence ?? this.discordPresence, - endlessPlayback: endlessPlayback ?? this.endlessPlayback, - enableConnect: enableConnect ?? this.enableConnect, - connectPort: connectPort ?? this.connectPort, - cacheMusic: cacheMusic ?? this.cacheMusic, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (albumColorSync.present) { - map['album_color_sync'] = Variable(albumColorSync.value); - } - if (amoledDarkTheme.present) { - map['amoled_dark_theme'] = Variable(amoledDarkTheme.value); - } - if (checkUpdate.present) { - map['check_update'] = Variable(checkUpdate.value); - } - if (normalizeAudio.present) { - map['normalize_audio'] = Variable(normalizeAudio.value); - } - if (showSystemTrayIcon.present) { - map['show_system_tray_icon'] = Variable(showSystemTrayIcon.value); - } - if (systemTitleBar.present) { - map['system_title_bar'] = Variable(systemTitleBar.value); - } - if (skipNonMusic.present) { - map['skip_non_music'] = Variable(skipNonMusic.value); - } - if (closeBehavior.present) { - map['close_behavior'] = Variable($PreferencesTableTable - .$convertercloseBehavior - .toSql(closeBehavior.value)); - } - if (accentColorScheme.present) { - map['accent_color_scheme'] = Variable($PreferencesTableTable - .$converteraccentColorScheme - .toSql(accentColorScheme.value)); - } - if (layoutMode.present) { - map['layout_mode'] = Variable( - $PreferencesTableTable.$converterlayoutMode.toSql(layoutMode.value)); - } - if (locale.present) { - map['locale'] = Variable( - $PreferencesTableTable.$converterlocale.toSql(locale.value)); - } - if (market.present) { - map['market'] = Variable( - $PreferencesTableTable.$convertermarket.toSql(market.value)); - } - if (searchMode.present) { - map['search_mode'] = Variable( - $PreferencesTableTable.$convertersearchMode.toSql(searchMode.value)); - } - if (downloadLocation.present) { - map['download_location'] = Variable(downloadLocation.value); - } - if (localLibraryLocation.present) { - map['local_library_location'] = Variable($PreferencesTableTable - .$converterlocalLibraryLocation - .toSql(localLibraryLocation.value)); - } - if (themeMode.present) { - map['theme_mode'] = Variable( - $PreferencesTableTable.$converterthemeMode.toSql(themeMode.value)); - } - if (audioSourceId.present) { - map['audio_source_id'] = Variable(audioSourceId.value); - } - if (youtubeClientEngine.present) { - map['youtube_client_engine'] = Variable($PreferencesTableTable - .$converteryoutubeClientEngine - .toSql(youtubeClientEngine.value)); - } - if (discordPresence.present) { - map['discord_presence'] = Variable(discordPresence.value); - } - if (endlessPlayback.present) { - map['endless_playback'] = Variable(endlessPlayback.value); - } - if (enableConnect.present) { - map['enable_connect'] = Variable(enableConnect.value); - } - if (connectPort.present) { - map['connect_port'] = Variable(connectPort.value); - } - if (cacheMusic.present) { - map['cache_music'] = Variable(cacheMusic.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('PreferencesTableCompanion(') - ..write('id: $id, ') - ..write('albumColorSync: $albumColorSync, ') - ..write('amoledDarkTheme: $amoledDarkTheme, ') - ..write('checkUpdate: $checkUpdate, ') - ..write('normalizeAudio: $normalizeAudio, ') - ..write('showSystemTrayIcon: $showSystemTrayIcon, ') - ..write('systemTitleBar: $systemTitleBar, ') - ..write('skipNonMusic: $skipNonMusic, ') - ..write('closeBehavior: $closeBehavior, ') - ..write('accentColorScheme: $accentColorScheme, ') - ..write('layoutMode: $layoutMode, ') - ..write('locale: $locale, ') - ..write('market: $market, ') - ..write('searchMode: $searchMode, ') - ..write('downloadLocation: $downloadLocation, ') - ..write('localLibraryLocation: $localLibraryLocation, ') - ..write('themeMode: $themeMode, ') - ..write('audioSourceId: $audioSourceId, ') - ..write('youtubeClientEngine: $youtubeClientEngine, ') - ..write('discordPresence: $discordPresence, ') - ..write('endlessPlayback: $endlessPlayback, ') - ..write('enableConnect: $enableConnect, ') - ..write('connectPort: $connectPort, ') - ..write('cacheMusic: $cacheMusic') - ..write(')')) - .toString(); - } -} - -class $ScrobblerTableTable extends ScrobblerTable - with TableInfo<$ScrobblerTableTable, ScrobblerTableData> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $ScrobblerTableTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); - static const VerificationMeta _createdAtMeta = - const VerificationMeta('createdAt'); - @override - late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); - static const VerificationMeta _usernameMeta = - const VerificationMeta('username'); - @override - late final GeneratedColumn username = GeneratedColumn( - 'username', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - @override - late final GeneratedColumnWithTypeConverter - passwordHash = GeneratedColumn( - 'password_hash', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter( - $ScrobblerTableTable.$converterpasswordHash); - @override - List get $columns => [id, createdAt, username, passwordHash]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'scrobbler_table'; - @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } - if (data.containsKey('created_at')) { - context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); - } - if (data.containsKey('username')) { - context.handle(_usernameMeta, - username.isAcceptableOrUnknown(data['username']!, _usernameMeta)); - } else if (isInserting) { - context.missing(_usernameMeta); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - ScrobblerTableData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return ScrobblerTableData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - createdAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, - username: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}username'])!, - passwordHash: $ScrobblerTableTable.$converterpasswordHash.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}password_hash'])!), - ); - } - - @override - $ScrobblerTableTable createAlias(String alias) { - return $ScrobblerTableTable(attachedDatabase, alias); - } - - static TypeConverter $converterpasswordHash = - EncryptedTextConverter(); -} - -class ScrobblerTableData extends DataClass - implements Insertable { - final int id; - final DateTime createdAt; - final String username; - final DecryptedText passwordHash; - const ScrobblerTableData( - {required this.id, - required this.createdAt, - required this.username, - required this.passwordHash}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['created_at'] = Variable(createdAt); - map['username'] = Variable(username); - { - map['password_hash'] = Variable( - $ScrobblerTableTable.$converterpasswordHash.toSql(passwordHash)); - } - return map; - } - - ScrobblerTableCompanion toCompanion(bool nullToAbsent) { - return ScrobblerTableCompanion( - id: Value(id), - createdAt: Value(createdAt), - username: Value(username), - passwordHash: Value(passwordHash), - ); - } - - factory ScrobblerTableData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return ScrobblerTableData( - id: serializer.fromJson(json['id']), - createdAt: serializer.fromJson(json['createdAt']), - username: serializer.fromJson(json['username']), - passwordHash: serializer.fromJson(json['passwordHash']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'createdAt': serializer.toJson(createdAt), - 'username': serializer.toJson(username), - 'passwordHash': serializer.toJson(passwordHash), - }; - } - - ScrobblerTableData copyWith( - {int? id, - DateTime? createdAt, - String? username, - DecryptedText? passwordHash}) => - ScrobblerTableData( - id: id ?? this.id, - createdAt: createdAt ?? this.createdAt, - username: username ?? this.username, - passwordHash: passwordHash ?? this.passwordHash, - ); - ScrobblerTableData copyWithCompanion(ScrobblerTableCompanion data) { - return ScrobblerTableData( - id: data.id.present ? data.id.value : this.id, - createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, - username: data.username.present ? data.username.value : this.username, - passwordHash: data.passwordHash.present - ? data.passwordHash.value - : this.passwordHash, - ); - } - - @override - String toString() { - return (StringBuffer('ScrobblerTableData(') - ..write('id: $id, ') - ..write('createdAt: $createdAt, ') - ..write('username: $username, ') - ..write('passwordHash: $passwordHash') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash(id, createdAt, username, passwordHash); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ScrobblerTableData && - other.id == this.id && - other.createdAt == this.createdAt && - other.username == this.username && - other.passwordHash == this.passwordHash); -} - -class ScrobblerTableCompanion extends UpdateCompanion { - final Value id; - final Value createdAt; - final Value username; - final Value passwordHash; - const ScrobblerTableCompanion({ - this.id = const Value.absent(), - this.createdAt = const Value.absent(), - this.username = const Value.absent(), - this.passwordHash = const Value.absent(), - }); - ScrobblerTableCompanion.insert({ - this.id = const Value.absent(), - this.createdAt = const Value.absent(), - required String username, - required DecryptedText passwordHash, - }) : username = Value(username), - passwordHash = Value(passwordHash); - static Insertable custom({ - Expression? id, - Expression? createdAt, - Expression? username, - Expression? passwordHash, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (createdAt != null) 'created_at': createdAt, - if (username != null) 'username': username, - if (passwordHash != null) 'password_hash': passwordHash, - }); - } - - ScrobblerTableCompanion copyWith( - {Value? id, - Value? createdAt, - Value? username, - Value? passwordHash}) { - return ScrobblerTableCompanion( - id: id ?? this.id, - createdAt: createdAt ?? this.createdAt, - username: username ?? this.username, - passwordHash: passwordHash ?? this.passwordHash, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (createdAt.present) { - map['created_at'] = Variable(createdAt.value); - } - if (username.present) { - map['username'] = Variable(username.value); - } - if (passwordHash.present) { - map['password_hash'] = Variable($ScrobblerTableTable - .$converterpasswordHash - .toSql(passwordHash.value)); - } - return map; - } - - @override - String toString() { - return (StringBuffer('ScrobblerTableCompanion(') - ..write('id: $id, ') - ..write('createdAt: $createdAt, ') - ..write('username: $username, ') - ..write('passwordHash: $passwordHash') - ..write(')')) - .toString(); - } -} - -class $SkipSegmentTableTable extends SkipSegmentTable - with TableInfo<$SkipSegmentTableTable, SkipSegmentTableData> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $SkipSegmentTableTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); - static const VerificationMeta _startMeta = const VerificationMeta('start'); - @override - late final GeneratedColumn start = GeneratedColumn( - 'start', aliasedName, false, - type: DriftSqlType.int, requiredDuringInsert: true); - static const VerificationMeta _endMeta = const VerificationMeta('end'); - @override - late final GeneratedColumn end = GeneratedColumn( - 'end', aliasedName, false, - type: DriftSqlType.int, requiredDuringInsert: true); - static const VerificationMeta _trackIdMeta = - const VerificationMeta('trackId'); - @override - late final GeneratedColumn trackId = GeneratedColumn( - 'track_id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _createdAtMeta = - const VerificationMeta('createdAt'); - @override - late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); - @override - List get $columns => [id, start, end, trackId, createdAt]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'skip_segment_table'; - @override - VerificationContext validateIntegrity( - Insertable instance, - {bool isInserting = false}) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } - if (data.containsKey('start')) { - context.handle( - _startMeta, start.isAcceptableOrUnknown(data['start']!, _startMeta)); - } else if (isInserting) { - context.missing(_startMeta); - } - if (data.containsKey('end')) { - context.handle( - _endMeta, end.isAcceptableOrUnknown(data['end']!, _endMeta)); - } else if (isInserting) { - context.missing(_endMeta); - } - if (data.containsKey('track_id')) { - context.handle(_trackIdMeta, - trackId.isAcceptableOrUnknown(data['track_id']!, _trackIdMeta)); - } else if (isInserting) { - context.missing(_trackIdMeta); - } - if (data.containsKey('created_at')) { - context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - SkipSegmentTableData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return SkipSegmentTableData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - start: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}start'])!, - end: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}end'])!, - trackId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}track_id'])!, - createdAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, - ); - } - - @override - $SkipSegmentTableTable createAlias(String alias) { - return $SkipSegmentTableTable(attachedDatabase, alias); - } -} - -class SkipSegmentTableData extends DataClass - implements Insertable { - final int id; - final int start; - final int end; - final String trackId; - final DateTime createdAt; - const SkipSegmentTableData( - {required this.id, - required this.start, - required this.end, - required this.trackId, - required this.createdAt}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['start'] = Variable(start); - map['end'] = Variable(end); - map['track_id'] = Variable(trackId); - map['created_at'] = Variable(createdAt); - return map; - } - - SkipSegmentTableCompanion toCompanion(bool nullToAbsent) { - return SkipSegmentTableCompanion( - id: Value(id), - start: Value(start), - end: Value(end), - trackId: Value(trackId), - createdAt: Value(createdAt), - ); - } - - factory SkipSegmentTableData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return SkipSegmentTableData( - id: serializer.fromJson(json['id']), - start: serializer.fromJson(json['start']), - end: serializer.fromJson(json['end']), - trackId: serializer.fromJson(json['trackId']), - createdAt: serializer.fromJson(json['createdAt']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'start': serializer.toJson(start), - 'end': serializer.toJson(end), - 'trackId': serializer.toJson(trackId), - 'createdAt': serializer.toJson(createdAt), - }; - } - - SkipSegmentTableData copyWith( - {int? id, - int? start, - int? end, - String? trackId, - DateTime? createdAt}) => - SkipSegmentTableData( - id: id ?? this.id, - start: start ?? this.start, - end: end ?? this.end, - trackId: trackId ?? this.trackId, - createdAt: createdAt ?? this.createdAt, - ); - SkipSegmentTableData copyWithCompanion(SkipSegmentTableCompanion data) { - return SkipSegmentTableData( - id: data.id.present ? data.id.value : this.id, - start: data.start.present ? data.start.value : this.start, - end: data.end.present ? data.end.value : this.end, - trackId: data.trackId.present ? data.trackId.value : this.trackId, - createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, - ); - } - - @override - String toString() { - return (StringBuffer('SkipSegmentTableData(') - ..write('id: $id, ') - ..write('start: $start, ') - ..write('end: $end, ') - ..write('trackId: $trackId, ') - ..write('createdAt: $createdAt') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash(id, start, end, trackId, createdAt); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is SkipSegmentTableData && - other.id == this.id && - other.start == this.start && - other.end == this.end && - other.trackId == this.trackId && - other.createdAt == this.createdAt); -} - -class SkipSegmentTableCompanion extends UpdateCompanion { - final Value id; - final Value start; - final Value end; - final Value trackId; - final Value createdAt; - const SkipSegmentTableCompanion({ - this.id = const Value.absent(), - this.start = const Value.absent(), - this.end = const Value.absent(), - this.trackId = const Value.absent(), - this.createdAt = const Value.absent(), - }); - SkipSegmentTableCompanion.insert({ - this.id = const Value.absent(), - required int start, - required int end, - required String trackId, - this.createdAt = const Value.absent(), - }) : start = Value(start), - end = Value(end), - trackId = Value(trackId); - static Insertable custom({ - Expression? id, - Expression? start, - Expression? end, - Expression? trackId, - Expression? createdAt, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (start != null) 'start': start, - if (end != null) 'end': end, - if (trackId != null) 'track_id': trackId, - if (createdAt != null) 'created_at': createdAt, - }); - } - - SkipSegmentTableCompanion copyWith( - {Value? id, - Value? start, - Value? end, - Value? trackId, - Value? createdAt}) { - return SkipSegmentTableCompanion( - id: id ?? this.id, - start: start ?? this.start, - end: end ?? this.end, - trackId: trackId ?? this.trackId, - createdAt: createdAt ?? this.createdAt, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (start.present) { - map['start'] = Variable(start.value); - } - if (end.present) { - map['end'] = Variable(end.value); - } - if (trackId.present) { - map['track_id'] = Variable(trackId.value); - } - if (createdAt.present) { - map['created_at'] = Variable(createdAt.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('SkipSegmentTableCompanion(') - ..write('id: $id, ') - ..write('start: $start, ') - ..write('end: $end, ') - ..write('trackId: $trackId, ') - ..write('createdAt: $createdAt') - ..write(')')) - .toString(); - } -} - -class $SourceMatchTableTable extends SourceMatchTable - with TableInfo<$SourceMatchTableTable, SourceMatchTableData> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $SourceMatchTableTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); - static const VerificationMeta _trackIdMeta = - const VerificationMeta('trackId'); - @override - late final GeneratedColumn trackId = GeneratedColumn( - 'track_id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _sourceInfoMeta = - const VerificationMeta('sourceInfo'); - @override - late final GeneratedColumn sourceInfo = GeneratedColumn( - 'source_info', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: const Constant("{}")); - static const VerificationMeta _sourceTypeMeta = - const VerificationMeta('sourceType'); - @override - late final GeneratedColumn sourceType = GeneratedColumn( - 'source_type', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _createdAtMeta = - const VerificationMeta('createdAt'); - @override - late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); - @override - List get $columns => - [id, trackId, sourceInfo, sourceType, createdAt]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'source_match_table'; - @override - VerificationContext validateIntegrity( - Insertable instance, - {bool isInserting = false}) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } - if (data.containsKey('track_id')) { - context.handle(_trackIdMeta, - trackId.isAcceptableOrUnknown(data['track_id']!, _trackIdMeta)); - } else if (isInserting) { - context.missing(_trackIdMeta); - } - if (data.containsKey('source_info')) { - context.handle( - _sourceInfoMeta, - sourceInfo.isAcceptableOrUnknown( - data['source_info']!, _sourceInfoMeta)); - } - if (data.containsKey('source_type')) { - context.handle( - _sourceTypeMeta, - sourceType.isAcceptableOrUnknown( - data['source_type']!, _sourceTypeMeta)); - } else if (isInserting) { - context.missing(_sourceTypeMeta); - } - if (data.containsKey('created_at')) { - context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - SourceMatchTableData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return SourceMatchTableData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - trackId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}track_id'])!, - sourceInfo: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}source_info'])!, - sourceType: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}source_type'])!, - createdAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, - ); - } - - @override - $SourceMatchTableTable createAlias(String alias) { - return $SourceMatchTableTable(attachedDatabase, alias); - } -} - -class SourceMatchTableData extends DataClass - implements Insertable { - final int id; - final String trackId; - final String sourceInfo; - final String sourceType; - final DateTime createdAt; - const SourceMatchTableData( - {required this.id, - required this.trackId, - required this.sourceInfo, - required this.sourceType, - required this.createdAt}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['track_id'] = Variable(trackId); - map['source_info'] = Variable(sourceInfo); - map['source_type'] = Variable(sourceType); - map['created_at'] = Variable(createdAt); - return map; - } - - SourceMatchTableCompanion toCompanion(bool nullToAbsent) { - return SourceMatchTableCompanion( - id: Value(id), - trackId: Value(trackId), - sourceInfo: Value(sourceInfo), - sourceType: Value(sourceType), - createdAt: Value(createdAt), - ); - } - - factory SourceMatchTableData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return SourceMatchTableData( - id: serializer.fromJson(json['id']), - trackId: serializer.fromJson(json['trackId']), - sourceInfo: serializer.fromJson(json['sourceInfo']), - sourceType: serializer.fromJson(json['sourceType']), - createdAt: serializer.fromJson(json['createdAt']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'trackId': serializer.toJson(trackId), - 'sourceInfo': serializer.toJson(sourceInfo), - 'sourceType': serializer.toJson(sourceType), - 'createdAt': serializer.toJson(createdAt), - }; - } - - SourceMatchTableData copyWith( - {int? id, - String? trackId, - String? sourceInfo, - String? sourceType, - DateTime? createdAt}) => - SourceMatchTableData( - id: id ?? this.id, - trackId: trackId ?? this.trackId, - sourceInfo: sourceInfo ?? this.sourceInfo, - sourceType: sourceType ?? this.sourceType, - createdAt: createdAt ?? this.createdAt, - ); - SourceMatchTableData copyWithCompanion(SourceMatchTableCompanion data) { - return SourceMatchTableData( - id: data.id.present ? data.id.value : this.id, - trackId: data.trackId.present ? data.trackId.value : this.trackId, - sourceInfo: - data.sourceInfo.present ? data.sourceInfo.value : this.sourceInfo, - sourceType: - data.sourceType.present ? data.sourceType.value : this.sourceType, - createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, - ); - } - - @override - String toString() { - return (StringBuffer('SourceMatchTableData(') - ..write('id: $id, ') - ..write('trackId: $trackId, ') - ..write('sourceInfo: $sourceInfo, ') - ..write('sourceType: $sourceType, ') - ..write('createdAt: $createdAt') - ..write(')')) - .toString(); - } - - @override - int get hashCode => - Object.hash(id, trackId, sourceInfo, sourceType, createdAt); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is SourceMatchTableData && - other.id == this.id && - other.trackId == this.trackId && - other.sourceInfo == this.sourceInfo && - other.sourceType == this.sourceType && - other.createdAt == this.createdAt); -} - -class SourceMatchTableCompanion extends UpdateCompanion { - final Value id; - final Value trackId; - final Value sourceInfo; - final Value sourceType; - final Value createdAt; - const SourceMatchTableCompanion({ - this.id = const Value.absent(), - this.trackId = const Value.absent(), - this.sourceInfo = const Value.absent(), - this.sourceType = const Value.absent(), - this.createdAt = const Value.absent(), - }); - SourceMatchTableCompanion.insert({ - this.id = const Value.absent(), - required String trackId, - this.sourceInfo = const Value.absent(), - required String sourceType, - this.createdAt = const Value.absent(), - }) : trackId = Value(trackId), - sourceType = Value(sourceType); - static Insertable custom({ - Expression? id, - Expression? trackId, - Expression? sourceInfo, - Expression? sourceType, - Expression? createdAt, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (trackId != null) 'track_id': trackId, - if (sourceInfo != null) 'source_info': sourceInfo, - if (sourceType != null) 'source_type': sourceType, - if (createdAt != null) 'created_at': createdAt, - }); - } - - SourceMatchTableCompanion copyWith( - {Value? id, - Value? trackId, - Value? sourceInfo, - Value? sourceType, - Value? createdAt}) { - return SourceMatchTableCompanion( - id: id ?? this.id, - trackId: trackId ?? this.trackId, - sourceInfo: sourceInfo ?? this.sourceInfo, - sourceType: sourceType ?? this.sourceType, - createdAt: createdAt ?? this.createdAt, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (trackId.present) { - map['track_id'] = Variable(trackId.value); - } - if (sourceInfo.present) { - map['source_info'] = Variable(sourceInfo.value); - } - if (sourceType.present) { - map['source_type'] = Variable(sourceType.value); - } - if (createdAt.present) { - map['created_at'] = Variable(createdAt.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('SourceMatchTableCompanion(') - ..write('id: $id, ') - ..write('trackId: $trackId, ') - ..write('sourceInfo: $sourceInfo, ') - ..write('sourceType: $sourceType, ') - ..write('createdAt: $createdAt') - ..write(')')) - .toString(); - } -} - -class $AudioPlayerStateTableTable extends AudioPlayerStateTable - with TableInfo<$AudioPlayerStateTableTable, AudioPlayerStateTableData> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $AudioPlayerStateTableTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); - static const VerificationMeta _playingMeta = - const VerificationMeta('playing'); - @override - late final GeneratedColumn playing = GeneratedColumn( - 'playing', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: true, - defaultConstraints: - GeneratedColumn.constraintIsAlways('CHECK ("playing" IN (0, 1))')); - @override - late final GeneratedColumnWithTypeConverter loopMode = - GeneratedColumn('loop_mode', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter( - $AudioPlayerStateTableTable.$converterloopMode); - static const VerificationMeta _shuffledMeta = - const VerificationMeta('shuffled'); - @override - late final GeneratedColumn shuffled = GeneratedColumn( - 'shuffled', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: true, - defaultConstraints: - GeneratedColumn.constraintIsAlways('CHECK ("shuffled" IN (0, 1))')); - @override - late final GeneratedColumnWithTypeConverter, String> - collections = GeneratedColumn('collections', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter>( - $AudioPlayerStateTableTable.$convertercollections); - @override - late final GeneratedColumnWithTypeConverter, String> - tracks = GeneratedColumn('tracks', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: const Constant("[]")) - .withConverter>( - $AudioPlayerStateTableTable.$convertertracks); - static const VerificationMeta _currentIndexMeta = - const VerificationMeta('currentIndex'); - @override - late final GeneratedColumn currentIndex = GeneratedColumn( - 'current_index', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0)); - @override - List get $columns => - [id, playing, loopMode, shuffled, collections, tracks, currentIndex]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'audio_player_state_table'; - @override - VerificationContext validateIntegrity( - Insertable instance, - {bool isInserting = false}) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } - if (data.containsKey('playing')) { - context.handle(_playingMeta, - playing.isAcceptableOrUnknown(data['playing']!, _playingMeta)); - } else if (isInserting) { - context.missing(_playingMeta); - } - if (data.containsKey('shuffled')) { - context.handle(_shuffledMeta, - shuffled.isAcceptableOrUnknown(data['shuffled']!, _shuffledMeta)); - } else if (isInserting) { - context.missing(_shuffledMeta); - } - if (data.containsKey('current_index')) { - context.handle( - _currentIndexMeta, - currentIndex.isAcceptableOrUnknown( - data['current_index']!, _currentIndexMeta)); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - AudioPlayerStateTableData map(Map data, - {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return AudioPlayerStateTableData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - playing: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}playing'])!, - loopMode: $AudioPlayerStateTableTable.$converterloopMode.fromSql( - attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}loop_mode'])!), - shuffled: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}shuffled'])!, - collections: $AudioPlayerStateTableTable.$convertercollections.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}collections'])!), - tracks: $AudioPlayerStateTableTable.$convertertracks.fromSql( - attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}tracks'])!), - currentIndex: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}current_index'])!, - ); - } - - @override - $AudioPlayerStateTableTable createAlias(String alias) { - return $AudioPlayerStateTableTable(attachedDatabase, alias); - } - - static JsonTypeConverter2 $converterloopMode = - const EnumNameConverter(PlaylistMode.values); - static TypeConverter, String> $convertercollections = - const StringListConverter(); - static TypeConverter, String> $convertertracks = - const SpotubeTrackObjectListConverter(); -} - -class AudioPlayerStateTableData extends DataClass - implements Insertable { - final int id; - final bool playing; - final PlaylistMode loopMode; - final bool shuffled; - final List collections; - final List tracks; - final int currentIndex; - const AudioPlayerStateTableData( - {required this.id, - required this.playing, - required this.loopMode, - required this.shuffled, - required this.collections, - required this.tracks, - required this.currentIndex}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['playing'] = Variable(playing); - { - map['loop_mode'] = Variable( - $AudioPlayerStateTableTable.$converterloopMode.toSql(loopMode)); - } - map['shuffled'] = Variable(shuffled); - { - map['collections'] = Variable( - $AudioPlayerStateTableTable.$convertercollections.toSql(collections)); - } - { - map['tracks'] = Variable( - $AudioPlayerStateTableTable.$convertertracks.toSql(tracks)); - } - map['current_index'] = Variable(currentIndex); - return map; - } - - AudioPlayerStateTableCompanion toCompanion(bool nullToAbsent) { - return AudioPlayerStateTableCompanion( - id: Value(id), - playing: Value(playing), - loopMode: Value(loopMode), - shuffled: Value(shuffled), - collections: Value(collections), - tracks: Value(tracks), - currentIndex: Value(currentIndex), - ); - } - - factory AudioPlayerStateTableData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return AudioPlayerStateTableData( - id: serializer.fromJson(json['id']), - playing: serializer.fromJson(json['playing']), - loopMode: $AudioPlayerStateTableTable.$converterloopMode - .fromJson(serializer.fromJson(json['loopMode'])), - shuffled: serializer.fromJson(json['shuffled']), - collections: serializer.fromJson>(json['collections']), - tracks: serializer.fromJson>(json['tracks']), - currentIndex: serializer.fromJson(json['currentIndex']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'playing': serializer.toJson(playing), - 'loopMode': serializer.toJson( - $AudioPlayerStateTableTable.$converterloopMode.toJson(loopMode)), - 'shuffled': serializer.toJson(shuffled), - 'collections': serializer.toJson>(collections), - 'tracks': serializer.toJson>(tracks), - 'currentIndex': serializer.toJson(currentIndex), - }; - } - - AudioPlayerStateTableData copyWith( - {int? id, - bool? playing, - PlaylistMode? loopMode, - bool? shuffled, - List? collections, - List? tracks, - int? currentIndex}) => - AudioPlayerStateTableData( - id: id ?? this.id, - playing: playing ?? this.playing, - loopMode: loopMode ?? this.loopMode, - shuffled: shuffled ?? this.shuffled, - collections: collections ?? this.collections, - tracks: tracks ?? this.tracks, - currentIndex: currentIndex ?? this.currentIndex, - ); - AudioPlayerStateTableData copyWithCompanion( - AudioPlayerStateTableCompanion data) { - return AudioPlayerStateTableData( - id: data.id.present ? data.id.value : this.id, - playing: data.playing.present ? data.playing.value : this.playing, - loopMode: data.loopMode.present ? data.loopMode.value : this.loopMode, - shuffled: data.shuffled.present ? data.shuffled.value : this.shuffled, - collections: - data.collections.present ? data.collections.value : this.collections, - tracks: data.tracks.present ? data.tracks.value : this.tracks, - currentIndex: data.currentIndex.present - ? data.currentIndex.value - : this.currentIndex, - ); - } - - @override - String toString() { - return (StringBuffer('AudioPlayerStateTableData(') - ..write('id: $id, ') - ..write('playing: $playing, ') - ..write('loopMode: $loopMode, ') - ..write('shuffled: $shuffled, ') - ..write('collections: $collections, ') - ..write('tracks: $tracks, ') - ..write('currentIndex: $currentIndex') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash( - id, playing, loopMode, shuffled, collections, tracks, currentIndex); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is AudioPlayerStateTableData && - other.id == this.id && - other.playing == this.playing && - other.loopMode == this.loopMode && - other.shuffled == this.shuffled && - other.collections == this.collections && - other.tracks == this.tracks && - other.currentIndex == this.currentIndex); -} - -class AudioPlayerStateTableCompanion - extends UpdateCompanion { - final Value id; - final Value playing; - final Value loopMode; - final Value shuffled; - final Value> collections; - final Value> tracks; - final Value currentIndex; - const AudioPlayerStateTableCompanion({ - this.id = const Value.absent(), - this.playing = const Value.absent(), - this.loopMode = const Value.absent(), - this.shuffled = const Value.absent(), - this.collections = const Value.absent(), - this.tracks = const Value.absent(), - this.currentIndex = const Value.absent(), - }); - AudioPlayerStateTableCompanion.insert({ - this.id = const Value.absent(), - required bool playing, - required PlaylistMode loopMode, - required bool shuffled, - required List collections, - this.tracks = const Value.absent(), - this.currentIndex = const Value.absent(), - }) : playing = Value(playing), - loopMode = Value(loopMode), - shuffled = Value(shuffled), - collections = Value(collections); - static Insertable custom({ - Expression? id, - Expression? playing, - Expression? loopMode, - Expression? shuffled, - Expression? collections, - Expression? tracks, - Expression? currentIndex, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (playing != null) 'playing': playing, - if (loopMode != null) 'loop_mode': loopMode, - if (shuffled != null) 'shuffled': shuffled, - if (collections != null) 'collections': collections, - if (tracks != null) 'tracks': tracks, - if (currentIndex != null) 'current_index': currentIndex, - }); - } - - AudioPlayerStateTableCompanion copyWith( - {Value? id, - Value? playing, - Value? loopMode, - Value? shuffled, - Value>? collections, - Value>? tracks, - Value? currentIndex}) { - return AudioPlayerStateTableCompanion( - id: id ?? this.id, - playing: playing ?? this.playing, - loopMode: loopMode ?? this.loopMode, - shuffled: shuffled ?? this.shuffled, - collections: collections ?? this.collections, - tracks: tracks ?? this.tracks, - currentIndex: currentIndex ?? this.currentIndex, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (playing.present) { - map['playing'] = Variable(playing.value); - } - if (loopMode.present) { - map['loop_mode'] = Variable( - $AudioPlayerStateTableTable.$converterloopMode.toSql(loopMode.value)); - } - if (shuffled.present) { - map['shuffled'] = Variable(shuffled.value); - } - if (collections.present) { - map['collections'] = Variable($AudioPlayerStateTableTable - .$convertercollections - .toSql(collections.value)); - } - if (tracks.present) { - map['tracks'] = Variable( - $AudioPlayerStateTableTable.$convertertracks.toSql(tracks.value)); - } - if (currentIndex.present) { - map['current_index'] = Variable(currentIndex.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('AudioPlayerStateTableCompanion(') - ..write('id: $id, ') - ..write('playing: $playing, ') - ..write('loopMode: $loopMode, ') - ..write('shuffled: $shuffled, ') - ..write('collections: $collections, ') - ..write('tracks: $tracks, ') - ..write('currentIndex: $currentIndex') - ..write(')')) - .toString(); - } -} - -class $HistoryTableTable extends HistoryTable - with TableInfo<$HistoryTableTable, HistoryTableData> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $HistoryTableTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); - static const VerificationMeta _createdAtMeta = - const VerificationMeta('createdAt'); - @override - late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); - @override - late final GeneratedColumnWithTypeConverter type = - GeneratedColumn('type', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter($HistoryTableTable.$convertertype); - static const VerificationMeta _itemIdMeta = const VerificationMeta('itemId'); - @override - late final GeneratedColumn itemId = GeneratedColumn( - 'item_id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - @override - late final GeneratedColumnWithTypeConverter, String> - data = GeneratedColumn('data', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter>( - $HistoryTableTable.$converterdata); - @override - List get $columns => [id, createdAt, type, itemId, data]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'history_table'; - @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } - if (data.containsKey('created_at')) { - context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); - } - if (data.containsKey('item_id')) { - context.handle(_itemIdMeta, - itemId.isAcceptableOrUnknown(data['item_id']!, _itemIdMeta)); - } else if (isInserting) { - context.missing(_itemIdMeta); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - HistoryTableData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return HistoryTableData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - createdAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, - type: $HistoryTableTable.$convertertype.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}type'])!), - itemId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}item_id'])!, - data: $HistoryTableTable.$converterdata.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}data'])!), - ); - } - - @override - $HistoryTableTable createAlias(String alias) { - return $HistoryTableTable(attachedDatabase, alias); - } - - static JsonTypeConverter2 $convertertype = - const EnumNameConverter(HistoryEntryType.values); - static TypeConverter, String> $converterdata = - const MapTypeConverter(); -} - -class HistoryTableData extends DataClass - implements Insertable { - final int id; - final DateTime createdAt; - final HistoryEntryType type; - final String itemId; - final Map data; - const HistoryTableData( - {required this.id, - required this.createdAt, - required this.type, - required this.itemId, - required this.data}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['created_at'] = Variable(createdAt); - { - map['type'] = - Variable($HistoryTableTable.$convertertype.toSql(type)); - } - map['item_id'] = Variable(itemId); - { - map['data'] = - Variable($HistoryTableTable.$converterdata.toSql(data)); - } - return map; - } - - HistoryTableCompanion toCompanion(bool nullToAbsent) { - return HistoryTableCompanion( - id: Value(id), - createdAt: Value(createdAt), - type: Value(type), - itemId: Value(itemId), - data: Value(data), - ); - } - - factory HistoryTableData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return HistoryTableData( - id: serializer.fromJson(json['id']), - createdAt: serializer.fromJson(json['createdAt']), - type: $HistoryTableTable.$convertertype - .fromJson(serializer.fromJson(json['type'])), - itemId: serializer.fromJson(json['itemId']), - data: serializer.fromJson>(json['data']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'createdAt': serializer.toJson(createdAt), - 'type': serializer - .toJson($HistoryTableTable.$convertertype.toJson(type)), - 'itemId': serializer.toJson(itemId), - 'data': serializer.toJson>(data), - }; - } - - HistoryTableData copyWith( - {int? id, - DateTime? createdAt, - HistoryEntryType? type, - String? itemId, - Map? data}) => - HistoryTableData( - id: id ?? this.id, - createdAt: createdAt ?? this.createdAt, - type: type ?? this.type, - itemId: itemId ?? this.itemId, - data: data ?? this.data, - ); - HistoryTableData copyWithCompanion(HistoryTableCompanion data) { - return HistoryTableData( - id: data.id.present ? data.id.value : this.id, - createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, - type: data.type.present ? data.type.value : this.type, - itemId: data.itemId.present ? data.itemId.value : this.itemId, - data: data.data.present ? data.data.value : this.data, - ); - } - - @override - String toString() { - return (StringBuffer('HistoryTableData(') - ..write('id: $id, ') - ..write('createdAt: $createdAt, ') - ..write('type: $type, ') - ..write('itemId: $itemId, ') - ..write('data: $data') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash(id, createdAt, type, itemId, data); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is HistoryTableData && - other.id == this.id && - other.createdAt == this.createdAt && - other.type == this.type && - other.itemId == this.itemId && - other.data == this.data); -} - -class HistoryTableCompanion extends UpdateCompanion { - final Value id; - final Value createdAt; - final Value type; - final Value itemId; - final Value> data; - const HistoryTableCompanion({ - this.id = const Value.absent(), - this.createdAt = const Value.absent(), - this.type = const Value.absent(), - this.itemId = const Value.absent(), - this.data = const Value.absent(), - }); - HistoryTableCompanion.insert({ - this.id = const Value.absent(), - this.createdAt = const Value.absent(), - required HistoryEntryType type, - required String itemId, - required Map data, - }) : type = Value(type), - itemId = Value(itemId), - data = Value(data); - static Insertable custom({ - Expression? id, - Expression? createdAt, - Expression? type, - Expression? itemId, - Expression? data, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (createdAt != null) 'created_at': createdAt, - if (type != null) 'type': type, - if (itemId != null) 'item_id': itemId, - if (data != null) 'data': data, - }); - } - - HistoryTableCompanion copyWith( - {Value? id, - Value? createdAt, - Value? type, - Value? itemId, - Value>? data}) { - return HistoryTableCompanion( - id: id ?? this.id, - createdAt: createdAt ?? this.createdAt, - type: type ?? this.type, - itemId: itemId ?? this.itemId, - data: data ?? this.data, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (createdAt.present) { - map['created_at'] = Variable(createdAt.value); - } - if (type.present) { - map['type'] = - Variable($HistoryTableTable.$convertertype.toSql(type.value)); - } - if (itemId.present) { - map['item_id'] = Variable(itemId.value); - } - if (data.present) { - map['data'] = - Variable($HistoryTableTable.$converterdata.toSql(data.value)); - } - return map; - } - - @override - String toString() { - return (StringBuffer('HistoryTableCompanion(') - ..write('id: $id, ') - ..write('createdAt: $createdAt, ') - ..write('type: $type, ') - ..write('itemId: $itemId, ') - ..write('data: $data') - ..write(')')) - .toString(); - } -} - -class $LyricsTableTable extends LyricsTable - with TableInfo<$LyricsTableTable, LyricsTableData> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $LyricsTableTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); - static const VerificationMeta _trackIdMeta = - const VerificationMeta('trackId'); - @override - late final GeneratedColumn trackId = GeneratedColumn( - 'track_id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - @override - late final GeneratedColumnWithTypeConverter data = - GeneratedColumn('data', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter($LyricsTableTable.$converterdata); - @override - List get $columns => [id, trackId, data]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'lyrics_table'; - @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } - if (data.containsKey('track_id')) { - context.handle(_trackIdMeta, - trackId.isAcceptableOrUnknown(data['track_id']!, _trackIdMeta)); - } else if (isInserting) { - context.missing(_trackIdMeta); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - LyricsTableData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return LyricsTableData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - trackId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}track_id'])!, - data: $LyricsTableTable.$converterdata.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}data'])!), - ); - } - - @override - $LyricsTableTable createAlias(String alias) { - return $LyricsTableTable(attachedDatabase, alias); - } - - static TypeConverter $converterdata = - SubtitleTypeConverter(); -} - -class LyricsTableData extends DataClass implements Insertable { - final int id; - final String trackId; - final SubtitleSimple data; - const LyricsTableData( - {required this.id, required this.trackId, required this.data}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['track_id'] = Variable(trackId); - { - map['data'] = - Variable($LyricsTableTable.$converterdata.toSql(data)); - } - return map; - } - - LyricsTableCompanion toCompanion(bool nullToAbsent) { - return LyricsTableCompanion( - id: Value(id), - trackId: Value(trackId), - data: Value(data), - ); - } - - factory LyricsTableData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return LyricsTableData( - id: serializer.fromJson(json['id']), - trackId: serializer.fromJson(json['trackId']), - data: serializer.fromJson(json['data']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'trackId': serializer.toJson(trackId), - 'data': serializer.toJson(data), - }; - } - - LyricsTableData copyWith({int? id, String? trackId, SubtitleSimple? data}) => - LyricsTableData( - id: id ?? this.id, - trackId: trackId ?? this.trackId, - data: data ?? this.data, - ); - LyricsTableData copyWithCompanion(LyricsTableCompanion data) { - return LyricsTableData( - id: data.id.present ? data.id.value : this.id, - trackId: data.trackId.present ? data.trackId.value : this.trackId, - data: data.data.present ? data.data.value : this.data, - ); - } - - @override - String toString() { - return (StringBuffer('LyricsTableData(') - ..write('id: $id, ') - ..write('trackId: $trackId, ') - ..write('data: $data') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash(id, trackId, data); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is LyricsTableData && - other.id == this.id && - other.trackId == this.trackId && - other.data == this.data); -} - -class LyricsTableCompanion extends UpdateCompanion { - final Value id; - final Value trackId; - final Value data; - const LyricsTableCompanion({ - this.id = const Value.absent(), - this.trackId = const Value.absent(), - this.data = const Value.absent(), - }); - LyricsTableCompanion.insert({ - this.id = const Value.absent(), - required String trackId, - required SubtitleSimple data, - }) : trackId = Value(trackId), - data = Value(data); - static Insertable custom({ - Expression? id, - Expression? trackId, - Expression? data, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (trackId != null) 'track_id': trackId, - if (data != null) 'data': data, - }); - } - - LyricsTableCompanion copyWith( - {Value? id, Value? trackId, Value? data}) { - return LyricsTableCompanion( - id: id ?? this.id, - trackId: trackId ?? this.trackId, - data: data ?? this.data, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (trackId.present) { - map['track_id'] = Variable(trackId.value); - } - if (data.present) { - map['data'] = - Variable($LyricsTableTable.$converterdata.toSql(data.value)); - } - return map; - } - - @override - String toString() { - return (StringBuffer('LyricsTableCompanion(') - ..write('id: $id, ') - ..write('trackId: $trackId, ') - ..write('data: $data') - ..write(')')) - .toString(); - } -} - -class $PluginsTableTable extends PluginsTable - with TableInfo<$PluginsTableTable, PluginsTableData> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $PluginsTableTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); - static const VerificationMeta _nameMeta = const VerificationMeta('name'); - @override - late final GeneratedColumn name = GeneratedColumn( - 'name', aliasedName, false, - additionalChecks: - GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 50), - type: DriftSqlType.string, - requiredDuringInsert: true); - static const VerificationMeta _descriptionMeta = - const VerificationMeta('description'); - @override - late final GeneratedColumn description = GeneratedColumn( - 'description', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _versionMeta = - const VerificationMeta('version'); - @override - late final GeneratedColumn version = GeneratedColumn( - 'version', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _authorMeta = const VerificationMeta('author'); - @override - late final GeneratedColumn author = GeneratedColumn( - 'author', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _entryPointMeta = - const VerificationMeta('entryPoint'); - @override - late final GeneratedColumn entryPoint = GeneratedColumn( - 'entry_point', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - @override - late final GeneratedColumnWithTypeConverter, String> apis = - GeneratedColumn('apis', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter>($PluginsTableTable.$converterapis); - @override - late final GeneratedColumnWithTypeConverter, String> abilities = - GeneratedColumn('abilities', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter>($PluginsTableTable.$converterabilities); - static const VerificationMeta _selectedForMetadataMeta = - const VerificationMeta('selectedForMetadata'); - @override - late final GeneratedColumn selectedForMetadata = GeneratedColumn( - 'selected_for_metadata', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("selected_for_metadata" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _selectedForAudioSourceMeta = - const VerificationMeta('selectedForAudioSource'); - @override - late final GeneratedColumn selectedForAudioSource = - GeneratedColumn('selected_for_audio_source', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("selected_for_audio_source" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _repositoryMeta = - const VerificationMeta('repository'); - @override - late final GeneratedColumn repository = GeneratedColumn( - 'repository', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _pluginApiVersionMeta = - const VerificationMeta('pluginApiVersion'); - @override - late final GeneratedColumn pluginApiVersion = GeneratedColumn( - 'plugin_api_version', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: const Constant('2.0.0')); - @override - List get $columns => [ - id, - name, - description, - version, - author, - entryPoint, - apis, - abilities, - selectedForMetadata, - selectedForAudioSource, - repository, - pluginApiVersion - ]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'plugins_table'; - @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } - if (data.containsKey('name')) { - context.handle( - _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); - } else if (isInserting) { - context.missing(_nameMeta); - } - if (data.containsKey('description')) { - context.handle( - _descriptionMeta, - description.isAcceptableOrUnknown( - data['description']!, _descriptionMeta)); - } else if (isInserting) { - context.missing(_descriptionMeta); - } - if (data.containsKey('version')) { - context.handle(_versionMeta, - version.isAcceptableOrUnknown(data['version']!, _versionMeta)); - } else if (isInserting) { - context.missing(_versionMeta); - } - if (data.containsKey('author')) { - context.handle(_authorMeta, - author.isAcceptableOrUnknown(data['author']!, _authorMeta)); - } else if (isInserting) { - context.missing(_authorMeta); - } - if (data.containsKey('entry_point')) { - context.handle( - _entryPointMeta, - entryPoint.isAcceptableOrUnknown( - data['entry_point']!, _entryPointMeta)); - } else if (isInserting) { - context.missing(_entryPointMeta); - } - if (data.containsKey('selected_for_metadata')) { - context.handle( - _selectedForMetadataMeta, - selectedForMetadata.isAcceptableOrUnknown( - data['selected_for_metadata']!, _selectedForMetadataMeta)); - } - if (data.containsKey('selected_for_audio_source')) { - context.handle( - _selectedForAudioSourceMeta, - selectedForAudioSource.isAcceptableOrUnknown( - data['selected_for_audio_source']!, _selectedForAudioSourceMeta)); - } - if (data.containsKey('repository')) { - context.handle( - _repositoryMeta, - repository.isAcceptableOrUnknown( - data['repository']!, _repositoryMeta)); - } - if (data.containsKey('plugin_api_version')) { - context.handle( - _pluginApiVersionMeta, - pluginApiVersion.isAcceptableOrUnknown( - data['plugin_api_version']!, _pluginApiVersionMeta)); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - PluginsTableData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return PluginsTableData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - name: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}name'])!, - description: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}description'])!, - version: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}version'])!, - author: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}author'])!, - entryPoint: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}entry_point'])!, - apis: $PluginsTableTable.$converterapis.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}apis'])!), - abilities: $PluginsTableTable.$converterabilities.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}abilities'])!), - selectedForMetadata: attachedDatabase.typeMapping.read( - DriftSqlType.bool, data['${effectivePrefix}selected_for_metadata'])!, - selectedForAudioSource: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}selected_for_audio_source'])!, - repository: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}repository']), - pluginApiVersion: attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}plugin_api_version'])!, - ); - } - - @override - $PluginsTableTable createAlias(String alias) { - return $PluginsTableTable(attachedDatabase, alias); - } - - static TypeConverter, String> $converterapis = - const StringListConverter(); - static TypeConverter, String> $converterabilities = - const StringListConverter(); -} - -class PluginsTableData extends DataClass - implements Insertable { - final int id; - final String name; - final String description; - final String version; - final String author; - final String entryPoint; - final List apis; - final List abilities; - final bool selectedForMetadata; - final bool selectedForAudioSource; - final String? repository; - final String pluginApiVersion; - const PluginsTableData( - {required this.id, - required this.name, - required this.description, - required this.version, - required this.author, - required this.entryPoint, - required this.apis, - required this.abilities, - required this.selectedForMetadata, - required this.selectedForAudioSource, - this.repository, - required this.pluginApiVersion}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['name'] = Variable(name); - map['description'] = Variable(description); - map['version'] = Variable(version); - map['author'] = Variable(author); - map['entry_point'] = Variable(entryPoint); - { - map['apis'] = - Variable($PluginsTableTable.$converterapis.toSql(apis)); - } - { - map['abilities'] = Variable( - $PluginsTableTable.$converterabilities.toSql(abilities)); - } - map['selected_for_metadata'] = Variable(selectedForMetadata); - map['selected_for_audio_source'] = Variable(selectedForAudioSource); - if (!nullToAbsent || repository != null) { - map['repository'] = Variable(repository); - } - map['plugin_api_version'] = Variable(pluginApiVersion); - return map; - } - - PluginsTableCompanion toCompanion(bool nullToAbsent) { - return PluginsTableCompanion( - id: Value(id), - name: Value(name), - description: Value(description), - version: Value(version), - author: Value(author), - entryPoint: Value(entryPoint), - apis: Value(apis), - abilities: Value(abilities), - selectedForMetadata: Value(selectedForMetadata), - selectedForAudioSource: Value(selectedForAudioSource), - repository: repository == null && nullToAbsent - ? const Value.absent() - : Value(repository), - pluginApiVersion: Value(pluginApiVersion), - ); - } - - factory PluginsTableData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return PluginsTableData( - id: serializer.fromJson(json['id']), - name: serializer.fromJson(json['name']), - description: serializer.fromJson(json['description']), - version: serializer.fromJson(json['version']), - author: serializer.fromJson(json['author']), - entryPoint: serializer.fromJson(json['entryPoint']), - apis: serializer.fromJson>(json['apis']), - abilities: serializer.fromJson>(json['abilities']), - selectedForMetadata: - serializer.fromJson(json['selectedForMetadata']), - selectedForAudioSource: - serializer.fromJson(json['selectedForAudioSource']), - repository: serializer.fromJson(json['repository']), - pluginApiVersion: serializer.fromJson(json['pluginApiVersion']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'name': serializer.toJson(name), - 'description': serializer.toJson(description), - 'version': serializer.toJson(version), - 'author': serializer.toJson(author), - 'entryPoint': serializer.toJson(entryPoint), - 'apis': serializer.toJson>(apis), - 'abilities': serializer.toJson>(abilities), - 'selectedForMetadata': serializer.toJson(selectedForMetadata), - 'selectedForAudioSource': serializer.toJson(selectedForAudioSource), - 'repository': serializer.toJson(repository), - 'pluginApiVersion': serializer.toJson(pluginApiVersion), - }; - } - - PluginsTableData copyWith( - {int? id, - String? name, - String? description, - String? version, - String? author, - String? entryPoint, - List? apis, - List? abilities, - bool? selectedForMetadata, - bool? selectedForAudioSource, - Value repository = const Value.absent(), - String? pluginApiVersion}) => - PluginsTableData( - id: id ?? this.id, - name: name ?? this.name, - description: description ?? this.description, - version: version ?? this.version, - author: author ?? this.author, - entryPoint: entryPoint ?? this.entryPoint, - apis: apis ?? this.apis, - abilities: abilities ?? this.abilities, - selectedForMetadata: selectedForMetadata ?? this.selectedForMetadata, - selectedForAudioSource: - selectedForAudioSource ?? this.selectedForAudioSource, - repository: repository.present ? repository.value : this.repository, - pluginApiVersion: pluginApiVersion ?? this.pluginApiVersion, - ); - PluginsTableData copyWithCompanion(PluginsTableCompanion data) { - return PluginsTableData( - id: data.id.present ? data.id.value : this.id, - name: data.name.present ? data.name.value : this.name, - description: - data.description.present ? data.description.value : this.description, - version: data.version.present ? data.version.value : this.version, - author: data.author.present ? data.author.value : this.author, - entryPoint: - data.entryPoint.present ? data.entryPoint.value : this.entryPoint, - apis: data.apis.present ? data.apis.value : this.apis, - abilities: data.abilities.present ? data.abilities.value : this.abilities, - selectedForMetadata: data.selectedForMetadata.present - ? data.selectedForMetadata.value - : this.selectedForMetadata, - selectedForAudioSource: data.selectedForAudioSource.present - ? data.selectedForAudioSource.value - : this.selectedForAudioSource, - repository: - data.repository.present ? data.repository.value : this.repository, - pluginApiVersion: data.pluginApiVersion.present - ? data.pluginApiVersion.value - : this.pluginApiVersion, - ); - } - - @override - String toString() { - return (StringBuffer('PluginsTableData(') - ..write('id: $id, ') - ..write('name: $name, ') - ..write('description: $description, ') - ..write('version: $version, ') - ..write('author: $author, ') - ..write('entryPoint: $entryPoint, ') - ..write('apis: $apis, ') - ..write('abilities: $abilities, ') - ..write('selectedForMetadata: $selectedForMetadata, ') - ..write('selectedForAudioSource: $selectedForAudioSource, ') - ..write('repository: $repository, ') - ..write('pluginApiVersion: $pluginApiVersion') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash( - id, - name, - description, - version, - author, - entryPoint, - apis, - abilities, - selectedForMetadata, - selectedForAudioSource, - repository, - pluginApiVersion); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is PluginsTableData && - other.id == this.id && - other.name == this.name && - other.description == this.description && - other.version == this.version && - other.author == this.author && - other.entryPoint == this.entryPoint && - other.apis == this.apis && - other.abilities == this.abilities && - other.selectedForMetadata == this.selectedForMetadata && - other.selectedForAudioSource == this.selectedForAudioSource && - other.repository == this.repository && - other.pluginApiVersion == this.pluginApiVersion); -} - -class PluginsTableCompanion extends UpdateCompanion { - final Value id; - final Value name; - final Value description; - final Value version; - final Value author; - final Value entryPoint; - final Value> apis; - final Value> abilities; - final Value selectedForMetadata; - final Value selectedForAudioSource; - final Value repository; - final Value pluginApiVersion; - const PluginsTableCompanion({ - this.id = const Value.absent(), - this.name = const Value.absent(), - this.description = const Value.absent(), - this.version = const Value.absent(), - this.author = const Value.absent(), - this.entryPoint = const Value.absent(), - this.apis = const Value.absent(), - this.abilities = const Value.absent(), - this.selectedForMetadata = const Value.absent(), - this.selectedForAudioSource = const Value.absent(), - this.repository = const Value.absent(), - this.pluginApiVersion = const Value.absent(), - }); - PluginsTableCompanion.insert({ - this.id = const Value.absent(), - required String name, - required String description, - required String version, - required String author, - required String entryPoint, - required List apis, - required List abilities, - this.selectedForMetadata = const Value.absent(), - this.selectedForAudioSource = const Value.absent(), - this.repository = const Value.absent(), - this.pluginApiVersion = const Value.absent(), - }) : name = Value(name), - description = Value(description), - version = Value(version), - author = Value(author), - entryPoint = Value(entryPoint), - apis = Value(apis), - abilities = Value(abilities); - static Insertable custom({ - Expression? id, - Expression? name, - Expression? description, - Expression? version, - Expression? author, - Expression? entryPoint, - Expression? apis, - Expression? abilities, - Expression? selectedForMetadata, - Expression? selectedForAudioSource, - Expression? repository, - Expression? pluginApiVersion, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (name != null) 'name': name, - if (description != null) 'description': description, - if (version != null) 'version': version, - if (author != null) 'author': author, - if (entryPoint != null) 'entry_point': entryPoint, - if (apis != null) 'apis': apis, - if (abilities != null) 'abilities': abilities, - if (selectedForMetadata != null) - 'selected_for_metadata': selectedForMetadata, - if (selectedForAudioSource != null) - 'selected_for_audio_source': selectedForAudioSource, - if (repository != null) 'repository': repository, - if (pluginApiVersion != null) 'plugin_api_version': pluginApiVersion, - }); - } - - PluginsTableCompanion copyWith( - {Value? id, - Value? name, - Value? description, - Value? version, - Value? author, - Value? entryPoint, - Value>? apis, - Value>? abilities, - Value? selectedForMetadata, - Value? selectedForAudioSource, - Value? repository, - Value? pluginApiVersion}) { - return PluginsTableCompanion( - id: id ?? this.id, - name: name ?? this.name, - description: description ?? this.description, - version: version ?? this.version, - author: author ?? this.author, - entryPoint: entryPoint ?? this.entryPoint, - apis: apis ?? this.apis, - abilities: abilities ?? this.abilities, - selectedForMetadata: selectedForMetadata ?? this.selectedForMetadata, - selectedForAudioSource: - selectedForAudioSource ?? this.selectedForAudioSource, - repository: repository ?? this.repository, - pluginApiVersion: pluginApiVersion ?? this.pluginApiVersion, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (name.present) { - map['name'] = Variable(name.value); - } - if (description.present) { - map['description'] = Variable(description.value); - } - if (version.present) { - map['version'] = Variable(version.value); - } - if (author.present) { - map['author'] = Variable(author.value); - } - if (entryPoint.present) { - map['entry_point'] = Variable(entryPoint.value); - } - if (apis.present) { - map['apis'] = - Variable($PluginsTableTable.$converterapis.toSql(apis.value)); - } - if (abilities.present) { - map['abilities'] = Variable( - $PluginsTableTable.$converterabilities.toSql(abilities.value)); - } - if (selectedForMetadata.present) { - map['selected_for_metadata'] = Variable(selectedForMetadata.value); - } - if (selectedForAudioSource.present) { - map['selected_for_audio_source'] = - Variable(selectedForAudioSource.value); - } - if (repository.present) { - map['repository'] = Variable(repository.value); - } - if (pluginApiVersion.present) { - map['plugin_api_version'] = Variable(pluginApiVersion.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('PluginsTableCompanion(') - ..write('id: $id, ') - ..write('name: $name, ') - ..write('description: $description, ') - ..write('version: $version, ') - ..write('author: $author, ') - ..write('entryPoint: $entryPoint, ') - ..write('apis: $apis, ') - ..write('abilities: $abilities, ') - ..write('selectedForMetadata: $selectedForMetadata, ') - ..write('selectedForAudioSource: $selectedForAudioSource, ') - ..write('repository: $repository, ') - ..write('pluginApiVersion: $pluginApiVersion') - ..write(')')) - .toString(); - } -} - -abstract class _$AppDatabase extends GeneratedDatabase { - _$AppDatabase(QueryExecutor e) : super(e); - $AppDatabaseManager get managers => $AppDatabaseManager(this); - late final $AuthenticationTableTable authenticationTable = - $AuthenticationTableTable(this); - late final $BlacklistTableTable blacklistTable = $BlacklistTableTable(this); - late final $PreferencesTableTable preferencesTable = - $PreferencesTableTable(this); - late final $ScrobblerTableTable scrobblerTable = $ScrobblerTableTable(this); - late final $SkipSegmentTableTable skipSegmentTable = - $SkipSegmentTableTable(this); - late final $SourceMatchTableTable sourceMatchTable = - $SourceMatchTableTable(this); - late final $AudioPlayerStateTableTable audioPlayerStateTable = - $AudioPlayerStateTableTable(this); - late final $HistoryTableTable historyTable = $HistoryTableTable(this); - late final $LyricsTableTable lyricsTable = $LyricsTableTable(this); - late final $PluginsTableTable pluginsTable = $PluginsTableTable(this); - late final Index uniqueBlacklist = Index('unique_blacklist', - 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); - @override - Iterable> get allTables => - allSchemaEntities.whereType>(); - @override - List get allSchemaEntities => [ - authenticationTable, - blacklistTable, - preferencesTable, - scrobblerTable, - skipSegmentTable, - sourceMatchTable, - audioPlayerStateTable, - historyTable, - lyricsTable, - pluginsTable, - uniqueBlacklist - ]; -} - -typedef $$AuthenticationTableTableCreateCompanionBuilder - = AuthenticationTableCompanion Function({ - Value id, - required DecryptedText cookie, - required DecryptedText accessToken, - required DateTime expiration, -}); -typedef $$AuthenticationTableTableUpdateCompanionBuilder - = AuthenticationTableCompanion Function({ - Value id, - Value cookie, - Value accessToken, - Value expiration, -}); - -class $$AuthenticationTableTableFilterComposer - extends Composer<_$AppDatabase, $AuthenticationTableTable> { - $$AuthenticationTableTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); - - ColumnWithTypeConverterFilters - get cookie => $composableBuilder( - column: $table.cookie, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnWithTypeConverterFilters - get accessToken => $composableBuilder( - column: $table.accessToken, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnFilters get expiration => $composableBuilder( - column: $table.expiration, builder: (column) => ColumnFilters(column)); -} - -class $$AuthenticationTableTableOrderingComposer - extends Composer<_$AppDatabase, $AuthenticationTableTable> { - $$AuthenticationTableTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get cookie => $composableBuilder( - column: $table.cookie, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get accessToken => $composableBuilder( - column: $table.accessToken, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get expiration => $composableBuilder( - column: $table.expiration, builder: (column) => ColumnOrderings(column)); -} - -class $$AuthenticationTableTableAnnotationComposer - extends Composer<_$AppDatabase, $AuthenticationTableTable> { - $$AuthenticationTableTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumnWithTypeConverter get cookie => - $composableBuilder(column: $table.cookie, builder: (column) => column); - - GeneratedColumnWithTypeConverter get accessToken => - $composableBuilder( - column: $table.accessToken, builder: (column) => column); - - GeneratedColumn get expiration => $composableBuilder( - column: $table.expiration, builder: (column) => column); -} - -class $$AuthenticationTableTableTableManager extends RootTableManager< - _$AppDatabase, - $AuthenticationTableTable, - AuthenticationTableData, - $$AuthenticationTableTableFilterComposer, - $$AuthenticationTableTableOrderingComposer, - $$AuthenticationTableTableAnnotationComposer, - $$AuthenticationTableTableCreateCompanionBuilder, - $$AuthenticationTableTableUpdateCompanionBuilder, - ( - AuthenticationTableData, - BaseReferences<_$AppDatabase, $AuthenticationTableTable, - AuthenticationTableData> - ), - AuthenticationTableData, - PrefetchHooks Function()> { - $$AuthenticationTableTableTableManager( - _$AppDatabase db, $AuthenticationTableTable table) - : super(TableManagerState( - db: db, - table: table, - createFilteringComposer: () => - $$AuthenticationTableTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$AuthenticationTableTableOrderingComposer( - $db: db, $table: table), - createComputedFieldComposer: () => - $$AuthenticationTableTableAnnotationComposer( - $db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value cookie = const Value.absent(), - Value accessToken = const Value.absent(), - Value expiration = const Value.absent(), - }) => - AuthenticationTableCompanion( - id: id, - cookie: cookie, - accessToken: accessToken, - expiration: expiration, - ), - createCompanionCallback: ({ - Value id = const Value.absent(), - required DecryptedText cookie, - required DecryptedText accessToken, - required DateTime expiration, - }) => - AuthenticationTableCompanion.insert( - id: id, - cookie: cookie, - accessToken: accessToken, - expiration: expiration, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - )); -} - -typedef $$AuthenticationTableTableProcessedTableManager = ProcessedTableManager< - _$AppDatabase, - $AuthenticationTableTable, - AuthenticationTableData, - $$AuthenticationTableTableFilterComposer, - $$AuthenticationTableTableOrderingComposer, - $$AuthenticationTableTableAnnotationComposer, - $$AuthenticationTableTableCreateCompanionBuilder, - $$AuthenticationTableTableUpdateCompanionBuilder, - ( - AuthenticationTableData, - BaseReferences<_$AppDatabase, $AuthenticationTableTable, - AuthenticationTableData> - ), - AuthenticationTableData, - PrefetchHooks Function()>; -typedef $$BlacklistTableTableCreateCompanionBuilder = BlacklistTableCompanion - Function({ - Value id, - required String name, - required BlacklistedType elementType, - required String elementId, -}); -typedef $$BlacklistTableTableUpdateCompanionBuilder = BlacklistTableCompanion - Function({ - Value id, - Value name, - Value elementType, - Value elementId, -}); - -class $$BlacklistTableTableFilterComposer - extends Composer<_$AppDatabase, $BlacklistTableTable> { - $$BlacklistTableTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); - - ColumnFilters get name => $composableBuilder( - column: $table.name, builder: (column) => ColumnFilters(column)); - - ColumnWithTypeConverterFilters - get elementType => $composableBuilder( - column: $table.elementType, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnFilters get elementId => $composableBuilder( - column: $table.elementId, builder: (column) => ColumnFilters(column)); -} - -class $$BlacklistTableTableOrderingComposer - extends Composer<_$AppDatabase, $BlacklistTableTable> { - $$BlacklistTableTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get name => $composableBuilder( - column: $table.name, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get elementType => $composableBuilder( - column: $table.elementType, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get elementId => $composableBuilder( - column: $table.elementId, builder: (column) => ColumnOrderings(column)); -} - -class $$BlacklistTableTableAnnotationComposer - extends Composer<_$AppDatabase, $BlacklistTableTable> { - $$BlacklistTableTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get name => - $composableBuilder(column: $table.name, builder: (column) => column); - - GeneratedColumnWithTypeConverter get elementType => - $composableBuilder( - column: $table.elementType, builder: (column) => column); - - GeneratedColumn get elementId => - $composableBuilder(column: $table.elementId, builder: (column) => column); -} - -class $$BlacklistTableTableTableManager extends RootTableManager< - _$AppDatabase, - $BlacklistTableTable, - BlacklistTableData, - $$BlacklistTableTableFilterComposer, - $$BlacklistTableTableOrderingComposer, - $$BlacklistTableTableAnnotationComposer, - $$BlacklistTableTableCreateCompanionBuilder, - $$BlacklistTableTableUpdateCompanionBuilder, - ( - BlacklistTableData, - BaseReferences<_$AppDatabase, $BlacklistTableTable, BlacklistTableData> - ), - BlacklistTableData, - PrefetchHooks Function()> { - $$BlacklistTableTableTableManager( - _$AppDatabase db, $BlacklistTableTable table) - : super(TableManagerState( - db: db, - table: table, - createFilteringComposer: () => - $$BlacklistTableTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$BlacklistTableTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$BlacklistTableTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value name = const Value.absent(), - Value elementType = const Value.absent(), - Value elementId = const Value.absent(), - }) => - BlacklistTableCompanion( - id: id, - name: name, - elementType: elementType, - elementId: elementId, - ), - createCompanionCallback: ({ - Value id = const Value.absent(), - required String name, - required BlacklistedType elementType, - required String elementId, - }) => - BlacklistTableCompanion.insert( - id: id, - name: name, - elementType: elementType, - elementId: elementId, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - )); -} - -typedef $$BlacklistTableTableProcessedTableManager = ProcessedTableManager< - _$AppDatabase, - $BlacklistTableTable, - BlacklistTableData, - $$BlacklistTableTableFilterComposer, - $$BlacklistTableTableOrderingComposer, - $$BlacklistTableTableAnnotationComposer, - $$BlacklistTableTableCreateCompanionBuilder, - $$BlacklistTableTableUpdateCompanionBuilder, - ( - BlacklistTableData, - BaseReferences<_$AppDatabase, $BlacklistTableTable, BlacklistTableData> - ), - BlacklistTableData, - PrefetchHooks Function()>; -typedef $$PreferencesTableTableCreateCompanionBuilder - = PreferencesTableCompanion Function({ - Value id, - Value albumColorSync, - Value amoledDarkTheme, - Value checkUpdate, - Value normalizeAudio, - Value showSystemTrayIcon, - Value systemTitleBar, - Value skipNonMusic, - Value closeBehavior, - Value accentColorScheme, - Value layoutMode, - Value locale, - Value market, - Value searchMode, - Value downloadLocation, - Value> localLibraryLocation, - Value themeMode, - Value audioSourceId, - Value youtubeClientEngine, - Value discordPresence, - Value endlessPlayback, - Value enableConnect, - Value connectPort, - Value cacheMusic, -}); -typedef $$PreferencesTableTableUpdateCompanionBuilder - = PreferencesTableCompanion Function({ - Value id, - Value albumColorSync, - Value amoledDarkTheme, - Value checkUpdate, - Value normalizeAudio, - Value showSystemTrayIcon, - Value systemTitleBar, - Value skipNonMusic, - Value closeBehavior, - Value accentColorScheme, - Value layoutMode, - Value locale, - Value market, - Value searchMode, - Value downloadLocation, - Value> localLibraryLocation, - Value themeMode, - Value audioSourceId, - Value youtubeClientEngine, - Value discordPresence, - Value endlessPlayback, - Value enableConnect, - Value connectPort, - Value cacheMusic, -}); - -class $$PreferencesTableTableFilterComposer - extends Composer<_$AppDatabase, $PreferencesTableTable> { - $$PreferencesTableTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); - - ColumnFilters get albumColorSync => $composableBuilder( - column: $table.albumColorSync, - builder: (column) => ColumnFilters(column)); - - ColumnFilters get amoledDarkTheme => $composableBuilder( - column: $table.amoledDarkTheme, - builder: (column) => ColumnFilters(column)); - - ColumnFilters get checkUpdate => $composableBuilder( - column: $table.checkUpdate, builder: (column) => ColumnFilters(column)); - - ColumnFilters get normalizeAudio => $composableBuilder( - column: $table.normalizeAudio, - builder: (column) => ColumnFilters(column)); - - ColumnFilters get showSystemTrayIcon => $composableBuilder( - column: $table.showSystemTrayIcon, - builder: (column) => ColumnFilters(column)); - - ColumnFilters get systemTitleBar => $composableBuilder( - column: $table.systemTitleBar, - builder: (column) => ColumnFilters(column)); - - ColumnFilters get skipNonMusic => $composableBuilder( - column: $table.skipNonMusic, builder: (column) => ColumnFilters(column)); - - ColumnWithTypeConverterFilters - get closeBehavior => $composableBuilder( - column: $table.closeBehavior, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnWithTypeConverterFilters - get accentColorScheme => $composableBuilder( - column: $table.accentColorScheme, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnWithTypeConverterFilters - get layoutMode => $composableBuilder( - column: $table.layoutMode, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnWithTypeConverterFilters get locale => - $composableBuilder( - column: $table.locale, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnWithTypeConverterFilters get market => - $composableBuilder( - column: $table.market, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnWithTypeConverterFilters - get searchMode => $composableBuilder( - column: $table.searchMode, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnFilters get downloadLocation => $composableBuilder( - column: $table.downloadLocation, - builder: (column) => ColumnFilters(column)); - - ColumnWithTypeConverterFilters, List, String> - get localLibraryLocation => $composableBuilder( - column: $table.localLibraryLocation, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnWithTypeConverterFilters get themeMode => - $composableBuilder( - column: $table.themeMode, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnFilters get audioSourceId => $composableBuilder( - column: $table.audioSourceId, builder: (column) => ColumnFilters(column)); - - ColumnWithTypeConverterFilters - get youtubeClientEngine => $composableBuilder( - column: $table.youtubeClientEngine, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnFilters get discordPresence => $composableBuilder( - column: $table.discordPresence, - builder: (column) => ColumnFilters(column)); - - ColumnFilters get endlessPlayback => $composableBuilder( - column: $table.endlessPlayback, - builder: (column) => ColumnFilters(column)); - - ColumnFilters get enableConnect => $composableBuilder( - column: $table.enableConnect, builder: (column) => ColumnFilters(column)); - - ColumnFilters get connectPort => $composableBuilder( - column: $table.connectPort, builder: (column) => ColumnFilters(column)); - - ColumnFilters get cacheMusic => $composableBuilder( - column: $table.cacheMusic, builder: (column) => ColumnFilters(column)); -} - -class $$PreferencesTableTableOrderingComposer - extends Composer<_$AppDatabase, $PreferencesTableTable> { - $$PreferencesTableTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get albumColorSync => $composableBuilder( - column: $table.albumColorSync, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get amoledDarkTheme => $composableBuilder( - column: $table.amoledDarkTheme, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get checkUpdate => $composableBuilder( - column: $table.checkUpdate, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get normalizeAudio => $composableBuilder( - column: $table.normalizeAudio, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get showSystemTrayIcon => $composableBuilder( - column: $table.showSystemTrayIcon, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get systemTitleBar => $composableBuilder( - column: $table.systemTitleBar, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get skipNonMusic => $composableBuilder( - column: $table.skipNonMusic, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get closeBehavior => $composableBuilder( - column: $table.closeBehavior, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get accentColorScheme => $composableBuilder( - column: $table.accentColorScheme, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get layoutMode => $composableBuilder( - column: $table.layoutMode, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get locale => $composableBuilder( - column: $table.locale, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get market => $composableBuilder( - column: $table.market, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get searchMode => $composableBuilder( - column: $table.searchMode, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get downloadLocation => $composableBuilder( - column: $table.downloadLocation, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get localLibraryLocation => $composableBuilder( - column: $table.localLibraryLocation, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get themeMode => $composableBuilder( - column: $table.themeMode, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get audioSourceId => $composableBuilder( - column: $table.audioSourceId, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get youtubeClientEngine => $composableBuilder( - column: $table.youtubeClientEngine, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get discordPresence => $composableBuilder( - column: $table.discordPresence, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get endlessPlayback => $composableBuilder( - column: $table.endlessPlayback, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get enableConnect => $composableBuilder( - column: $table.enableConnect, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get connectPort => $composableBuilder( - column: $table.connectPort, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get cacheMusic => $composableBuilder( - column: $table.cacheMusic, builder: (column) => ColumnOrderings(column)); -} - -class $$PreferencesTableTableAnnotationComposer - extends Composer<_$AppDatabase, $PreferencesTableTable> { - $$PreferencesTableTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get albumColorSync => $composableBuilder( - column: $table.albumColorSync, builder: (column) => column); - - GeneratedColumn get amoledDarkTheme => $composableBuilder( - column: $table.amoledDarkTheme, builder: (column) => column); - - GeneratedColumn get checkUpdate => $composableBuilder( - column: $table.checkUpdate, builder: (column) => column); - - GeneratedColumn get normalizeAudio => $composableBuilder( - column: $table.normalizeAudio, builder: (column) => column); - - GeneratedColumn get showSystemTrayIcon => $composableBuilder( - column: $table.showSystemTrayIcon, builder: (column) => column); - - GeneratedColumn get systemTitleBar => $composableBuilder( - column: $table.systemTitleBar, builder: (column) => column); - - GeneratedColumn get skipNonMusic => $composableBuilder( - column: $table.skipNonMusic, builder: (column) => column); - - GeneratedColumnWithTypeConverter get closeBehavior => - $composableBuilder( - column: $table.closeBehavior, builder: (column) => column); - - GeneratedColumnWithTypeConverter - get accentColorScheme => $composableBuilder( - column: $table.accentColorScheme, builder: (column) => column); - - GeneratedColumnWithTypeConverter get layoutMode => - $composableBuilder( - column: $table.layoutMode, builder: (column) => column); - - GeneratedColumnWithTypeConverter get locale => - $composableBuilder(column: $table.locale, builder: (column) => column); - - GeneratedColumnWithTypeConverter get market => - $composableBuilder(column: $table.market, builder: (column) => column); - - GeneratedColumnWithTypeConverter get searchMode => - $composableBuilder( - column: $table.searchMode, builder: (column) => column); - - GeneratedColumn get downloadLocation => $composableBuilder( - column: $table.downloadLocation, builder: (column) => column); - - GeneratedColumnWithTypeConverter, String> - get localLibraryLocation => $composableBuilder( - column: $table.localLibraryLocation, builder: (column) => column); - - GeneratedColumnWithTypeConverter get themeMode => - $composableBuilder(column: $table.themeMode, builder: (column) => column); - - GeneratedColumn get audioSourceId => $composableBuilder( - column: $table.audioSourceId, builder: (column) => column); - - GeneratedColumnWithTypeConverter - get youtubeClientEngine => $composableBuilder( - column: $table.youtubeClientEngine, builder: (column) => column); - - GeneratedColumn get discordPresence => $composableBuilder( - column: $table.discordPresence, builder: (column) => column); - - GeneratedColumn get endlessPlayback => $composableBuilder( - column: $table.endlessPlayback, builder: (column) => column); - - GeneratedColumn get enableConnect => $composableBuilder( - column: $table.enableConnect, builder: (column) => column); - - GeneratedColumn get connectPort => $composableBuilder( - column: $table.connectPort, builder: (column) => column); - - GeneratedColumn get cacheMusic => $composableBuilder( - column: $table.cacheMusic, builder: (column) => column); -} - -class $$PreferencesTableTableTableManager extends RootTableManager< - _$AppDatabase, - $PreferencesTableTable, - PreferencesTableData, - $$PreferencesTableTableFilterComposer, - $$PreferencesTableTableOrderingComposer, - $$PreferencesTableTableAnnotationComposer, - $$PreferencesTableTableCreateCompanionBuilder, - $$PreferencesTableTableUpdateCompanionBuilder, - ( - PreferencesTableData, - BaseReferences<_$AppDatabase, $PreferencesTableTable, - PreferencesTableData> - ), - PreferencesTableData, - PrefetchHooks Function()> { - $$PreferencesTableTableTableManager( - _$AppDatabase db, $PreferencesTableTable table) - : super(TableManagerState( - db: db, - table: table, - createFilteringComposer: () => - $$PreferencesTableTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$PreferencesTableTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$PreferencesTableTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value albumColorSync = const Value.absent(), - Value amoledDarkTheme = const Value.absent(), - Value checkUpdate = const Value.absent(), - Value normalizeAudio = const Value.absent(), - Value showSystemTrayIcon = const Value.absent(), - Value systemTitleBar = const Value.absent(), - Value skipNonMusic = const Value.absent(), - Value closeBehavior = const Value.absent(), - Value accentColorScheme = const Value.absent(), - Value layoutMode = const Value.absent(), - Value locale = const Value.absent(), - Value market = const Value.absent(), - Value searchMode = const Value.absent(), - Value downloadLocation = const Value.absent(), - Value> localLibraryLocation = const Value.absent(), - Value themeMode = const Value.absent(), - Value audioSourceId = const Value.absent(), - Value youtubeClientEngine = - const Value.absent(), - Value discordPresence = const Value.absent(), - Value endlessPlayback = const Value.absent(), - Value enableConnect = const Value.absent(), - Value connectPort = const Value.absent(), - Value cacheMusic = const Value.absent(), - }) => - PreferencesTableCompanion( - id: id, - albumColorSync: albumColorSync, - amoledDarkTheme: amoledDarkTheme, - checkUpdate: checkUpdate, - normalizeAudio: normalizeAudio, - showSystemTrayIcon: showSystemTrayIcon, - systemTitleBar: systemTitleBar, - skipNonMusic: skipNonMusic, - closeBehavior: closeBehavior, - accentColorScheme: accentColorScheme, - layoutMode: layoutMode, - locale: locale, - market: market, - searchMode: searchMode, - downloadLocation: downloadLocation, - localLibraryLocation: localLibraryLocation, - themeMode: themeMode, - audioSourceId: audioSourceId, - youtubeClientEngine: youtubeClientEngine, - discordPresence: discordPresence, - endlessPlayback: endlessPlayback, - enableConnect: enableConnect, - connectPort: connectPort, - cacheMusic: cacheMusic, - ), - createCompanionCallback: ({ - Value id = const Value.absent(), - Value albumColorSync = const Value.absent(), - Value amoledDarkTheme = const Value.absent(), - Value checkUpdate = const Value.absent(), - Value normalizeAudio = const Value.absent(), - Value showSystemTrayIcon = const Value.absent(), - Value systemTitleBar = const Value.absent(), - Value skipNonMusic = const Value.absent(), - Value closeBehavior = const Value.absent(), - Value accentColorScheme = const Value.absent(), - Value layoutMode = const Value.absent(), - Value locale = const Value.absent(), - Value market = const Value.absent(), - Value searchMode = const Value.absent(), - Value downloadLocation = const Value.absent(), - Value> localLibraryLocation = const Value.absent(), - Value themeMode = const Value.absent(), - Value audioSourceId = const Value.absent(), - Value youtubeClientEngine = - const Value.absent(), - Value discordPresence = const Value.absent(), - Value endlessPlayback = const Value.absent(), - Value enableConnect = const Value.absent(), - Value connectPort = const Value.absent(), - Value cacheMusic = const Value.absent(), - }) => - PreferencesTableCompanion.insert( - id: id, - albumColorSync: albumColorSync, - amoledDarkTheme: amoledDarkTheme, - checkUpdate: checkUpdate, - normalizeAudio: normalizeAudio, - showSystemTrayIcon: showSystemTrayIcon, - systemTitleBar: systemTitleBar, - skipNonMusic: skipNonMusic, - closeBehavior: closeBehavior, - accentColorScheme: accentColorScheme, - layoutMode: layoutMode, - locale: locale, - market: market, - searchMode: searchMode, - downloadLocation: downloadLocation, - localLibraryLocation: localLibraryLocation, - themeMode: themeMode, - audioSourceId: audioSourceId, - youtubeClientEngine: youtubeClientEngine, - discordPresence: discordPresence, - endlessPlayback: endlessPlayback, - enableConnect: enableConnect, - connectPort: connectPort, - cacheMusic: cacheMusic, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - )); -} - -typedef $$PreferencesTableTableProcessedTableManager = ProcessedTableManager< - _$AppDatabase, - $PreferencesTableTable, - PreferencesTableData, - $$PreferencesTableTableFilterComposer, - $$PreferencesTableTableOrderingComposer, - $$PreferencesTableTableAnnotationComposer, - $$PreferencesTableTableCreateCompanionBuilder, - $$PreferencesTableTableUpdateCompanionBuilder, - ( - PreferencesTableData, - BaseReferences<_$AppDatabase, $PreferencesTableTable, - PreferencesTableData> - ), - PreferencesTableData, - PrefetchHooks Function()>; -typedef $$ScrobblerTableTableCreateCompanionBuilder = ScrobblerTableCompanion - Function({ - Value id, - Value createdAt, - required String username, - required DecryptedText passwordHash, -}); -typedef $$ScrobblerTableTableUpdateCompanionBuilder = ScrobblerTableCompanion - Function({ - Value id, - Value createdAt, - Value username, - Value passwordHash, -}); - -class $$ScrobblerTableTableFilterComposer - extends Composer<_$AppDatabase, $ScrobblerTableTable> { - $$ScrobblerTableTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); - - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnFilters(column)); - - ColumnFilters get username => $composableBuilder( - column: $table.username, builder: (column) => ColumnFilters(column)); - - ColumnWithTypeConverterFilters - get passwordHash => $composableBuilder( - column: $table.passwordHash, - builder: (column) => ColumnWithTypeConverterFilters(column)); -} - -class $$ScrobblerTableTableOrderingComposer - extends Composer<_$AppDatabase, $ScrobblerTableTable> { - $$ScrobblerTableTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get username => $composableBuilder( - column: $table.username, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get passwordHash => $composableBuilder( - column: $table.passwordHash, - builder: (column) => ColumnOrderings(column)); -} - -class $$ScrobblerTableTableAnnotationComposer - extends Composer<_$AppDatabase, $ScrobblerTableTable> { - $$ScrobblerTableTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); - - GeneratedColumn get username => - $composableBuilder(column: $table.username, builder: (column) => column); - - GeneratedColumnWithTypeConverter get passwordHash => - $composableBuilder( - column: $table.passwordHash, builder: (column) => column); -} - -class $$ScrobblerTableTableTableManager extends RootTableManager< - _$AppDatabase, - $ScrobblerTableTable, - ScrobblerTableData, - $$ScrobblerTableTableFilterComposer, - $$ScrobblerTableTableOrderingComposer, - $$ScrobblerTableTableAnnotationComposer, - $$ScrobblerTableTableCreateCompanionBuilder, - $$ScrobblerTableTableUpdateCompanionBuilder, - ( - ScrobblerTableData, - BaseReferences<_$AppDatabase, $ScrobblerTableTable, ScrobblerTableData> - ), - ScrobblerTableData, - PrefetchHooks Function()> { - $$ScrobblerTableTableTableManager( - _$AppDatabase db, $ScrobblerTableTable table) - : super(TableManagerState( - db: db, - table: table, - createFilteringComposer: () => - $$ScrobblerTableTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$ScrobblerTableTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$ScrobblerTableTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value createdAt = const Value.absent(), - Value username = const Value.absent(), - Value passwordHash = const Value.absent(), - }) => - ScrobblerTableCompanion( - id: id, - createdAt: createdAt, - username: username, - passwordHash: passwordHash, - ), - createCompanionCallback: ({ - Value id = const Value.absent(), - Value createdAt = const Value.absent(), - required String username, - required DecryptedText passwordHash, - }) => - ScrobblerTableCompanion.insert( - id: id, - createdAt: createdAt, - username: username, - passwordHash: passwordHash, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - )); -} - -typedef $$ScrobblerTableTableProcessedTableManager = ProcessedTableManager< - _$AppDatabase, - $ScrobblerTableTable, - ScrobblerTableData, - $$ScrobblerTableTableFilterComposer, - $$ScrobblerTableTableOrderingComposer, - $$ScrobblerTableTableAnnotationComposer, - $$ScrobblerTableTableCreateCompanionBuilder, - $$ScrobblerTableTableUpdateCompanionBuilder, - ( - ScrobblerTableData, - BaseReferences<_$AppDatabase, $ScrobblerTableTable, ScrobblerTableData> - ), - ScrobblerTableData, - PrefetchHooks Function()>; -typedef $$SkipSegmentTableTableCreateCompanionBuilder - = SkipSegmentTableCompanion Function({ - Value id, - required int start, - required int end, - required String trackId, - Value createdAt, -}); -typedef $$SkipSegmentTableTableUpdateCompanionBuilder - = SkipSegmentTableCompanion Function({ - Value id, - Value start, - Value end, - Value trackId, - Value createdAt, -}); - -class $$SkipSegmentTableTableFilterComposer - extends Composer<_$AppDatabase, $SkipSegmentTableTable> { - $$SkipSegmentTableTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); - - ColumnFilters get start => $composableBuilder( - column: $table.start, builder: (column) => ColumnFilters(column)); - - ColumnFilters get end => $composableBuilder( - column: $table.end, builder: (column) => ColumnFilters(column)); - - ColumnFilters get trackId => $composableBuilder( - column: $table.trackId, builder: (column) => ColumnFilters(column)); - - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnFilters(column)); -} - -class $$SkipSegmentTableTableOrderingComposer - extends Composer<_$AppDatabase, $SkipSegmentTableTable> { - $$SkipSegmentTableTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get start => $composableBuilder( - column: $table.start, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get end => $composableBuilder( - column: $table.end, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get trackId => $composableBuilder( - column: $table.trackId, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnOrderings(column)); -} - -class $$SkipSegmentTableTableAnnotationComposer - extends Composer<_$AppDatabase, $SkipSegmentTableTable> { - $$SkipSegmentTableTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get start => - $composableBuilder(column: $table.start, builder: (column) => column); - - GeneratedColumn get end => - $composableBuilder(column: $table.end, builder: (column) => column); - - GeneratedColumn get trackId => - $composableBuilder(column: $table.trackId, builder: (column) => column); - - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); -} - -class $$SkipSegmentTableTableTableManager extends RootTableManager< - _$AppDatabase, - $SkipSegmentTableTable, - SkipSegmentTableData, - $$SkipSegmentTableTableFilterComposer, - $$SkipSegmentTableTableOrderingComposer, - $$SkipSegmentTableTableAnnotationComposer, - $$SkipSegmentTableTableCreateCompanionBuilder, - $$SkipSegmentTableTableUpdateCompanionBuilder, - ( - SkipSegmentTableData, - BaseReferences<_$AppDatabase, $SkipSegmentTableTable, - SkipSegmentTableData> - ), - SkipSegmentTableData, - PrefetchHooks Function()> { - $$SkipSegmentTableTableTableManager( - _$AppDatabase db, $SkipSegmentTableTable table) - : super(TableManagerState( - db: db, - table: table, - createFilteringComposer: () => - $$SkipSegmentTableTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$SkipSegmentTableTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$SkipSegmentTableTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value start = const Value.absent(), - Value end = const Value.absent(), - Value trackId = const Value.absent(), - Value createdAt = const Value.absent(), - }) => - SkipSegmentTableCompanion( - id: id, - start: start, - end: end, - trackId: trackId, - createdAt: createdAt, - ), - createCompanionCallback: ({ - Value id = const Value.absent(), - required int start, - required int end, - required String trackId, - Value createdAt = const Value.absent(), - }) => - SkipSegmentTableCompanion.insert( - id: id, - start: start, - end: end, - trackId: trackId, - createdAt: createdAt, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - )); -} - -typedef $$SkipSegmentTableTableProcessedTableManager = ProcessedTableManager< - _$AppDatabase, - $SkipSegmentTableTable, - SkipSegmentTableData, - $$SkipSegmentTableTableFilterComposer, - $$SkipSegmentTableTableOrderingComposer, - $$SkipSegmentTableTableAnnotationComposer, - $$SkipSegmentTableTableCreateCompanionBuilder, - $$SkipSegmentTableTableUpdateCompanionBuilder, - ( - SkipSegmentTableData, - BaseReferences<_$AppDatabase, $SkipSegmentTableTable, - SkipSegmentTableData> - ), - SkipSegmentTableData, - PrefetchHooks Function()>; -typedef $$SourceMatchTableTableCreateCompanionBuilder - = SourceMatchTableCompanion Function({ - Value id, - required String trackId, - Value sourceInfo, - required String sourceType, - Value createdAt, -}); -typedef $$SourceMatchTableTableUpdateCompanionBuilder - = SourceMatchTableCompanion Function({ - Value id, - Value trackId, - Value sourceInfo, - Value sourceType, - Value createdAt, -}); - -class $$SourceMatchTableTableFilterComposer - extends Composer<_$AppDatabase, $SourceMatchTableTable> { - $$SourceMatchTableTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); - - ColumnFilters get trackId => $composableBuilder( - column: $table.trackId, builder: (column) => ColumnFilters(column)); - - ColumnFilters get sourceInfo => $composableBuilder( - column: $table.sourceInfo, builder: (column) => ColumnFilters(column)); - - ColumnFilters get sourceType => $composableBuilder( - column: $table.sourceType, builder: (column) => ColumnFilters(column)); - - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnFilters(column)); -} - -class $$SourceMatchTableTableOrderingComposer - extends Composer<_$AppDatabase, $SourceMatchTableTable> { - $$SourceMatchTableTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get trackId => $composableBuilder( - column: $table.trackId, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get sourceInfo => $composableBuilder( - column: $table.sourceInfo, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get sourceType => $composableBuilder( - column: $table.sourceType, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnOrderings(column)); -} - -class $$SourceMatchTableTableAnnotationComposer - extends Composer<_$AppDatabase, $SourceMatchTableTable> { - $$SourceMatchTableTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get trackId => - $composableBuilder(column: $table.trackId, builder: (column) => column); - - GeneratedColumn get sourceInfo => $composableBuilder( - column: $table.sourceInfo, builder: (column) => column); - - GeneratedColumn get sourceType => $composableBuilder( - column: $table.sourceType, builder: (column) => column); - - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); -} - -class $$SourceMatchTableTableTableManager extends RootTableManager< - _$AppDatabase, - $SourceMatchTableTable, - SourceMatchTableData, - $$SourceMatchTableTableFilterComposer, - $$SourceMatchTableTableOrderingComposer, - $$SourceMatchTableTableAnnotationComposer, - $$SourceMatchTableTableCreateCompanionBuilder, - $$SourceMatchTableTableUpdateCompanionBuilder, - ( - SourceMatchTableData, - BaseReferences<_$AppDatabase, $SourceMatchTableTable, - SourceMatchTableData> - ), - SourceMatchTableData, - PrefetchHooks Function()> { - $$SourceMatchTableTableTableManager( - _$AppDatabase db, $SourceMatchTableTable table) - : super(TableManagerState( - db: db, - table: table, - createFilteringComposer: () => - $$SourceMatchTableTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$SourceMatchTableTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$SourceMatchTableTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value trackId = const Value.absent(), - Value sourceInfo = const Value.absent(), - Value sourceType = const Value.absent(), - Value createdAt = const Value.absent(), - }) => - SourceMatchTableCompanion( - id: id, - trackId: trackId, - sourceInfo: sourceInfo, - sourceType: sourceType, - createdAt: createdAt, - ), - createCompanionCallback: ({ - Value id = const Value.absent(), - required String trackId, - Value sourceInfo = const Value.absent(), - required String sourceType, - Value createdAt = const Value.absent(), - }) => - SourceMatchTableCompanion.insert( - id: id, - trackId: trackId, - sourceInfo: sourceInfo, - sourceType: sourceType, - createdAt: createdAt, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - )); -} - -typedef $$SourceMatchTableTableProcessedTableManager = ProcessedTableManager< - _$AppDatabase, - $SourceMatchTableTable, - SourceMatchTableData, - $$SourceMatchTableTableFilterComposer, - $$SourceMatchTableTableOrderingComposer, - $$SourceMatchTableTableAnnotationComposer, - $$SourceMatchTableTableCreateCompanionBuilder, - $$SourceMatchTableTableUpdateCompanionBuilder, - ( - SourceMatchTableData, - BaseReferences<_$AppDatabase, $SourceMatchTableTable, - SourceMatchTableData> - ), - SourceMatchTableData, - PrefetchHooks Function()>; -typedef $$AudioPlayerStateTableTableCreateCompanionBuilder - = AudioPlayerStateTableCompanion Function({ - Value id, - required bool playing, - required PlaylistMode loopMode, - required bool shuffled, - required List collections, - Value> tracks, - Value currentIndex, -}); -typedef $$AudioPlayerStateTableTableUpdateCompanionBuilder - = AudioPlayerStateTableCompanion Function({ - Value id, - Value playing, - Value loopMode, - Value shuffled, - Value> collections, - Value> tracks, - Value currentIndex, -}); - -class $$AudioPlayerStateTableTableFilterComposer - extends Composer<_$AppDatabase, $AudioPlayerStateTableTable> { - $$AudioPlayerStateTableTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); - - ColumnFilters get playing => $composableBuilder( - column: $table.playing, builder: (column) => ColumnFilters(column)); - - ColumnWithTypeConverterFilters - get loopMode => $composableBuilder( - column: $table.loopMode, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnFilters get shuffled => $composableBuilder( - column: $table.shuffled, builder: (column) => ColumnFilters(column)); - - ColumnWithTypeConverterFilters, List, String> - get collections => $composableBuilder( - column: $table.collections, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnWithTypeConverterFilters, - List, String> - get tracks => $composableBuilder( - column: $table.tracks, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnFilters get currentIndex => $composableBuilder( - column: $table.currentIndex, builder: (column) => ColumnFilters(column)); -} - -class $$AudioPlayerStateTableTableOrderingComposer - extends Composer<_$AppDatabase, $AudioPlayerStateTableTable> { - $$AudioPlayerStateTableTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get playing => $composableBuilder( - column: $table.playing, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get loopMode => $composableBuilder( - column: $table.loopMode, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get shuffled => $composableBuilder( - column: $table.shuffled, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get collections => $composableBuilder( - column: $table.collections, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get tracks => $composableBuilder( - column: $table.tracks, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get currentIndex => $composableBuilder( - column: $table.currentIndex, - builder: (column) => ColumnOrderings(column)); -} - -class $$AudioPlayerStateTableTableAnnotationComposer - extends Composer<_$AppDatabase, $AudioPlayerStateTableTable> { - $$AudioPlayerStateTableTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get playing => - $composableBuilder(column: $table.playing, builder: (column) => column); - - GeneratedColumnWithTypeConverter get loopMode => - $composableBuilder(column: $table.loopMode, builder: (column) => column); - - GeneratedColumn get shuffled => - $composableBuilder(column: $table.shuffled, builder: (column) => column); - - GeneratedColumnWithTypeConverter, String> get collections => - $composableBuilder( - column: $table.collections, builder: (column) => column); - - GeneratedColumnWithTypeConverter, String> - get tracks => $composableBuilder( - column: $table.tracks, builder: (column) => column); - - GeneratedColumn get currentIndex => $composableBuilder( - column: $table.currentIndex, builder: (column) => column); -} - -class $$AudioPlayerStateTableTableTableManager extends RootTableManager< - _$AppDatabase, - $AudioPlayerStateTableTable, - AudioPlayerStateTableData, - $$AudioPlayerStateTableTableFilterComposer, - $$AudioPlayerStateTableTableOrderingComposer, - $$AudioPlayerStateTableTableAnnotationComposer, - $$AudioPlayerStateTableTableCreateCompanionBuilder, - $$AudioPlayerStateTableTableUpdateCompanionBuilder, - ( - AudioPlayerStateTableData, - BaseReferences<_$AppDatabase, $AudioPlayerStateTableTable, - AudioPlayerStateTableData> - ), - AudioPlayerStateTableData, - PrefetchHooks Function()> { - $$AudioPlayerStateTableTableTableManager( - _$AppDatabase db, $AudioPlayerStateTableTable table) - : super(TableManagerState( - db: db, - table: table, - createFilteringComposer: () => - $$AudioPlayerStateTableTableFilterComposer( - $db: db, $table: table), - createOrderingComposer: () => - $$AudioPlayerStateTableTableOrderingComposer( - $db: db, $table: table), - createComputedFieldComposer: () => - $$AudioPlayerStateTableTableAnnotationComposer( - $db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value playing = const Value.absent(), - Value loopMode = const Value.absent(), - Value shuffled = const Value.absent(), - Value> collections = const Value.absent(), - Value> tracks = const Value.absent(), - Value currentIndex = const Value.absent(), - }) => - AudioPlayerStateTableCompanion( - id: id, - playing: playing, - loopMode: loopMode, - shuffled: shuffled, - collections: collections, - tracks: tracks, - currentIndex: currentIndex, - ), - createCompanionCallback: ({ - Value id = const Value.absent(), - required bool playing, - required PlaylistMode loopMode, - required bool shuffled, - required List collections, - Value> tracks = const Value.absent(), - Value currentIndex = const Value.absent(), - }) => - AudioPlayerStateTableCompanion.insert( - id: id, - playing: playing, - loopMode: loopMode, - shuffled: shuffled, - collections: collections, - tracks: tracks, - currentIndex: currentIndex, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - )); -} - -typedef $$AudioPlayerStateTableTableProcessedTableManager - = ProcessedTableManager< - _$AppDatabase, - $AudioPlayerStateTableTable, - AudioPlayerStateTableData, - $$AudioPlayerStateTableTableFilterComposer, - $$AudioPlayerStateTableTableOrderingComposer, - $$AudioPlayerStateTableTableAnnotationComposer, - $$AudioPlayerStateTableTableCreateCompanionBuilder, - $$AudioPlayerStateTableTableUpdateCompanionBuilder, - ( - AudioPlayerStateTableData, - BaseReferences<_$AppDatabase, $AudioPlayerStateTableTable, - AudioPlayerStateTableData> - ), - AudioPlayerStateTableData, - PrefetchHooks Function()>; -typedef $$HistoryTableTableCreateCompanionBuilder = HistoryTableCompanion - Function({ - Value id, - Value createdAt, - required HistoryEntryType type, - required String itemId, - required Map data, -}); -typedef $$HistoryTableTableUpdateCompanionBuilder = HistoryTableCompanion - Function({ - Value id, - Value createdAt, - Value type, - Value itemId, - Value> data, -}); - -class $$HistoryTableTableFilterComposer - extends Composer<_$AppDatabase, $HistoryTableTable> { - $$HistoryTableTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); - - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnFilters(column)); - - ColumnWithTypeConverterFilters - get type => $composableBuilder( - column: $table.type, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnFilters get itemId => $composableBuilder( - column: $table.itemId, builder: (column) => ColumnFilters(column)); - - ColumnWithTypeConverterFilters, Map, - String> - get data => $composableBuilder( - column: $table.data, - builder: (column) => ColumnWithTypeConverterFilters(column)); -} - -class $$HistoryTableTableOrderingComposer - extends Composer<_$AppDatabase, $HistoryTableTable> { - $$HistoryTableTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get type => $composableBuilder( - column: $table.type, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get itemId => $composableBuilder( - column: $table.itemId, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get data => $composableBuilder( - column: $table.data, builder: (column) => ColumnOrderings(column)); -} - -class $$HistoryTableTableAnnotationComposer - extends Composer<_$AppDatabase, $HistoryTableTable> { - $$HistoryTableTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); - - GeneratedColumnWithTypeConverter get type => - $composableBuilder(column: $table.type, builder: (column) => column); - - GeneratedColumn get itemId => - $composableBuilder(column: $table.itemId, builder: (column) => column); - - GeneratedColumnWithTypeConverter, String> get data => - $composableBuilder(column: $table.data, builder: (column) => column); -} - -class $$HistoryTableTableTableManager extends RootTableManager< - _$AppDatabase, - $HistoryTableTable, - HistoryTableData, - $$HistoryTableTableFilterComposer, - $$HistoryTableTableOrderingComposer, - $$HistoryTableTableAnnotationComposer, - $$HistoryTableTableCreateCompanionBuilder, - $$HistoryTableTableUpdateCompanionBuilder, - ( - HistoryTableData, - BaseReferences<_$AppDatabase, $HistoryTableTable, HistoryTableData> - ), - HistoryTableData, - PrefetchHooks Function()> { - $$HistoryTableTableTableManager(_$AppDatabase db, $HistoryTableTable table) - : super(TableManagerState( - db: db, - table: table, - createFilteringComposer: () => - $$HistoryTableTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$HistoryTableTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$HistoryTableTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value createdAt = const Value.absent(), - Value type = const Value.absent(), - Value itemId = const Value.absent(), - Value> data = const Value.absent(), - }) => - HistoryTableCompanion( - id: id, - createdAt: createdAt, - type: type, - itemId: itemId, - data: data, - ), - createCompanionCallback: ({ - Value id = const Value.absent(), - Value createdAt = const Value.absent(), - required HistoryEntryType type, - required String itemId, - required Map data, - }) => - HistoryTableCompanion.insert( - id: id, - createdAt: createdAt, - type: type, - itemId: itemId, - data: data, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - )); -} - -typedef $$HistoryTableTableProcessedTableManager = ProcessedTableManager< - _$AppDatabase, - $HistoryTableTable, - HistoryTableData, - $$HistoryTableTableFilterComposer, - $$HistoryTableTableOrderingComposer, - $$HistoryTableTableAnnotationComposer, - $$HistoryTableTableCreateCompanionBuilder, - $$HistoryTableTableUpdateCompanionBuilder, - ( - HistoryTableData, - BaseReferences<_$AppDatabase, $HistoryTableTable, HistoryTableData> - ), - HistoryTableData, - PrefetchHooks Function()>; -typedef $$LyricsTableTableCreateCompanionBuilder = LyricsTableCompanion - Function({ - Value id, - required String trackId, - required SubtitleSimple data, -}); -typedef $$LyricsTableTableUpdateCompanionBuilder = LyricsTableCompanion - Function({ - Value id, - Value trackId, - Value data, -}); - -class $$LyricsTableTableFilterComposer - extends Composer<_$AppDatabase, $LyricsTableTable> { - $$LyricsTableTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); - - ColumnFilters get trackId => $composableBuilder( - column: $table.trackId, builder: (column) => ColumnFilters(column)); - - ColumnWithTypeConverterFilters - get data => $composableBuilder( - column: $table.data, - builder: (column) => ColumnWithTypeConverterFilters(column)); -} - -class $$LyricsTableTableOrderingComposer - extends Composer<_$AppDatabase, $LyricsTableTable> { - $$LyricsTableTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get trackId => $composableBuilder( - column: $table.trackId, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get data => $composableBuilder( - column: $table.data, builder: (column) => ColumnOrderings(column)); -} - -class $$LyricsTableTableAnnotationComposer - extends Composer<_$AppDatabase, $LyricsTableTable> { - $$LyricsTableTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get trackId => - $composableBuilder(column: $table.trackId, builder: (column) => column); - - GeneratedColumnWithTypeConverter get data => - $composableBuilder(column: $table.data, builder: (column) => column); -} - -class $$LyricsTableTableTableManager extends RootTableManager< - _$AppDatabase, - $LyricsTableTable, - LyricsTableData, - $$LyricsTableTableFilterComposer, - $$LyricsTableTableOrderingComposer, - $$LyricsTableTableAnnotationComposer, - $$LyricsTableTableCreateCompanionBuilder, - $$LyricsTableTableUpdateCompanionBuilder, - ( - LyricsTableData, - BaseReferences<_$AppDatabase, $LyricsTableTable, LyricsTableData> - ), - LyricsTableData, - PrefetchHooks Function()> { - $$LyricsTableTableTableManager(_$AppDatabase db, $LyricsTableTable table) - : super(TableManagerState( - db: db, - table: table, - createFilteringComposer: () => - $$LyricsTableTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$LyricsTableTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$LyricsTableTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value trackId = const Value.absent(), - Value data = const Value.absent(), - }) => - LyricsTableCompanion( - id: id, - trackId: trackId, - data: data, - ), - createCompanionCallback: ({ - Value id = const Value.absent(), - required String trackId, - required SubtitleSimple data, - }) => - LyricsTableCompanion.insert( - id: id, - trackId: trackId, - data: data, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - )); -} - -typedef $$LyricsTableTableProcessedTableManager = ProcessedTableManager< - _$AppDatabase, - $LyricsTableTable, - LyricsTableData, - $$LyricsTableTableFilterComposer, - $$LyricsTableTableOrderingComposer, - $$LyricsTableTableAnnotationComposer, - $$LyricsTableTableCreateCompanionBuilder, - $$LyricsTableTableUpdateCompanionBuilder, - ( - LyricsTableData, - BaseReferences<_$AppDatabase, $LyricsTableTable, LyricsTableData> - ), - LyricsTableData, - PrefetchHooks Function()>; -typedef $$PluginsTableTableCreateCompanionBuilder = PluginsTableCompanion - Function({ - Value id, - required String name, - required String description, - required String version, - required String author, - required String entryPoint, - required List apis, - required List abilities, - Value selectedForMetadata, - Value selectedForAudioSource, - Value repository, - Value pluginApiVersion, -}); -typedef $$PluginsTableTableUpdateCompanionBuilder = PluginsTableCompanion - Function({ - Value id, - Value name, - Value description, - Value version, - Value author, - Value entryPoint, - Value> apis, - Value> abilities, - Value selectedForMetadata, - Value selectedForAudioSource, - Value repository, - Value pluginApiVersion, -}); - -class $$PluginsTableTableFilterComposer - extends Composer<_$AppDatabase, $PluginsTableTable> { - $$PluginsTableTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); - - ColumnFilters get name => $composableBuilder( - column: $table.name, builder: (column) => ColumnFilters(column)); - - ColumnFilters get description => $composableBuilder( - column: $table.description, builder: (column) => ColumnFilters(column)); - - ColumnFilters get version => $composableBuilder( - column: $table.version, builder: (column) => ColumnFilters(column)); - - ColumnFilters get author => $composableBuilder( - column: $table.author, builder: (column) => ColumnFilters(column)); - - ColumnFilters get entryPoint => $composableBuilder( - column: $table.entryPoint, builder: (column) => ColumnFilters(column)); - - ColumnWithTypeConverterFilters, List, String> get apis => - $composableBuilder( - column: $table.apis, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnWithTypeConverterFilters, List, String> - get abilities => $composableBuilder( - column: $table.abilities, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - ColumnFilters get selectedForMetadata => $composableBuilder( - column: $table.selectedForMetadata, - builder: (column) => ColumnFilters(column)); - - ColumnFilters get selectedForAudioSource => $composableBuilder( - column: $table.selectedForAudioSource, - builder: (column) => ColumnFilters(column)); - - ColumnFilters get repository => $composableBuilder( - column: $table.repository, builder: (column) => ColumnFilters(column)); - - ColumnFilters get pluginApiVersion => $composableBuilder( - column: $table.pluginApiVersion, - builder: (column) => ColumnFilters(column)); -} - -class $$PluginsTableTableOrderingComposer - extends Composer<_$AppDatabase, $PluginsTableTable> { - $$PluginsTableTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get name => $composableBuilder( - column: $table.name, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get description => $composableBuilder( - column: $table.description, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get version => $composableBuilder( - column: $table.version, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get author => $composableBuilder( - column: $table.author, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get entryPoint => $composableBuilder( - column: $table.entryPoint, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get apis => $composableBuilder( - column: $table.apis, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get abilities => $composableBuilder( - column: $table.abilities, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get selectedForMetadata => $composableBuilder( - column: $table.selectedForMetadata, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get selectedForAudioSource => $composableBuilder( - column: $table.selectedForAudioSource, - builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get repository => $composableBuilder( - column: $table.repository, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get pluginApiVersion => $composableBuilder( - column: $table.pluginApiVersion, - builder: (column) => ColumnOrderings(column)); -} - -class $$PluginsTableTableAnnotationComposer - extends Composer<_$AppDatabase, $PluginsTableTable> { - $$PluginsTableTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get name => - $composableBuilder(column: $table.name, builder: (column) => column); - - GeneratedColumn get description => $composableBuilder( - column: $table.description, builder: (column) => column); - - GeneratedColumn get version => - $composableBuilder(column: $table.version, builder: (column) => column); - - GeneratedColumn get author => - $composableBuilder(column: $table.author, builder: (column) => column); - - GeneratedColumn get entryPoint => $composableBuilder( - column: $table.entryPoint, builder: (column) => column); - - GeneratedColumnWithTypeConverter, String> get apis => - $composableBuilder(column: $table.apis, builder: (column) => column); - - GeneratedColumnWithTypeConverter, String> get abilities => - $composableBuilder(column: $table.abilities, builder: (column) => column); - - GeneratedColumn get selectedForMetadata => $composableBuilder( - column: $table.selectedForMetadata, builder: (column) => column); - - GeneratedColumn get selectedForAudioSource => $composableBuilder( - column: $table.selectedForAudioSource, builder: (column) => column); - - GeneratedColumn get repository => $composableBuilder( - column: $table.repository, builder: (column) => column); - - GeneratedColumn get pluginApiVersion => $composableBuilder( - column: $table.pluginApiVersion, builder: (column) => column); -} - -class $$PluginsTableTableTableManager extends RootTableManager< - _$AppDatabase, - $PluginsTableTable, - PluginsTableData, - $$PluginsTableTableFilterComposer, - $$PluginsTableTableOrderingComposer, - $$PluginsTableTableAnnotationComposer, - $$PluginsTableTableCreateCompanionBuilder, - $$PluginsTableTableUpdateCompanionBuilder, - ( - PluginsTableData, - BaseReferences<_$AppDatabase, $PluginsTableTable, PluginsTableData> - ), - PluginsTableData, - PrefetchHooks Function()> { - $$PluginsTableTableTableManager(_$AppDatabase db, $PluginsTableTable table) - : super(TableManagerState( - db: db, - table: table, - createFilteringComposer: () => - $$PluginsTableTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$PluginsTableTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$PluginsTableTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value name = const Value.absent(), - Value description = const Value.absent(), - Value version = const Value.absent(), - Value author = const Value.absent(), - Value entryPoint = const Value.absent(), - Value> apis = const Value.absent(), - Value> abilities = const Value.absent(), - Value selectedForMetadata = const Value.absent(), - Value selectedForAudioSource = const Value.absent(), - Value repository = const Value.absent(), - Value pluginApiVersion = const Value.absent(), - }) => - PluginsTableCompanion( - id: id, - name: name, - description: description, - version: version, - author: author, - entryPoint: entryPoint, - apis: apis, - abilities: abilities, - selectedForMetadata: selectedForMetadata, - selectedForAudioSource: selectedForAudioSource, - repository: repository, - pluginApiVersion: pluginApiVersion, - ), - createCompanionCallback: ({ - Value id = const Value.absent(), - required String name, - required String description, - required String version, - required String author, - required String entryPoint, - required List apis, - required List abilities, - Value selectedForMetadata = const Value.absent(), - Value selectedForAudioSource = const Value.absent(), - Value repository = const Value.absent(), - Value pluginApiVersion = const Value.absent(), - }) => - PluginsTableCompanion.insert( - id: id, - name: name, - description: description, - version: version, - author: author, - entryPoint: entryPoint, - apis: apis, - abilities: abilities, - selectedForMetadata: selectedForMetadata, - selectedForAudioSource: selectedForAudioSource, - repository: repository, - pluginApiVersion: pluginApiVersion, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - )); -} - -typedef $$PluginsTableTableProcessedTableManager = ProcessedTableManager< - _$AppDatabase, - $PluginsTableTable, - PluginsTableData, - $$PluginsTableTableFilterComposer, - $$PluginsTableTableOrderingComposer, - $$PluginsTableTableAnnotationComposer, - $$PluginsTableTableCreateCompanionBuilder, - $$PluginsTableTableUpdateCompanionBuilder, - ( - PluginsTableData, - BaseReferences<_$AppDatabase, $PluginsTableTable, PluginsTableData> - ), - PluginsTableData, - PrefetchHooks Function()>; - -class $AppDatabaseManager { - final _$AppDatabase _db; - $AppDatabaseManager(this._db); - $$AuthenticationTableTableTableManager get authenticationTable => - $$AuthenticationTableTableTableManager(_db, _db.authenticationTable); - $$BlacklistTableTableTableManager get blacklistTable => - $$BlacklistTableTableTableManager(_db, _db.blacklistTable); - $$PreferencesTableTableTableManager get preferencesTable => - $$PreferencesTableTableTableManager(_db, _db.preferencesTable); - $$ScrobblerTableTableTableManager get scrobblerTable => - $$ScrobblerTableTableTableManager(_db, _db.scrobblerTable); - $$SkipSegmentTableTableTableManager get skipSegmentTable => - $$SkipSegmentTableTableTableManager(_db, _db.skipSegmentTable); - $$SourceMatchTableTableTableManager get sourceMatchTable => - $$SourceMatchTableTableTableManager(_db, _db.sourceMatchTable); - $$AudioPlayerStateTableTableTableManager get audioPlayerStateTable => - $$AudioPlayerStateTableTableTableManager(_db, _db.audioPlayerStateTable); - $$HistoryTableTableTableManager get historyTable => - $$HistoryTableTableTableManager(_db, _db.historyTable); - $$LyricsTableTableTableManager get lyricsTable => - $$LyricsTableTableTableManager(_db, _db.lyricsTable); - $$PluginsTableTableTableManager get pluginsTable => - $$PluginsTableTableTableManager(_db, _db.pluginsTable); -} diff --git a/lib/models/database/database.steps.dart b/lib/models/database/database.steps.dart deleted file mode 100644 index 42cbdf6d..00000000 --- a/lib/models/database/database.steps.dart +++ /dev/null @@ -1,2828 +0,0 @@ -// dart format width=80 -import 'package:drift/internal/versioned_schema.dart' as i0; -import 'package:drift/drift.dart' as i1; -import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import -import 'package:flutter/material.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/metadata/market.dart'; - -// GENERATED BY drift_dev, DO NOT MODIFY. -final class Schema2 extends i0.VersionedSchema { - Schema2({required super.database}) : super(version: 2); - @override - late final List entities = [ - authenticationTable, - blacklistTable, - preferencesTable, - scrobblerTable, - skipSegmentTable, - sourceMatchTable, - audioPlayerStateTable, - playlistTable, - playlistMediaTable, - historyTable, - lyricsTable, - uniqueBlacklist, - uniqTrackMatch, - ]; - late final Shape0 authenticationTable = Shape0( - source: i0.VersionedTable( - entityName: 'authentication_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_1, - _column_2, - _column_3, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape1 blacklistTable = Shape1( - source: i0.VersionedTable( - entityName: 'blacklist_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_4, - _column_5, - _column_6, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape2 preferencesTable = Shape2( - source: i0.VersionedTable( - entityName: 'preferences_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_7, - _column_8, - _column_9, - _column_10, - _column_11, - _column_12, - _column_13, - _column_14, - _column_15, - _column_16, - _column_17, - _column_18, - _column_19, - _column_20, - _column_21, - _column_22, - _column_23, - _column_24, - _column_25, - _column_26, - _column_27, - _column_28, - _column_29, - _column_30, - _column_31, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape3 scrobblerTable = Shape3( - source: i0.VersionedTable( - entityName: 'scrobbler_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_33, - _column_34, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape4 skipSegmentTable = Shape4( - source: i0.VersionedTable( - entityName: 'skip_segment_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_35, - _column_36, - _column_37, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape5 sourceMatchTable = Shape5( - source: i0.VersionedTable( - entityName: 'source_match_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_38, - _column_39, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape6 audioPlayerStateTable = Shape6( - source: i0.VersionedTable( - entityName: 'audio_player_state_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_40, - _column_41, - _column_42, - _column_43, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape7 playlistTable = Shape7( - source: i0.VersionedTable( - entityName: 'playlist_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_44, - _column_45, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape8 playlistMediaTable = Shape8( - source: i0.VersionedTable( - entityName: 'playlist_media_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_46, - _column_47, - _column_48, - _column_49, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape9 historyTable = Shape9( - source: i0.VersionedTable( - entityName: 'history_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_50, - _column_51, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape10 lyricsTable = Shape10( - source: i0.VersionedTable( - entityName: 'lyrics_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - final i1.Index uniqueBlacklist = i1.Index('unique_blacklist', - 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); - final i1.Index uniqTrackMatch = i1.Index('uniq_track_match', - 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); -} - -class Shape0 extends i0.VersionedTable { - Shape0({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get cookie => - columnsByName['cookie']! as i1.GeneratedColumn; - i1.GeneratedColumn get accessToken => - columnsByName['access_token']! as i1.GeneratedColumn; - i1.GeneratedColumn get expiration => - columnsByName['expiration']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_0(String aliasedName) => - i1.GeneratedColumn('id', aliasedName, false, - hasAutoIncrement: true, - type: i1.DriftSqlType.int, - defaultConstraints: - i1.GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); -i1.GeneratedColumn _column_1(String aliasedName) => - i1.GeneratedColumn('cookie', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_2(String aliasedName) => - i1.GeneratedColumn('access_token', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_3(String aliasedName) => - i1.GeneratedColumn('expiration', aliasedName, false, - type: i1.DriftSqlType.dateTime); - -class Shape1 extends i0.VersionedTable { - Shape1({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get name => - columnsByName['name']! as i1.GeneratedColumn; - i1.GeneratedColumn get elementType => - columnsByName['element_type']! as i1.GeneratedColumn; - i1.GeneratedColumn get elementId => - columnsByName['element_id']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_4(String aliasedName) => - i1.GeneratedColumn('name', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_5(String aliasedName) => - i1.GeneratedColumn('element_type', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_6(String aliasedName) => - i1.GeneratedColumn('element_id', aliasedName, false, - type: i1.DriftSqlType.string); - -class Shape2 extends i0.VersionedTable { - Shape2({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get audioQuality => - columnsByName['audio_quality']! as i1.GeneratedColumn; - i1.GeneratedColumn get albumColorSync => - columnsByName['album_color_sync']! as i1.GeneratedColumn; - i1.GeneratedColumn get amoledDarkTheme => - columnsByName['amoled_dark_theme']! as i1.GeneratedColumn; - i1.GeneratedColumn get checkUpdate => - columnsByName['check_update']! as i1.GeneratedColumn; - i1.GeneratedColumn get normalizeAudio => - columnsByName['normalize_audio']! as i1.GeneratedColumn; - i1.GeneratedColumn get showSystemTrayIcon => - columnsByName['show_system_tray_icon']! as i1.GeneratedColumn; - i1.GeneratedColumn get systemTitleBar => - columnsByName['system_title_bar']! as i1.GeneratedColumn; - i1.GeneratedColumn get skipNonMusic => - columnsByName['skip_non_music']! as i1.GeneratedColumn; - i1.GeneratedColumn get closeBehavior => - columnsByName['close_behavior']! as i1.GeneratedColumn; - i1.GeneratedColumn get accentColorScheme => - columnsByName['accent_color_scheme']! as i1.GeneratedColumn; - i1.GeneratedColumn get layoutMode => - columnsByName['layout_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get locale => - columnsByName['locale']! as i1.GeneratedColumn; - i1.GeneratedColumn get market => - columnsByName['market']! as i1.GeneratedColumn; - i1.GeneratedColumn get searchMode => - columnsByName['search_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get downloadLocation => - columnsByName['download_location']! as i1.GeneratedColumn; - i1.GeneratedColumn get localLibraryLocation => - columnsByName['local_library_location']! as i1.GeneratedColumn; - i1.GeneratedColumn get pipedInstance => - columnsByName['piped_instance']! as i1.GeneratedColumn; - i1.GeneratedColumn get invidiousInstance => - columnsByName['invidious_instance']! as i1.GeneratedColumn; - i1.GeneratedColumn get themeMode => - columnsByName['theme_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get audioSource => - columnsByName['audio_source']! as i1.GeneratedColumn; - i1.GeneratedColumn get streamMusicCodec => - columnsByName['stream_music_codec']! as i1.GeneratedColumn; - i1.GeneratedColumn get downloadMusicCodec => - columnsByName['download_music_codec']! as i1.GeneratedColumn; - i1.GeneratedColumn get discordPresence => - columnsByName['discord_presence']! as i1.GeneratedColumn; - i1.GeneratedColumn get endlessPlayback => - columnsByName['endless_playback']! as i1.GeneratedColumn; - i1.GeneratedColumn get enableConnect => - columnsByName['enable_connect']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_7(String aliasedName) => - i1.GeneratedColumn('audio_quality', aliasedName, false, - type: i1.DriftSqlType.string, defaultValue: Constant("high")); -i1.GeneratedColumn _column_8(String aliasedName) => - i1.GeneratedColumn('album_color_sync', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("album_color_sync" IN (0, 1))'), - defaultValue: const Constant(true)); -i1.GeneratedColumn _column_9(String aliasedName) => - i1.GeneratedColumn('amoled_dark_theme', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("amoled_dark_theme" IN (0, 1))'), - defaultValue: const Constant(false)); -i1.GeneratedColumn _column_10(String aliasedName) => - i1.GeneratedColumn('check_update', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("check_update" IN (0, 1))'), - defaultValue: const Constant(true)); -i1.GeneratedColumn _column_11(String aliasedName) => - i1.GeneratedColumn('normalize_audio', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("normalize_audio" IN (0, 1))'), - defaultValue: const Constant(false)); -i1.GeneratedColumn _column_12(String aliasedName) => - i1.GeneratedColumn('show_system_tray_icon', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("show_system_tray_icon" IN (0, 1))'), - defaultValue: const Constant(false)); -i1.GeneratedColumn _column_13(String aliasedName) => - i1.GeneratedColumn('system_title_bar', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("system_title_bar" IN (0, 1))'), - defaultValue: const Constant(false)); -i1.GeneratedColumn _column_14(String aliasedName) => - i1.GeneratedColumn('skip_non_music', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("skip_non_music" IN (0, 1))'), - defaultValue: const Constant(false)); -i1.GeneratedColumn _column_15(String aliasedName) => - i1.GeneratedColumn('close_behavior', aliasedName, false, - type: i1.DriftSqlType.string, - defaultValue: Constant(CloseBehavior.close.name)); -i1.GeneratedColumn _column_16(String aliasedName) => - i1.GeneratedColumn('accent_color_scheme', aliasedName, false, - type: i1.DriftSqlType.string, - defaultValue: const Constant("Blue:0xFF2196F3")); -i1.GeneratedColumn _column_17(String aliasedName) => - i1.GeneratedColumn('layout_mode', aliasedName, false, - type: i1.DriftSqlType.string, - defaultValue: Constant(LayoutMode.adaptive.name)); -i1.GeneratedColumn _column_18(String aliasedName) => - i1.GeneratedColumn('locale', aliasedName, false, - type: i1.DriftSqlType.string, - defaultValue: - const Constant('{"languageCode":"system","countryCode":"system"}')); -i1.GeneratedColumn _column_19(String aliasedName) => - i1.GeneratedColumn('market', aliasedName, false, - type: i1.DriftSqlType.string, defaultValue: Constant(Market.US.name)); -i1.GeneratedColumn _column_20(String aliasedName) => - i1.GeneratedColumn('search_mode', aliasedName, false, - type: i1.DriftSqlType.string, - defaultValue: Constant(SearchMode.youtube.name)); -i1.GeneratedColumn _column_21(String aliasedName) => - i1.GeneratedColumn('download_location', aliasedName, false, - type: i1.DriftSqlType.string, defaultValue: const Constant("")); -i1.GeneratedColumn _column_22(String aliasedName) => - i1.GeneratedColumn('local_library_location', aliasedName, false, - type: i1.DriftSqlType.string, defaultValue: const Constant("")); -i1.GeneratedColumn _column_23(String aliasedName) => - i1.GeneratedColumn('piped_instance', aliasedName, false, - type: i1.DriftSqlType.string, - defaultValue: const Constant("https://pipedapi.kavin.rocks")); -i1.GeneratedColumn _column_24(String aliasedName) => - i1.GeneratedColumn('invidious_instance', aliasedName, false, - type: i1.DriftSqlType.string, - defaultValue: const Constant("https://inv.nadeko.net")); -i1.GeneratedColumn _column_25(String aliasedName) => - i1.GeneratedColumn('theme_mode', aliasedName, false, - type: i1.DriftSqlType.string, - defaultValue: Constant(ThemeMode.system.name)); -i1.GeneratedColumn _column_26(String aliasedName) => - i1.GeneratedColumn('audio_source', aliasedName, false, - type: i1.DriftSqlType.string, defaultValue: Constant("youtube")); -i1.GeneratedColumn _column_27(String aliasedName) => - i1.GeneratedColumn('stream_music_codec', aliasedName, false, - type: i1.DriftSqlType.string, defaultValue: Constant("weba")); -i1.GeneratedColumn _column_28(String aliasedName) => - i1.GeneratedColumn('download_music_codec', aliasedName, false, - type: i1.DriftSqlType.string, defaultValue: Constant("m4a")); -i1.GeneratedColumn _column_29(String aliasedName) => - i1.GeneratedColumn('discord_presence', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("discord_presence" IN (0, 1))'), - defaultValue: const Constant(true)); -i1.GeneratedColumn _column_30(String aliasedName) => - i1.GeneratedColumn('endless_playback', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("endless_playback" IN (0, 1))'), - defaultValue: const Constant(true)); -i1.GeneratedColumn _column_31(String aliasedName) => - i1.GeneratedColumn('enable_connect', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("enable_connect" IN (0, 1))'), - defaultValue: const Constant(false)); - -class Shape3 extends i0.VersionedTable { - Shape3({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get createdAt => - columnsByName['created_at']! as i1.GeneratedColumn; - i1.GeneratedColumn get username => - columnsByName['username']! as i1.GeneratedColumn; - i1.GeneratedColumn get passwordHash => - columnsByName['password_hash']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_32(String aliasedName) => - i1.GeneratedColumn('created_at', aliasedName, false, - type: i1.DriftSqlType.dateTime, defaultValue: currentDateAndTime); -i1.GeneratedColumn _column_33(String aliasedName) => - i1.GeneratedColumn('username', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_34(String aliasedName) => - i1.GeneratedColumn('password_hash', aliasedName, false, - type: i1.DriftSqlType.string); - -class Shape4 extends i0.VersionedTable { - Shape4({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get start => - columnsByName['start']! as i1.GeneratedColumn; - i1.GeneratedColumn get end => - columnsByName['end']! as i1.GeneratedColumn; - i1.GeneratedColumn get trackId => - columnsByName['track_id']! as i1.GeneratedColumn; - i1.GeneratedColumn get createdAt => - columnsByName['created_at']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_35(String aliasedName) => - i1.GeneratedColumn('start', aliasedName, false, - type: i1.DriftSqlType.int); -i1.GeneratedColumn _column_36(String aliasedName) => - i1.GeneratedColumn('end', aliasedName, false, - type: i1.DriftSqlType.int); -i1.GeneratedColumn _column_37(String aliasedName) => - i1.GeneratedColumn('track_id', aliasedName, false, - type: i1.DriftSqlType.string); - -class Shape5 extends i0.VersionedTable { - Shape5({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get trackId => - columnsByName['track_id']! as i1.GeneratedColumn; - i1.GeneratedColumn get sourceId => - columnsByName['source_id']! as i1.GeneratedColumn; - i1.GeneratedColumn get sourceType => - columnsByName['source_type']! as i1.GeneratedColumn; - i1.GeneratedColumn get createdAt => - columnsByName['created_at']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_38(String aliasedName) => - i1.GeneratedColumn('source_id', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_39(String aliasedName) => - i1.GeneratedColumn('source_type', aliasedName, false, - type: i1.DriftSqlType.string, defaultValue: Constant("youtube")); - -class Shape6 extends i0.VersionedTable { - Shape6({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get playing => - columnsByName['playing']! as i1.GeneratedColumn; - i1.GeneratedColumn get loopMode => - columnsByName['loop_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get shuffled => - columnsByName['shuffled']! as i1.GeneratedColumn; - i1.GeneratedColumn get collections => - columnsByName['collections']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_40(String aliasedName) => - i1.GeneratedColumn('playing', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("playing" IN (0, 1))')); -i1.GeneratedColumn _column_41(String aliasedName) => - i1.GeneratedColumn('loop_mode', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_42(String aliasedName) => - i1.GeneratedColumn('shuffled', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("shuffled" IN (0, 1))')); -i1.GeneratedColumn _column_43(String aliasedName) => - i1.GeneratedColumn('collections', aliasedName, false, - type: i1.DriftSqlType.string); - -class Shape7 extends i0.VersionedTable { - Shape7({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get audioPlayerStateId => - columnsByName['audio_player_state_id']! as i1.GeneratedColumn; - i1.GeneratedColumn get index => - columnsByName['index']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_44(String aliasedName) => - i1.GeneratedColumn('audio_player_state_id', aliasedName, false, - type: i1.DriftSqlType.int, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'REFERENCES audio_player_state_table (id)')); -i1.GeneratedColumn _column_45(String aliasedName) => - i1.GeneratedColumn('index', aliasedName, false, - type: i1.DriftSqlType.int); - -class Shape8 extends i0.VersionedTable { - Shape8({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get playlistId => - columnsByName['playlist_id']! as i1.GeneratedColumn; - i1.GeneratedColumn get uri => - columnsByName['uri']! as i1.GeneratedColumn; - i1.GeneratedColumn get extras => - columnsByName['extras']! as i1.GeneratedColumn; - i1.GeneratedColumn get httpHeaders => - columnsByName['http_headers']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_46(String aliasedName) => - i1.GeneratedColumn('playlist_id', aliasedName, false, - type: i1.DriftSqlType.int, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'REFERENCES playlist_table (id)')); -i1.GeneratedColumn _column_47(String aliasedName) => - i1.GeneratedColumn('uri', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_48(String aliasedName) => - i1.GeneratedColumn('extras', aliasedName, true, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_49(String aliasedName) => - i1.GeneratedColumn('http_headers', aliasedName, true, - type: i1.DriftSqlType.string); - -class Shape9 extends i0.VersionedTable { - Shape9({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get createdAt => - columnsByName['created_at']! as i1.GeneratedColumn; - i1.GeneratedColumn get type => - columnsByName['type']! as i1.GeneratedColumn; - i1.GeneratedColumn get itemId => - columnsByName['item_id']! as i1.GeneratedColumn; - i1.GeneratedColumn get data => - columnsByName['data']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_50(String aliasedName) => - i1.GeneratedColumn('type', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_51(String aliasedName) => - i1.GeneratedColumn('item_id', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_52(String aliasedName) => - i1.GeneratedColumn('data', aliasedName, false, - type: i1.DriftSqlType.string); - -class Shape10 extends i0.VersionedTable { - Shape10({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get trackId => - columnsByName['track_id']! as i1.GeneratedColumn; - i1.GeneratedColumn get data => - columnsByName['data']! as i1.GeneratedColumn; -} - -final class Schema3 extends i0.VersionedSchema { - Schema3({required super.database}) : super(version: 3); - @override - late final List entities = [ - authenticationTable, - blacklistTable, - preferencesTable, - scrobblerTable, - skipSegmentTable, - sourceMatchTable, - audioPlayerStateTable, - playlistTable, - playlistMediaTable, - historyTable, - lyricsTable, - uniqueBlacklist, - uniqTrackMatch, - ]; - late final Shape0 authenticationTable = Shape0( - source: i0.VersionedTable( - entityName: 'authentication_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_1, - _column_2, - _column_3, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape1 blacklistTable = Shape1( - source: i0.VersionedTable( - entityName: 'blacklist_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_4, - _column_5, - _column_6, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape11 preferencesTable = Shape11( - source: i0.VersionedTable( - entityName: 'preferences_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_7, - _column_8, - _column_9, - _column_10, - _column_11, - _column_12, - _column_13, - _column_14, - _column_15, - _column_16, - _column_17, - _column_18, - _column_19, - _column_20, - _column_21, - _column_22, - _column_23, - _column_24, - _column_25, - _column_26, - _column_27, - _column_28, - _column_29, - _column_30, - _column_31, - _column_53, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape3 scrobblerTable = Shape3( - source: i0.VersionedTable( - entityName: 'scrobbler_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_33, - _column_34, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape4 skipSegmentTable = Shape4( - source: i0.VersionedTable( - entityName: 'skip_segment_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_35, - _column_36, - _column_37, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape5 sourceMatchTable = Shape5( - source: i0.VersionedTable( - entityName: 'source_match_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_38, - _column_39, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape6 audioPlayerStateTable = Shape6( - source: i0.VersionedTable( - entityName: 'audio_player_state_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_40, - _column_41, - _column_42, - _column_43, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape7 playlistTable = Shape7( - source: i0.VersionedTable( - entityName: 'playlist_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_44, - _column_45, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape8 playlistMediaTable = Shape8( - source: i0.VersionedTable( - entityName: 'playlist_media_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_46, - _column_47, - _column_48, - _column_49, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape9 historyTable = Shape9( - source: i0.VersionedTable( - entityName: 'history_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_50, - _column_51, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape10 lyricsTable = Shape10( - source: i0.VersionedTable( - entityName: 'lyrics_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - final i1.Index uniqueBlacklist = i1.Index('unique_blacklist', - 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); - final i1.Index uniqTrackMatch = i1.Index('uniq_track_match', - 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); -} - -class Shape11 extends i0.VersionedTable { - Shape11({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get audioQuality => - columnsByName['audio_quality']! as i1.GeneratedColumn; - i1.GeneratedColumn get albumColorSync => - columnsByName['album_color_sync']! as i1.GeneratedColumn; - i1.GeneratedColumn get amoledDarkTheme => - columnsByName['amoled_dark_theme']! as i1.GeneratedColumn; - i1.GeneratedColumn get checkUpdate => - columnsByName['check_update']! as i1.GeneratedColumn; - i1.GeneratedColumn get normalizeAudio => - columnsByName['normalize_audio']! as i1.GeneratedColumn; - i1.GeneratedColumn get showSystemTrayIcon => - columnsByName['show_system_tray_icon']! as i1.GeneratedColumn; - i1.GeneratedColumn get systemTitleBar => - columnsByName['system_title_bar']! as i1.GeneratedColumn; - i1.GeneratedColumn get skipNonMusic => - columnsByName['skip_non_music']! as i1.GeneratedColumn; - i1.GeneratedColumn get closeBehavior => - columnsByName['close_behavior']! as i1.GeneratedColumn; - i1.GeneratedColumn get accentColorScheme => - columnsByName['accent_color_scheme']! as i1.GeneratedColumn; - i1.GeneratedColumn get layoutMode => - columnsByName['layout_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get locale => - columnsByName['locale']! as i1.GeneratedColumn; - i1.GeneratedColumn get market => - columnsByName['market']! as i1.GeneratedColumn; - i1.GeneratedColumn get searchMode => - columnsByName['search_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get downloadLocation => - columnsByName['download_location']! as i1.GeneratedColumn; - i1.GeneratedColumn get localLibraryLocation => - columnsByName['local_library_location']! as i1.GeneratedColumn; - i1.GeneratedColumn get pipedInstance => - columnsByName['piped_instance']! as i1.GeneratedColumn; - i1.GeneratedColumn get invidiousInstance => - columnsByName['invidious_instance']! as i1.GeneratedColumn; - i1.GeneratedColumn get themeMode => - columnsByName['theme_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get audioSource => - columnsByName['audio_source']! as i1.GeneratedColumn; - i1.GeneratedColumn get streamMusicCodec => - columnsByName['stream_music_codec']! as i1.GeneratedColumn; - i1.GeneratedColumn get downloadMusicCodec => - columnsByName['download_music_codec']! as i1.GeneratedColumn; - i1.GeneratedColumn get discordPresence => - columnsByName['discord_presence']! as i1.GeneratedColumn; - i1.GeneratedColumn get endlessPlayback => - columnsByName['endless_playback']! as i1.GeneratedColumn; - i1.GeneratedColumn get enableConnect => - columnsByName['enable_connect']! as i1.GeneratedColumn; - i1.GeneratedColumn get cacheMusic => - columnsByName['cache_music']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_53(String aliasedName) => - i1.GeneratedColumn('cache_music', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("cache_music" IN (0, 1))'), - defaultValue: const Constant(true)); - -final class Schema4 extends i0.VersionedSchema { - Schema4({required super.database}) : super(version: 4); - @override - late final List entities = [ - authenticationTable, - blacklistTable, - preferencesTable, - scrobblerTable, - skipSegmentTable, - sourceMatchTable, - audioPlayerStateTable, - playlistTable, - playlistMediaTable, - historyTable, - lyricsTable, - uniqueBlacklist, - uniqTrackMatch, - ]; - late final Shape0 authenticationTable = Shape0( - source: i0.VersionedTable( - entityName: 'authentication_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_1, - _column_2, - _column_3, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape1 blacklistTable = Shape1( - source: i0.VersionedTable( - entityName: 'blacklist_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_4, - _column_5, - _column_6, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape12 preferencesTable = Shape12( - source: i0.VersionedTable( - entityName: 'preferences_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_7, - _column_8, - _column_9, - _column_10, - _column_11, - _column_12, - _column_13, - _column_14, - _column_15, - _column_16, - _column_17, - _column_18, - _column_19, - _column_20, - _column_21, - _column_22, - _column_23, - _column_24, - _column_25, - _column_26, - _column_54, - _column_27, - _column_28, - _column_29, - _column_30, - _column_31, - _column_53, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape3 scrobblerTable = Shape3( - source: i0.VersionedTable( - entityName: 'scrobbler_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_33, - _column_34, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape4 skipSegmentTable = Shape4( - source: i0.VersionedTable( - entityName: 'skip_segment_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_35, - _column_36, - _column_37, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape5 sourceMatchTable = Shape5( - source: i0.VersionedTable( - entityName: 'source_match_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_38, - _column_39, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape6 audioPlayerStateTable = Shape6( - source: i0.VersionedTable( - entityName: 'audio_player_state_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_40, - _column_41, - _column_42, - _column_43, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape7 playlistTable = Shape7( - source: i0.VersionedTable( - entityName: 'playlist_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_44, - _column_45, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape8 playlistMediaTable = Shape8( - source: i0.VersionedTable( - entityName: 'playlist_media_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_46, - _column_47, - _column_48, - _column_49, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape9 historyTable = Shape9( - source: i0.VersionedTable( - entityName: 'history_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_50, - _column_51, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape10 lyricsTable = Shape10( - source: i0.VersionedTable( - entityName: 'lyrics_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - final i1.Index uniqueBlacklist = i1.Index('unique_blacklist', - 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); - final i1.Index uniqTrackMatch = i1.Index('uniq_track_match', - 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); -} - -class Shape12 extends i0.VersionedTable { - Shape12({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get audioQuality => - columnsByName['audio_quality']! as i1.GeneratedColumn; - i1.GeneratedColumn get albumColorSync => - columnsByName['album_color_sync']! as i1.GeneratedColumn; - i1.GeneratedColumn get amoledDarkTheme => - columnsByName['amoled_dark_theme']! as i1.GeneratedColumn; - i1.GeneratedColumn get checkUpdate => - columnsByName['check_update']! as i1.GeneratedColumn; - i1.GeneratedColumn get normalizeAudio => - columnsByName['normalize_audio']! as i1.GeneratedColumn; - i1.GeneratedColumn get showSystemTrayIcon => - columnsByName['show_system_tray_icon']! as i1.GeneratedColumn; - i1.GeneratedColumn get systemTitleBar => - columnsByName['system_title_bar']! as i1.GeneratedColumn; - i1.GeneratedColumn get skipNonMusic => - columnsByName['skip_non_music']! as i1.GeneratedColumn; - i1.GeneratedColumn get closeBehavior => - columnsByName['close_behavior']! as i1.GeneratedColumn; - i1.GeneratedColumn get accentColorScheme => - columnsByName['accent_color_scheme']! as i1.GeneratedColumn; - i1.GeneratedColumn get layoutMode => - columnsByName['layout_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get locale => - columnsByName['locale']! as i1.GeneratedColumn; - i1.GeneratedColumn get market => - columnsByName['market']! as i1.GeneratedColumn; - i1.GeneratedColumn get searchMode => - columnsByName['search_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get downloadLocation => - columnsByName['download_location']! as i1.GeneratedColumn; - i1.GeneratedColumn get localLibraryLocation => - columnsByName['local_library_location']! as i1.GeneratedColumn; - i1.GeneratedColumn get pipedInstance => - columnsByName['piped_instance']! as i1.GeneratedColumn; - i1.GeneratedColumn get invidiousInstance => - columnsByName['invidious_instance']! as i1.GeneratedColumn; - i1.GeneratedColumn get themeMode => - columnsByName['theme_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get audioSource => - columnsByName['audio_source']! as i1.GeneratedColumn; - i1.GeneratedColumn get youtubeClientEngine => - columnsByName['youtube_client_engine']! as i1.GeneratedColumn; - i1.GeneratedColumn get streamMusicCodec => - columnsByName['stream_music_codec']! as i1.GeneratedColumn; - i1.GeneratedColumn get downloadMusicCodec => - columnsByName['download_music_codec']! as i1.GeneratedColumn; - i1.GeneratedColumn get discordPresence => - columnsByName['discord_presence']! as i1.GeneratedColumn; - i1.GeneratedColumn get endlessPlayback => - columnsByName['endless_playback']! as i1.GeneratedColumn; - i1.GeneratedColumn get enableConnect => - columnsByName['enable_connect']! as i1.GeneratedColumn; - i1.GeneratedColumn get cacheMusic => - columnsByName['cache_music']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_54(String aliasedName) => - i1.GeneratedColumn('youtube_client_engine', aliasedName, false, - type: i1.DriftSqlType.string, - defaultValue: Constant(YoutubeClientEngine.youtubeExplode.name)); - -final class Schema5 extends i0.VersionedSchema { - Schema5({required super.database}) : super(version: 5); - @override - late final List entities = [ - authenticationTable, - blacklistTable, - preferencesTable, - scrobblerTable, - skipSegmentTable, - sourceMatchTable, - audioPlayerStateTable, - playlistTable, - playlistMediaTable, - historyTable, - lyricsTable, - uniqueBlacklist, - uniqTrackMatch, - ]; - late final Shape0 authenticationTable = Shape0( - source: i0.VersionedTable( - entityName: 'authentication_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_1, - _column_2, - _column_3, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape1 blacklistTable = Shape1( - source: i0.VersionedTable( - entityName: 'blacklist_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_4, - _column_5, - _column_6, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape12 preferencesTable = Shape12( - source: i0.VersionedTable( - entityName: 'preferences_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_7, - _column_8, - _column_9, - _column_10, - _column_11, - _column_12, - _column_13, - _column_14, - _column_15, - _column_55, - _column_17, - _column_18, - _column_19, - _column_20, - _column_21, - _column_22, - _column_23, - _column_24, - _column_25, - _column_26, - _column_54, - _column_27, - _column_28, - _column_29, - _column_30, - _column_31, - _column_53, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape3 scrobblerTable = Shape3( - source: i0.VersionedTable( - entityName: 'scrobbler_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_33, - _column_34, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape4 skipSegmentTable = Shape4( - source: i0.VersionedTable( - entityName: 'skip_segment_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_35, - _column_36, - _column_37, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape5 sourceMatchTable = Shape5( - source: i0.VersionedTable( - entityName: 'source_match_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_38, - _column_39, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape6 audioPlayerStateTable = Shape6( - source: i0.VersionedTable( - entityName: 'audio_player_state_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_40, - _column_41, - _column_42, - _column_43, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape7 playlistTable = Shape7( - source: i0.VersionedTable( - entityName: 'playlist_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_44, - _column_45, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape8 playlistMediaTable = Shape8( - source: i0.VersionedTable( - entityName: 'playlist_media_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_46, - _column_47, - _column_48, - _column_49, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape9 historyTable = Shape9( - source: i0.VersionedTable( - entityName: 'history_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_50, - _column_51, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape10 lyricsTable = Shape10( - source: i0.VersionedTable( - entityName: 'lyrics_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - final i1.Index uniqueBlacklist = i1.Index('unique_blacklist', - 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); - final i1.Index uniqTrackMatch = i1.Index('uniq_track_match', - 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); -} - -i1.GeneratedColumn _column_55(String aliasedName) => - i1.GeneratedColumn('accent_color_scheme', aliasedName, false, - type: i1.DriftSqlType.string, - defaultValue: const Constant("Orange:0xFFf97315")); - -final class Schema6 extends i0.VersionedSchema { - Schema6({required super.database}) : super(version: 6); - @override - late final List entities = [ - authenticationTable, - blacklistTable, - preferencesTable, - scrobblerTable, - skipSegmentTable, - sourceMatchTable, - audioPlayerStateTable, - playlistTable, - playlistMediaTable, - historyTable, - lyricsTable, - uniqueBlacklist, - uniqTrackMatch, - ]; - late final Shape0 authenticationTable = Shape0( - source: i0.VersionedTable( - entityName: 'authentication_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_1, - _column_2, - _column_3, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape1 blacklistTable = Shape1( - source: i0.VersionedTable( - entityName: 'blacklist_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_4, - _column_5, - _column_6, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape13 preferencesTable = Shape13( - source: i0.VersionedTable( - entityName: 'preferences_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_7, - _column_8, - _column_9, - _column_10, - _column_11, - _column_12, - _column_13, - _column_14, - _column_15, - _column_55, - _column_17, - _column_18, - _column_19, - _column_20, - _column_21, - _column_22, - _column_23, - _column_24, - _column_25, - _column_26, - _column_54, - _column_27, - _column_28, - _column_29, - _column_30, - _column_31, - _column_56, - _column_53, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape3 scrobblerTable = Shape3( - source: i0.VersionedTable( - entityName: 'scrobbler_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_33, - _column_34, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape4 skipSegmentTable = Shape4( - source: i0.VersionedTable( - entityName: 'skip_segment_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_35, - _column_36, - _column_37, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape5 sourceMatchTable = Shape5( - source: i0.VersionedTable( - entityName: 'source_match_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_38, - _column_39, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape6 audioPlayerStateTable = Shape6( - source: i0.VersionedTable( - entityName: 'audio_player_state_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_40, - _column_41, - _column_42, - _column_43, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape7 playlistTable = Shape7( - source: i0.VersionedTable( - entityName: 'playlist_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_44, - _column_45, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape8 playlistMediaTable = Shape8( - source: i0.VersionedTable( - entityName: 'playlist_media_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_46, - _column_47, - _column_48, - _column_49, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape9 historyTable = Shape9( - source: i0.VersionedTable( - entityName: 'history_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_50, - _column_51, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape10 lyricsTable = Shape10( - source: i0.VersionedTable( - entityName: 'lyrics_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - final i1.Index uniqueBlacklist = i1.Index('unique_blacklist', - 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); - final i1.Index uniqTrackMatch = i1.Index('uniq_track_match', - 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); -} - -class Shape13 extends i0.VersionedTable { - Shape13({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get audioQuality => - columnsByName['audio_quality']! as i1.GeneratedColumn; - i1.GeneratedColumn get albumColorSync => - columnsByName['album_color_sync']! as i1.GeneratedColumn; - i1.GeneratedColumn get amoledDarkTheme => - columnsByName['amoled_dark_theme']! as i1.GeneratedColumn; - i1.GeneratedColumn get checkUpdate => - columnsByName['check_update']! as i1.GeneratedColumn; - i1.GeneratedColumn get normalizeAudio => - columnsByName['normalize_audio']! as i1.GeneratedColumn; - i1.GeneratedColumn get showSystemTrayIcon => - columnsByName['show_system_tray_icon']! as i1.GeneratedColumn; - i1.GeneratedColumn get systemTitleBar => - columnsByName['system_title_bar']! as i1.GeneratedColumn; - i1.GeneratedColumn get skipNonMusic => - columnsByName['skip_non_music']! as i1.GeneratedColumn; - i1.GeneratedColumn get closeBehavior => - columnsByName['close_behavior']! as i1.GeneratedColumn; - i1.GeneratedColumn get accentColorScheme => - columnsByName['accent_color_scheme']! as i1.GeneratedColumn; - i1.GeneratedColumn get layoutMode => - columnsByName['layout_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get locale => - columnsByName['locale']! as i1.GeneratedColumn; - i1.GeneratedColumn get market => - columnsByName['market']! as i1.GeneratedColumn; - i1.GeneratedColumn get searchMode => - columnsByName['search_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get downloadLocation => - columnsByName['download_location']! as i1.GeneratedColumn; - i1.GeneratedColumn get localLibraryLocation => - columnsByName['local_library_location']! as i1.GeneratedColumn; - i1.GeneratedColumn get pipedInstance => - columnsByName['piped_instance']! as i1.GeneratedColumn; - i1.GeneratedColumn get invidiousInstance => - columnsByName['invidious_instance']! as i1.GeneratedColumn; - i1.GeneratedColumn get themeMode => - columnsByName['theme_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get audioSource => - columnsByName['audio_source']! as i1.GeneratedColumn; - i1.GeneratedColumn get youtubeClientEngine => - columnsByName['youtube_client_engine']! as i1.GeneratedColumn; - i1.GeneratedColumn get streamMusicCodec => - columnsByName['stream_music_codec']! as i1.GeneratedColumn; - i1.GeneratedColumn get downloadMusicCodec => - columnsByName['download_music_codec']! as i1.GeneratedColumn; - i1.GeneratedColumn get discordPresence => - columnsByName['discord_presence']! as i1.GeneratedColumn; - i1.GeneratedColumn get endlessPlayback => - columnsByName['endless_playback']! as i1.GeneratedColumn; - i1.GeneratedColumn get enableConnect => - columnsByName['enable_connect']! as i1.GeneratedColumn; - i1.GeneratedColumn get connectPort => - columnsByName['connect_port']! as i1.GeneratedColumn; - i1.GeneratedColumn get cacheMusic => - columnsByName['cache_music']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_56(String aliasedName) => - i1.GeneratedColumn('connect_port', aliasedName, false, - type: i1.DriftSqlType.int, defaultValue: const Constant(-1)); - -final class Schema7 extends i0.VersionedSchema { - Schema7({required super.database}) : super(version: 7); - @override - late final List entities = [ - authenticationTable, - blacklistTable, - preferencesTable, - scrobblerTable, - skipSegmentTable, - sourceMatchTable, - audioPlayerStateTable, - historyTable, - lyricsTable, - metadataPluginsTable, - uniqueBlacklist, - uniqTrackMatch, - ]; - late final Shape0 authenticationTable = Shape0( - source: i0.VersionedTable( - entityName: 'authentication_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_1, - _column_2, - _column_3, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape1 blacklistTable = Shape1( - source: i0.VersionedTable( - entityName: 'blacklist_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_4, - _column_5, - _column_6, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape13 preferencesTable = Shape13( - source: i0.VersionedTable( - entityName: 'preferences_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_7, - _column_8, - _column_9, - _column_10, - _column_11, - _column_12, - _column_13, - _column_14, - _column_15, - _column_55, - _column_17, - _column_18, - _column_19, - _column_20, - _column_21, - _column_22, - _column_23, - _column_24, - _column_25, - _column_26, - _column_54, - _column_27, - _column_28, - _column_29, - _column_30, - _column_31, - _column_56, - _column_53, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape3 scrobblerTable = Shape3( - source: i0.VersionedTable( - entityName: 'scrobbler_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_33, - _column_34, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape4 skipSegmentTable = Shape4( - source: i0.VersionedTable( - entityName: 'skip_segment_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_35, - _column_36, - _column_37, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape5 sourceMatchTable = Shape5( - source: i0.VersionedTable( - entityName: 'source_match_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_38, - _column_39, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape14 audioPlayerStateTable = Shape14( - source: i0.VersionedTable( - entityName: 'audio_player_state_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_40, - _column_41, - _column_42, - _column_43, - _column_57, - _column_58, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape9 historyTable = Shape9( - source: i0.VersionedTable( - entityName: 'history_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_50, - _column_51, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape10 lyricsTable = Shape10( - source: i0.VersionedTable( - entityName: 'lyrics_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape15 metadataPluginsTable = Shape15( - source: i0.VersionedTable( - entityName: 'metadata_plugins_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_59, - _column_60, - _column_61, - _column_62, - _column_63, - _column_64, - _column_65, - _column_66, - _column_67, - _column_68, - ], - attachedDatabase: database, - ), - alias: null); - final i1.Index uniqueBlacklist = i1.Index('unique_blacklist', - 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); - final i1.Index uniqTrackMatch = i1.Index('uniq_track_match', - 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); -} - -class Shape14 extends i0.VersionedTable { - Shape14({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get playing => - columnsByName['playing']! as i1.GeneratedColumn; - i1.GeneratedColumn get loopMode => - columnsByName['loop_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get shuffled => - columnsByName['shuffled']! as i1.GeneratedColumn; - i1.GeneratedColumn get collections => - columnsByName['collections']! as i1.GeneratedColumn; - i1.GeneratedColumn get tracks => - columnsByName['tracks']! as i1.GeneratedColumn; - i1.GeneratedColumn get currentIndex => - columnsByName['current_index']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_57(String aliasedName) => - i1.GeneratedColumn('tracks', aliasedName, false, - type: i1.DriftSqlType.string, defaultValue: const Constant("[]")); -i1.GeneratedColumn _column_58(String aliasedName) => - i1.GeneratedColumn('current_index', aliasedName, false, - type: i1.DriftSqlType.int, defaultValue: const Constant(0)); - -class Shape15 extends i0.VersionedTable { - Shape15({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get name => - columnsByName['name']! as i1.GeneratedColumn; - i1.GeneratedColumn get description => - columnsByName['description']! as i1.GeneratedColumn; - i1.GeneratedColumn get version => - columnsByName['version']! as i1.GeneratedColumn; - i1.GeneratedColumn get author => - columnsByName['author']! as i1.GeneratedColumn; - i1.GeneratedColumn get entryPoint => - columnsByName['entry_point']! as i1.GeneratedColumn; - i1.GeneratedColumn get apis => - columnsByName['apis']! as i1.GeneratedColumn; - i1.GeneratedColumn get abilities => - columnsByName['abilities']! as i1.GeneratedColumn; - i1.GeneratedColumn get selected => - columnsByName['selected']! as i1.GeneratedColumn; - i1.GeneratedColumn get repository => - columnsByName['repository']! as i1.GeneratedColumn; - i1.GeneratedColumn get pluginApiVersion => - columnsByName['plugin_api_version']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_59(String aliasedName) => - i1.GeneratedColumn('name', aliasedName, false, - additionalChecks: i1.GeneratedColumn.checkTextLength( - minTextLength: 1, maxTextLength: 50), - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_60(String aliasedName) => - i1.GeneratedColumn('description', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_61(String aliasedName) => - i1.GeneratedColumn('version', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_62(String aliasedName) => - i1.GeneratedColumn('author', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_63(String aliasedName) => - i1.GeneratedColumn('entry_point', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_64(String aliasedName) => - i1.GeneratedColumn('apis', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_65(String aliasedName) => - i1.GeneratedColumn('abilities', aliasedName, false, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_66(String aliasedName) => - i1.GeneratedColumn('selected', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("selected" IN (0, 1))'), - defaultValue: const Constant(false)); -i1.GeneratedColumn _column_67(String aliasedName) => - i1.GeneratedColumn('repository', aliasedName, true, - type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_68(String aliasedName) => - i1.GeneratedColumn('plugin_api_version', aliasedName, false, - type: i1.DriftSqlType.string); - -final class Schema8 extends i0.VersionedSchema { - Schema8({required super.database}) : super(version: 8); - @override - late final List entities = [ - authenticationTable, - blacklistTable, - preferencesTable, - scrobblerTable, - skipSegmentTable, - sourceMatchTable, - audioPlayerStateTable, - historyTable, - lyricsTable, - metadataPluginsTable, - uniqueBlacklist, - uniqTrackMatch, - ]; - late final Shape0 authenticationTable = Shape0( - source: i0.VersionedTable( - entityName: 'authentication_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_1, - _column_2, - _column_3, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape1 blacklistTable = Shape1( - source: i0.VersionedTable( - entityName: 'blacklist_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_4, - _column_5, - _column_6, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape13 preferencesTable = Shape13( - source: i0.VersionedTable( - entityName: 'preferences_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_7, - _column_8, - _column_9, - _column_10, - _column_11, - _column_12, - _column_13, - _column_14, - _column_15, - _column_69, - _column_17, - _column_18, - _column_19, - _column_20, - _column_21, - _column_22, - _column_23, - _column_24, - _column_25, - _column_26, - _column_54, - _column_27, - _column_28, - _column_29, - _column_30, - _column_31, - _column_56, - _column_53, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape3 scrobblerTable = Shape3( - source: i0.VersionedTable( - entityName: 'scrobbler_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_33, - _column_34, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape4 skipSegmentTable = Shape4( - source: i0.VersionedTable( - entityName: 'skip_segment_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_35, - _column_36, - _column_37, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape5 sourceMatchTable = Shape5( - source: i0.VersionedTable( - entityName: 'source_match_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_38, - _column_39, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape14 audioPlayerStateTable = Shape14( - source: i0.VersionedTable( - entityName: 'audio_player_state_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_40, - _column_41, - _column_42, - _column_43, - _column_57, - _column_58, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape9 historyTable = Shape9( - source: i0.VersionedTable( - entityName: 'history_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_50, - _column_51, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape10 lyricsTable = Shape10( - source: i0.VersionedTable( - entityName: 'lyrics_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape15 metadataPluginsTable = Shape15( - source: i0.VersionedTable( - entityName: 'metadata_plugins_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_59, - _column_60, - _column_61, - _column_62, - _column_63, - _column_64, - _column_65, - _column_66, - _column_67, - _column_70, - ], - attachedDatabase: database, - ), - alias: null); - final i1.Index uniqueBlacklist = i1.Index('unique_blacklist', - 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); - final i1.Index uniqTrackMatch = i1.Index('uniq_track_match', - 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); -} - -i1.GeneratedColumn _column_69(String aliasedName) => - i1.GeneratedColumn('accent_color_scheme', aliasedName, false, - type: i1.DriftSqlType.string, - defaultValue: const Constant("Slate:0xff64748b")); -i1.GeneratedColumn _column_70(String aliasedName) => - i1.GeneratedColumn('plugin_api_version', aliasedName, false, - type: i1.DriftSqlType.string, defaultValue: const Constant('1.0.0')); - -final class Schema9 extends i0.VersionedSchema { - Schema9({required super.database}) : super(version: 9); - @override - late final List entities = [ - authenticationTable, - blacklistTable, - preferencesTable, - scrobblerTable, - skipSegmentTable, - sourceMatchTable, - audioPlayerStateTable, - historyTable, - lyricsTable, - pluginsTable, - uniqueBlacklist, - uniqTrackMatch, - ]; - late final Shape0 authenticationTable = Shape0( - source: i0.VersionedTable( - entityName: 'authentication_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_1, - _column_2, - _column_3, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape1 blacklistTable = Shape1( - source: i0.VersionedTable( - entityName: 'blacklist_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_4, - _column_5, - _column_6, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape13 preferencesTable = Shape13( - source: i0.VersionedTable( - entityName: 'preferences_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_7, - _column_8, - _column_9, - _column_10, - _column_11, - _column_12, - _column_13, - _column_14, - _column_15, - _column_69, - _column_17, - _column_18, - _column_19, - _column_20, - _column_21, - _column_22, - _column_23, - _column_24, - _column_25, - _column_26, - _column_54, - _column_27, - _column_28, - _column_29, - _column_30, - _column_31, - _column_56, - _column_53, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape3 scrobblerTable = Shape3( - source: i0.VersionedTable( - entityName: 'scrobbler_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_33, - _column_34, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape4 skipSegmentTable = Shape4( - source: i0.VersionedTable( - entityName: 'skip_segment_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_35, - _column_36, - _column_37, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape5 sourceMatchTable = Shape5( - source: i0.VersionedTable( - entityName: 'source_match_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_38, - _column_39, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape14 audioPlayerStateTable = Shape14( - source: i0.VersionedTable( - entityName: 'audio_player_state_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_40, - _column_41, - _column_42, - _column_43, - _column_57, - _column_58, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape9 historyTable = Shape9( - source: i0.VersionedTable( - entityName: 'history_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_50, - _column_51, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape10 lyricsTable = Shape10( - source: i0.VersionedTable( - entityName: 'lyrics_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape16 pluginsTable = Shape16( - source: i0.VersionedTable( - entityName: 'plugins_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_59, - _column_60, - _column_61, - _column_62, - _column_63, - _column_64, - _column_65, - _column_71, - _column_72, - _column_67, - _column_73, - ], - attachedDatabase: database, - ), - alias: null); - final i1.Index uniqueBlacklist = i1.Index('unique_blacklist', - 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); - final i1.Index uniqTrackMatch = i1.Index('uniq_track_match', - 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); -} - -class Shape16 extends i0.VersionedTable { - Shape16({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get name => - columnsByName['name']! as i1.GeneratedColumn; - i1.GeneratedColumn get description => - columnsByName['description']! as i1.GeneratedColumn; - i1.GeneratedColumn get version => - columnsByName['version']! as i1.GeneratedColumn; - i1.GeneratedColumn get author => - columnsByName['author']! as i1.GeneratedColumn; - i1.GeneratedColumn get entryPoint => - columnsByName['entry_point']! as i1.GeneratedColumn; - i1.GeneratedColumn get apis => - columnsByName['apis']! as i1.GeneratedColumn; - i1.GeneratedColumn get abilities => - columnsByName['abilities']! as i1.GeneratedColumn; - i1.GeneratedColumn get selectedForMetadata => - columnsByName['selected_for_metadata']! as i1.GeneratedColumn; - i1.GeneratedColumn get selectedForAudioSource => - columnsByName['selected_for_audio_source']! as i1.GeneratedColumn; - i1.GeneratedColumn get repository => - columnsByName['repository']! as i1.GeneratedColumn; - i1.GeneratedColumn get pluginApiVersion => - columnsByName['plugin_api_version']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_71(String aliasedName) => - i1.GeneratedColumn('selected_for_metadata', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("selected_for_metadata" IN (0, 1))'), - defaultValue: const Constant(false)); -i1.GeneratedColumn _column_72(String aliasedName) => - i1.GeneratedColumn('selected_for_audio_source', aliasedName, false, - type: i1.DriftSqlType.bool, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'CHECK ("selected_for_audio_source" IN (0, 1))'), - defaultValue: const Constant(false)); -i1.GeneratedColumn _column_73(String aliasedName) => - i1.GeneratedColumn('plugin_api_version', aliasedName, false, - type: i1.DriftSqlType.string, defaultValue: const Constant('2.0.0')); - -final class Schema10 extends i0.VersionedSchema { - Schema10({required super.database}) : super(version: 10); - @override - late final List entities = [ - authenticationTable, - blacklistTable, - preferencesTable, - scrobblerTable, - skipSegmentTable, - sourceMatchTable, - audioPlayerStateTable, - historyTable, - lyricsTable, - pluginsTable, - uniqueBlacklist, - uniqTrackMatch, - ]; - late final Shape0 authenticationTable = Shape0( - source: i0.VersionedTable( - entityName: 'authentication_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_1, - _column_2, - _column_3, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape1 blacklistTable = Shape1( - source: i0.VersionedTable( - entityName: 'blacklist_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_4, - _column_5, - _column_6, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape17 preferencesTable = Shape17( - source: i0.VersionedTable( - entityName: 'preferences_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_8, - _column_9, - _column_10, - _column_11, - _column_12, - _column_13, - _column_14, - _column_15, - _column_69, - _column_17, - _column_18, - _column_19, - _column_20, - _column_21, - _column_22, - _column_25, - _column_74, - _column_54, - _column_29, - _column_30, - _column_31, - _column_56, - _column_53, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape3 scrobblerTable = Shape3( - source: i0.VersionedTable( - entityName: 'scrobbler_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_33, - _column_34, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape4 skipSegmentTable = Shape4( - source: i0.VersionedTable( - entityName: 'skip_segment_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_35, - _column_36, - _column_37, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape18 sourceMatchTable = Shape18( - source: i0.VersionedTable( - entityName: 'source_match_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_75, - _column_76, - _column_32, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape14 audioPlayerStateTable = Shape14( - source: i0.VersionedTable( - entityName: 'audio_player_state_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_40, - _column_41, - _column_42, - _column_43, - _column_57, - _column_58, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape9 historyTable = Shape9( - source: i0.VersionedTable( - entityName: 'history_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_32, - _column_50, - _column_51, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape10 lyricsTable = Shape10( - source: i0.VersionedTable( - entityName: 'lyrics_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_37, - _column_52, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape16 pluginsTable = Shape16( - source: i0.VersionedTable( - entityName: 'plugins_table', - withoutRowId: false, - isStrict: false, - tableConstraints: [], - columns: [ - _column_0, - _column_59, - _column_60, - _column_61, - _column_62, - _column_63, - _column_64, - _column_65, - _column_71, - _column_72, - _column_67, - _column_73, - ], - attachedDatabase: database, - ), - alias: null); - final i1.Index uniqueBlacklist = i1.Index('unique_blacklist', - 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); - final i1.Index uniqTrackMatch = i1.Index('uniq_track_match', - 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_info, source_type)'); -} - -class Shape17 extends i0.VersionedTable { - Shape17({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get albumColorSync => - columnsByName['album_color_sync']! as i1.GeneratedColumn; - i1.GeneratedColumn get amoledDarkTheme => - columnsByName['amoled_dark_theme']! as i1.GeneratedColumn; - i1.GeneratedColumn get checkUpdate => - columnsByName['check_update']! as i1.GeneratedColumn; - i1.GeneratedColumn get normalizeAudio => - columnsByName['normalize_audio']! as i1.GeneratedColumn; - i1.GeneratedColumn get showSystemTrayIcon => - columnsByName['show_system_tray_icon']! as i1.GeneratedColumn; - i1.GeneratedColumn get systemTitleBar => - columnsByName['system_title_bar']! as i1.GeneratedColumn; - i1.GeneratedColumn get skipNonMusic => - columnsByName['skip_non_music']! as i1.GeneratedColumn; - i1.GeneratedColumn get closeBehavior => - columnsByName['close_behavior']! as i1.GeneratedColumn; - i1.GeneratedColumn get accentColorScheme => - columnsByName['accent_color_scheme']! as i1.GeneratedColumn; - i1.GeneratedColumn get layoutMode => - columnsByName['layout_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get locale => - columnsByName['locale']! as i1.GeneratedColumn; - i1.GeneratedColumn get market => - columnsByName['market']! as i1.GeneratedColumn; - i1.GeneratedColumn get searchMode => - columnsByName['search_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get downloadLocation => - columnsByName['download_location']! as i1.GeneratedColumn; - i1.GeneratedColumn get localLibraryLocation => - columnsByName['local_library_location']! as i1.GeneratedColumn; - i1.GeneratedColumn get themeMode => - columnsByName['theme_mode']! as i1.GeneratedColumn; - i1.GeneratedColumn get audioSourceId => - columnsByName['audio_source_id']! as i1.GeneratedColumn; - i1.GeneratedColumn get youtubeClientEngine => - columnsByName['youtube_client_engine']! as i1.GeneratedColumn; - i1.GeneratedColumn get discordPresence => - columnsByName['discord_presence']! as i1.GeneratedColumn; - i1.GeneratedColumn get endlessPlayback => - columnsByName['endless_playback']! as i1.GeneratedColumn; - i1.GeneratedColumn get enableConnect => - columnsByName['enable_connect']! as i1.GeneratedColumn; - i1.GeneratedColumn get connectPort => - columnsByName['connect_port']! as i1.GeneratedColumn; - i1.GeneratedColumn get cacheMusic => - columnsByName['cache_music']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_74(String aliasedName) => - i1.GeneratedColumn('audio_source_id', aliasedName, true, - type: i1.DriftSqlType.string); - -class Shape18 extends i0.VersionedTable { - Shape18({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get trackId => - columnsByName['track_id']! as i1.GeneratedColumn; - i1.GeneratedColumn get sourceInfo => - columnsByName['source_info']! as i1.GeneratedColumn; - i1.GeneratedColumn get sourceType => - columnsByName['source_type']! as i1.GeneratedColumn; - i1.GeneratedColumn get createdAt => - columnsByName['created_at']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_75(String aliasedName) => - i1.GeneratedColumn('source_info', aliasedName, false, - type: i1.DriftSqlType.string, defaultValue: const Constant("{}")); -i1.GeneratedColumn _column_76(String aliasedName) => - i1.GeneratedColumn('source_type', aliasedName, false, - type: i1.DriftSqlType.string); -i0.MigrationStepWithVersion migrationSteps({ - required Future Function(i1.Migrator m, Schema2 schema) from1To2, - required Future Function(i1.Migrator m, Schema3 schema) from2To3, - required Future Function(i1.Migrator m, Schema4 schema) from3To4, - required Future Function(i1.Migrator m, Schema5 schema) from4To5, - required Future Function(i1.Migrator m, Schema6 schema) from5To6, - required Future Function(i1.Migrator m, Schema7 schema) from6To7, - required Future Function(i1.Migrator m, Schema8 schema) from7To8, - required Future Function(i1.Migrator m, Schema9 schema) from8To9, - required Future Function(i1.Migrator m, Schema10 schema) from9To10, -}) { - return (currentVersion, database) async { - switch (currentVersion) { - case 1: - final schema = Schema2(database: database); - final migrator = i1.Migrator(database, schema); - await from1To2(migrator, schema); - return 2; - case 2: - final schema = Schema3(database: database); - final migrator = i1.Migrator(database, schema); - await from2To3(migrator, schema); - return 3; - case 3: - final schema = Schema4(database: database); - final migrator = i1.Migrator(database, schema); - await from3To4(migrator, schema); - return 4; - case 4: - final schema = Schema5(database: database); - final migrator = i1.Migrator(database, schema); - await from4To5(migrator, schema); - return 5; - case 5: - final schema = Schema6(database: database); - final migrator = i1.Migrator(database, schema); - await from5To6(migrator, schema); - return 6; - case 6: - final schema = Schema7(database: database); - final migrator = i1.Migrator(database, schema); - await from6To7(migrator, schema); - return 7; - case 7: - final schema = Schema8(database: database); - final migrator = i1.Migrator(database, schema); - await from7To8(migrator, schema); - return 8; - case 8: - final schema = Schema9(database: database); - final migrator = i1.Migrator(database, schema); - await from8To9(migrator, schema); - return 9; - case 9: - final schema = Schema10(database: database); - final migrator = i1.Migrator(database, schema); - await from9To10(migrator, schema); - return 10; - default: - throw ArgumentError.value('Unknown migration from $currentVersion'); - } - }; -} - -i1.OnUpgrade stepByStep({ - required Future Function(i1.Migrator m, Schema2 schema) from1To2, - required Future Function(i1.Migrator m, Schema3 schema) from2To3, - required Future Function(i1.Migrator m, Schema4 schema) from3To4, - required Future Function(i1.Migrator m, Schema5 schema) from4To5, - required Future Function(i1.Migrator m, Schema6 schema) from5To6, - required Future Function(i1.Migrator m, Schema7 schema) from6To7, - required Future Function(i1.Migrator m, Schema8 schema) from7To8, - required Future Function(i1.Migrator m, Schema9 schema) from8To9, - required Future Function(i1.Migrator m, Schema10 schema) from9To10, -}) => - i0.VersionedSchema.stepByStepHelper( - step: migrationSteps( - from1To2: from1To2, - from2To3: from2To3, - from3To4: from3To4, - from4To5: from4To5, - from5To6: from5To6, - from6To7: from6To7, - from7To8: from7To8, - from8To9: from8To9, - from9To10: from9To10, - )); diff --git a/lib/models/database/tables/audio_player_state.dart b/lib/models/database/tables/audio_player_state.dart deleted file mode 100644 index bd570da7..00000000 --- a/lib/models/database/tables/audio_player_state.dart +++ /dev/null @@ -1,34 +0,0 @@ -part of '../database.dart'; - -class AudioPlayerStateTable extends Table { - IntColumn get id => integer().autoIncrement()(); - BoolColumn get playing => boolean()(); - TextColumn get loopMode => textEnum()(); - BoolColumn get shuffled => boolean()(); - TextColumn get collections => text().map(const StringListConverter())(); - TextColumn get tracks => text() - .map(const SpotubeTrackObjectListConverter()) - .withDefault(const Constant("[]"))(); - IntColumn get currentIndex => integer().withDefault(const Constant(0))(); -} - -class SpotubeTrackObjectListConverter - extends TypeConverter, String> { - const SpotubeTrackObjectListConverter(); - - @override - List fromSql(String fromDb) { - final raw = (jsonDecode(fromDb) as List).cast(); - - return raw - .map((e) => SpotubeTrackObject.fromJson(e.cast())) - .toList(); - } - - @override - String toSql(List value) { - return jsonEncode( - value.map((e) => e.toJson()).toList(), - ); - } -} diff --git a/lib/models/database/tables/authentication.dart b/lib/models/database/tables/authentication.dart deleted file mode 100644 index 96041952..00000000 --- a/lib/models/database/tables/authentication.dart +++ /dev/null @@ -1,8 +0,0 @@ -part of '../database.dart'; - -class AuthenticationTable extends Table { - IntColumn get id => integer().autoIncrement()(); - TextColumn get cookie => text().map(EncryptedTextConverter())(); - TextColumn get accessToken => text().map(EncryptedTextConverter())(); - DateTimeColumn get expiration => dateTime()(); -} diff --git a/lib/models/database/tables/blacklist.dart b/lib/models/database/tables/blacklist.dart deleted file mode 100644 index 8a8d9dee..00000000 --- a/lib/models/database/tables/blacklist.dart +++ /dev/null @@ -1,18 +0,0 @@ -part of '../database.dart'; - -enum BlacklistedType { - artist, - track; -} - -@TableIndex( - name: "unique_blacklist", - unique: true, - columns: {#elementType, #elementId}, -) -class BlacklistTable extends Table { - IntColumn get id => integer().autoIncrement()(); - TextColumn get name => text()(); - TextColumn get elementType => textEnum()(); - TextColumn get elementId => text()(); -} diff --git a/lib/models/database/tables/history.dart b/lib/models/database/tables/history.dart deleted file mode 100644 index f074e248..00000000 --- a/lib/models/database/tables/history.dart +++ /dev/null @@ -1,31 +0,0 @@ -part of '../database.dart'; - -enum HistoryEntryType { - playlist, - album, - track, -} - -class HistoryTable extends Table { - IntColumn get id => integer().autoIncrement()(); - DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); - TextColumn get type => textEnum()(); - TextColumn get itemId => text()(); - TextColumn get data => - text().map(const MapTypeConverter())(); -} - -extension HistoryItemParseExtension on HistoryTableData { - SpotubeSimplePlaylistObject? get playlist => - type == HistoryEntryType.playlist && !data.containsKey("external_urls") - ? SpotubeSimplePlaylistObject.fromJson(data) - : null; - SpotubeSimpleAlbumObject? get album => - type == HistoryEntryType.album && !data.containsKey("external_urls") - ? SpotubeSimpleAlbumObject.fromJson(data) - : null; - SpotubeTrackObject? get track => - type == HistoryEntryType.track && !data.containsKey("external_urls") - ? SpotubeTrackObject.fromJson(data) - : null; -} diff --git a/lib/models/database/tables/lyrics.dart b/lib/models/database/tables/lyrics.dart deleted file mode 100644 index 7c4c7f8f..00000000 --- a/lib/models/database/tables/lyrics.dart +++ /dev/null @@ -1,8 +0,0 @@ -part of '../database.dart'; - -class LyricsTable extends Table { - IntColumn get id => integer().autoIncrement()(); - - TextColumn get trackId => text()(); - TextColumn get data => text().map(SubtitleTypeConverter())(); -} diff --git a/lib/models/database/tables/metadata_plugins.dart b/lib/models/database/tables/metadata_plugins.dart deleted file mode 100644 index 3447497d..00000000 --- a/lib/models/database/tables/metadata_plugins.dart +++ /dev/null @@ -1,19 +0,0 @@ -part of '../database.dart'; - -class PluginsTable extends Table { - IntColumn get id => integer().autoIncrement()(); - TextColumn get name => text().withLength(min: 1, max: 50)(); - TextColumn get description => text()(); - TextColumn get version => text()(); - TextColumn get author => text()(); - TextColumn get entryPoint => text()(); - TextColumn get apis => text().map(const StringListConverter())(); - TextColumn get abilities => text().map(const StringListConverter())(); - BoolColumn get selectedForMetadata => - boolean().withDefault(const Constant(false))(); - BoolColumn get selectedForAudioSource => - boolean().withDefault(const Constant(false))(); - TextColumn get repository => text().nullable()(); - TextColumn get pluginApiVersion => - text().withDefault(const Constant('2.0.0'))(); -} diff --git a/lib/models/database/tables/preferences.dart b/lib/models/database/tables/preferences.dart deleted file mode 100644 index 3029e2a8..00000000 --- a/lib/models/database/tables/preferences.dart +++ /dev/null @@ -1,124 +0,0 @@ -part of '../database.dart'; - -enum LayoutMode { - compact, - extended, - adaptive, -} - -enum CloseBehavior { - minimizeToTray, - close, -} - -enum YoutubeClientEngine { - ytDlp("yt-dlp"), - youtubeExplode("YouTubeExplode"), - newPipe("NewPipe"); - - final String label; - - const YoutubeClientEngine(this.label); - - bool isAvailableForPlatform() { - return switch (this) { - YoutubeClientEngine.youtubeExplode => - YouTubeExplodeEngine.isAvailableForPlatform, - YoutubeClientEngine.ytDlp => YtDlpEngine.isAvailableForPlatform, - YoutubeClientEngine.newPipe => NewPipeEngine.isAvailableForPlatform, - }; - } -} - -enum SearchMode { - youtube._("YouTube"), - youtubeMusic._("YouTube Music"); - - final String label; - - const SearchMode._(this.label); - - factory SearchMode.fromString(String key) { - return SearchMode.values.firstWhere((e) => e.name == key); - } -} - -class PreferencesTable extends Table { - IntColumn get id => integer().autoIncrement()(); - BoolColumn get albumColorSync => - boolean().withDefault(const Constant(true))(); - BoolColumn get amoledDarkTheme => - boolean().withDefault(const Constant(false))(); - BoolColumn get checkUpdate => boolean().withDefault(const Constant(true))(); - BoolColumn get normalizeAudio => - boolean().withDefault(const Constant(false))(); - BoolColumn get showSystemTrayIcon => - boolean().withDefault(const Constant(false))(); - BoolColumn get systemTitleBar => - boolean().withDefault(const Constant(false))(); - BoolColumn get skipNonMusic => boolean().withDefault(const Constant(false))(); - TextColumn get closeBehavior => textEnum() - .withDefault(Constant(CloseBehavior.close.name))(); - TextColumn get accentColorScheme => text() - .withDefault(const Constant("Slate:0xff64748b")) - .map(const SpotubeColorConverter())(); - TextColumn get layoutMode => - textEnum().withDefault(Constant(LayoutMode.adaptive.name))(); - TextColumn get locale => text() - .withDefault( - const Constant('{"languageCode":"system","countryCode":"system"}'), - ) - .map(const LocaleConverter())(); - TextColumn get market => - textEnum().withDefault(Constant(Market.US.name))(); - TextColumn get searchMode => - textEnum().withDefault(Constant(SearchMode.youtube.name))(); - TextColumn get downloadLocation => text().withDefault(const Constant(""))(); - TextColumn get localLibraryLocation => - text().withDefault(const Constant("")).map(const StringListConverter())(); - TextColumn get themeMode => - textEnum().withDefault(Constant(ThemeMode.system.name))(); - TextColumn get audioSourceId => text().nullable()(); - TextColumn get youtubeClientEngine => textEnum() - .withDefault(Constant(YoutubeClientEngine.youtubeExplode.name))(); - BoolColumn get discordPresence => - boolean().withDefault(const Constant(true))(); - BoolColumn get endlessPlayback => - boolean().withDefault(const Constant(true))(); - BoolColumn get enableConnect => - boolean().withDefault(const Constant(false))(); - IntColumn get connectPort => integer().withDefault(const Constant(-1))(); - BoolColumn get cacheMusic => boolean().withDefault(const Constant(true))(); - - // Default values as PreferencesTableData - static PreferencesTableData defaults() { - return PreferencesTableData( - id: 0, - albumColorSync: true, - amoledDarkTheme: false, - checkUpdate: true, - normalizeAudio: false, - showSystemTrayIcon: false, - systemTitleBar: false, - skipNonMusic: false, - closeBehavior: CloseBehavior.close, - accentColorScheme: SpotubeColor(Colors.slate.value, name: "Slate"), - layoutMode: LayoutMode.adaptive, - locale: const Locale("system", "system"), - market: Market.US, - searchMode: SearchMode.youtube, - downloadLocation: "", - localLibraryLocation: [], - themeMode: ThemeMode.system, - audioSourceId: null, - youtubeClientEngine: kIsIOS - ? YoutubeClientEngine.youtubeExplode - : YoutubeClientEngine.newPipe, - discordPresence: true, - endlessPlayback: true, - enableConnect: false, - cacheMusic: true, - connectPort: -1, - ); - } -} diff --git a/lib/models/database/tables/scrobbler.dart b/lib/models/database/tables/scrobbler.dart deleted file mode 100644 index 481c441e..00000000 --- a/lib/models/database/tables/scrobbler.dart +++ /dev/null @@ -1,8 +0,0 @@ -part of '../database.dart'; - -class ScrobblerTable extends Table { - IntColumn get id => integer().autoIncrement()(); - DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); - TextColumn get username => text()(); - TextColumn get passwordHash => text().map(EncryptedTextConverter())(); -} diff --git a/lib/models/database/tables/skip_segment.dart b/lib/models/database/tables/skip_segment.dart deleted file mode 100644 index 719f2617..00000000 --- a/lib/models/database/tables/skip_segment.dart +++ /dev/null @@ -1,9 +0,0 @@ -part of '../database.dart'; - -class SkipSegmentTable extends Table { - IntColumn get id => integer().autoIncrement()(); - IntColumn get start => integer()(); - IntColumn get end => integer()(); - TextColumn get trackId => text()(); - DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); -} diff --git a/lib/models/database/tables/source_match.dart b/lib/models/database/tables/source_match.dart deleted file mode 100644 index 66a4959c..00000000 --- a/lib/models/database/tables/source_match.dart +++ /dev/null @@ -1,9 +0,0 @@ -part of '../database.dart'; - -class SourceMatchTable extends Table { - IntColumn get id => integer().autoIncrement()(); - TextColumn get trackId => text()(); - TextColumn get sourceInfo => text().withDefault(const Constant("{}"))(); - TextColumn get sourceType => text()(); - DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); -} diff --git a/lib/models/database/typeconverters/color.dart b/lib/models/database/typeconverters/color.dart deleted file mode 100644 index 513921a2..00000000 --- a/lib/models/database/typeconverters/color.dart +++ /dev/null @@ -1,29 +0,0 @@ -part of '../database.dart'; - -class ColorConverter extends TypeConverter { - const ColorConverter(); - - @override - Color fromSql(int fromDb) { - return Color(fromDb); - } - - @override - int toSql(Color value) { - return value.toARGB32(); - } -} - -class SpotubeColorConverter extends TypeConverter { - const SpotubeColorConverter(); - - @override - SpotubeColor fromSql(String fromDb) { - return SpotubeColor.fromString(fromDb); - } - - @override - String toSql(SpotubeColor value) { - return value.toString(); - } -} diff --git a/lib/models/database/typeconverters/encrypted_text.dart b/lib/models/database/typeconverters/encrypted_text.dart deleted file mode 100644 index 6afa8210..00000000 --- a/lib/models/database/typeconverters/encrypted_text.dart +++ /dev/null @@ -1,44 +0,0 @@ -part of '../database.dart'; - -class DecryptedText { - final String value; - const DecryptedText(this.value); - - static Encrypter? _encrypter; - - factory DecryptedText.decrypted(String value) { - _encrypter ??= Encrypter( - Salsa20( - Key.fromUtf8(EncryptedKvStoreService.encryptionKeySync), - ), - ); - - return DecryptedText( - _encrypter!.decrypt( - Encrypted.fromBase64(value), - iv: KVStoreService.ivKey, - ), - ); - } - - String encrypt() { - _encrypter ??= Encrypter( - Salsa20( - Key.fromUtf8(EncryptedKvStoreService.encryptionKeySync), - ), - ); - return _encrypter!.encrypt(value, iv: KVStoreService.ivKey).base64; - } -} - -class EncryptedTextConverter extends TypeConverter { - @override - DecryptedText fromSql(String fromDb) { - return DecryptedText.decrypted(fromDb); - } - - @override - String toSql(DecryptedText value) { - return value.encrypt(); - } -} diff --git a/lib/models/database/typeconverters/locale.dart b/lib/models/database/typeconverters/locale.dart deleted file mode 100644 index c460088e..00000000 --- a/lib/models/database/typeconverters/locale.dart +++ /dev/null @@ -1,19 +0,0 @@ -part of '../database.dart'; - -class LocaleConverter extends TypeConverter { - const LocaleConverter(); - - @override - Locale fromSql(String fromDb) { - final rawMap = jsonDecode(fromDb) as Map; - return Locale(rawMap["languageCode"], rawMap["countryCode"]); - } - - @override - String toSql(Locale value) { - return jsonEncode({ - "languageCode": value.languageCode, - "countryCode": value.countryCode, - }); - } -} diff --git a/lib/models/database/typeconverters/map.dart b/lib/models/database/typeconverters/map.dart deleted file mode 100644 index 0b0ff7e0..00000000 --- a/lib/models/database/typeconverters/map.dart +++ /dev/null @@ -1,15 +0,0 @@ -part of '../database.dart'; - -class MapTypeConverter extends TypeConverter, String> { - const MapTypeConverter(); - - @override - fromSql(String fromDb) { - return json.decode(fromDb) as Map; - } - - @override - toSql(value) { - return json.encode(value); - } -} diff --git a/lib/models/database/typeconverters/map_list.dart b/lib/models/database/typeconverters/map_list.dart deleted file mode 100644 index b92e781d..00000000 --- a/lib/models/database/typeconverters/map_list.dart +++ /dev/null @@ -1,20 +0,0 @@ -part of '../database.dart'; - -class MapListConverter - extends TypeConverter>, String> { - const MapListConverter(); - - @override - List> fromSql(String fromDb) { - return fromDb - .split(",") - .where((e) => e.isNotEmpty) - .map((e) => json.decode(e) as Map) - .toList(); - } - - @override - String toSql(List> value) { - return value.map((e) => json.encode(e)).join(","); - } -} diff --git a/lib/models/database/typeconverters/string_list.dart b/lib/models/database/typeconverters/string_list.dart deleted file mode 100644 index 466ae4c4..00000000 --- a/lib/models/database/typeconverters/string_list.dart +++ /dev/null @@ -1,15 +0,0 @@ -part of '../database.dart'; - -class StringListConverter extends TypeConverter, String> { - const StringListConverter(); - - @override - List fromSql(String fromDb) { - return fromDb.split(",").where((e) => e.isNotEmpty).toList(); - } - - @override - String toSql(List value) { - return value.join(","); - } -} diff --git a/lib/models/database/typeconverters/subtitle.dart b/lib/models/database/typeconverters/subtitle.dart deleted file mode 100644 index 25fa4ad5..00000000 --- a/lib/models/database/typeconverters/subtitle.dart +++ /dev/null @@ -1,13 +0,0 @@ -part of '../database.dart'; - -class SubtitleTypeConverter extends TypeConverter { - @override - SubtitleSimple fromSql(String fromDb) { - return SubtitleSimple.fromJson(jsonDecode(fromDb)); - } - - @override - String toSql(SubtitleSimple value) { - return jsonEncode(value.toJson()); - } -} diff --git a/lib/models/lyrics.dart b/lib/models/lyrics.dart deleted file mode 100644 index f6457287..00000000 --- a/lib/models/lyrics.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:lrc/lrc.dart'; - -class SubtitleSimple { - Uri uri; - String name; - List lyrics; - int rating; - String provider; - - SubtitleSimple({ - required this.uri, - required this.name, - required this.lyrics, - required this.rating, - required this.provider, - }); - - factory SubtitleSimple.fromJson(Map json) { - return SubtitleSimple( - uri: Uri.parse(json["uri"] as String), - name: json["name"] as String, - lyrics: (json["lyrics"] as List) - .map((e) => LyricSlice.fromJson(e as Map)) - .toList(), - rating: json["rating"] as int, - provider: json["provider"] as String? ?? "unknown", - ); - } - - Map toJson() { - return { - "uri": uri.toString(), - "name": name, - "lyrics": lyrics.map((e) => e.toJson()).toList(), - "rating": rating, - "provider": provider, - }; - } -} - -class LyricSlice { - Duration time; - String text; - - LyricSlice({required this.time, required this.text}); - - factory LyricSlice.fromLrcLine(LrcLine line) { - return LyricSlice( - time: line.timestamp, - text: line.lyrics.trim(), - ); - } - - factory LyricSlice.fromJson(Map json) { - return LyricSlice( - time: Duration(milliseconds: json["time"]), - text: json["text"] as String, - ); - } - - Map toJson() { - return { - "time": time.inMilliseconds, - "text": text, - }; - } - - @override - String toString() { - return "LyricsSlice({time: $time, text: $text})"; - } -} diff --git a/lib/models/metadata/album.dart b/lib/models/metadata/album.dart deleted file mode 100644 index bc9022de..00000000 --- a/lib/models/metadata/album.dart +++ /dev/null @@ -1,42 +0,0 @@ -part of 'metadata.dart'; - -enum SpotubeAlbumType { - album, - single, - compilation, -} - -@freezed -class SpotubeFullAlbumObject with _$SpotubeFullAlbumObject { - factory SpotubeFullAlbumObject({ - required String id, - required String name, - required List artists, - @Default([]) List images, - required String releaseDate, - required String externalUri, - required int totalTracks, - required SpotubeAlbumType albumType, - String? recordLabel, - List? genres, - }) = _SpotubeFullAlbumObject; - - factory SpotubeFullAlbumObject.fromJson(Map json) => - _$SpotubeFullAlbumObjectFromJson(json); -} - -@freezed -class SpotubeSimpleAlbumObject with _$SpotubeSimpleAlbumObject { - factory SpotubeSimpleAlbumObject({ - required String id, - required String name, - required String externalUri, - required List artists, - @Default([]) List images, - required SpotubeAlbumType albumType, - String? releaseDate, - }) = _SpotubeSimpleAlbumObject; - - factory SpotubeSimpleAlbumObject.fromJson(Map json) => - _$SpotubeSimpleAlbumObjectFromJson(json); -} diff --git a/lib/models/metadata/artist.dart b/lib/models/metadata/artist.dart deleted file mode 100644 index 24d8f55c..00000000 --- a/lib/models/metadata/artist.dart +++ /dev/null @@ -1,41 +0,0 @@ -part of 'metadata.dart'; - -@freezed -class SpotubeFullArtistObject with _$SpotubeFullArtistObject { - factory SpotubeFullArtistObject({ - required String id, - required String name, - required String externalUri, - @Default([]) List images, - List? genres, - int? followers, - }) = _SpotubeFullArtistObject; - - factory SpotubeFullArtistObject.fromJson(Map json) => - _$SpotubeFullArtistObjectFromJson(json); -} - -@freezed -class SpotubeSimpleArtistObject with _$SpotubeSimpleArtistObject { - factory SpotubeSimpleArtistObject({ - required String id, - required String name, - required String externalUri, - List? images, - }) = _SpotubeSimpleArtistObject; - - factory SpotubeSimpleArtistObject.fromJson(Map json) => - _$SpotubeSimpleArtistObjectFromJson(json); -} - -extension SpotubeFullArtistObjectAsString on List { - String asString() { - return map((e) => e.name).join(", "); - } -} - -extension SpotubeSimpleArtistObjectAsString on List { - String asString() { - return map((e) => e.name).join(", "); - } -} diff --git a/lib/models/metadata/audio_source.dart b/lib/models/metadata/audio_source.dart deleted file mode 100644 index 4fb790ea..00000000 --- a/lib/models/metadata/audio_source.dart +++ /dev/null @@ -1,110 +0,0 @@ -part of 'metadata.dart'; - -final oneOptionalDecimalFormatter = NumberFormat('0.#', 'en_US'); - -enum SpotubeMediaCompressionType { - lossy, - lossless, -} - -@Freezed(unionKey: 'type') -class SpotubeAudioSourceContainerPreset - with _$SpotubeAudioSourceContainerPreset { - const SpotubeAudioSourceContainerPreset._(); - - @FreezedUnionValue("lossy") - factory SpotubeAudioSourceContainerPreset.lossy({ - required SpotubeMediaCompressionType type, - required String name, - required List qualities, - }) = SpotubeAudioSourceContainerPresetLossy; - - @FreezedUnionValue("lossless") - factory SpotubeAudioSourceContainerPreset.lossless({ - required SpotubeMediaCompressionType type, - required String name, - required List qualities, - }) = SpotubeAudioSourceContainerPresetLossless; - - factory SpotubeAudioSourceContainerPreset.fromJson( - Map json) => - _$SpotubeAudioSourceContainerPresetFromJson(json); - - String getFileExtension() { - return switch (name) { - "mp4" => "m4a", - "webm" => "weba", - _ => name, - }; - } -} - -@freezed -class SpotubeAudioLossyContainerQuality - with _$SpotubeAudioLossyContainerQuality { - const SpotubeAudioLossyContainerQuality._(); - - factory SpotubeAudioLossyContainerQuality({ - required int bitrate, // bits per second - }) = _SpotubeAudioLossyContainerQuality; - - factory SpotubeAudioLossyContainerQuality.fromJson( - Map json) => - _$SpotubeAudioLossyContainerQualityFromJson(json); - - @override - toString() { - return "${oneOptionalDecimalFormatter.format(bitrate / 1000)}kbps"; - } -} - -@freezed -class SpotubeAudioLosslessContainerQuality - with _$SpotubeAudioLosslessContainerQuality { - const SpotubeAudioLosslessContainerQuality._(); - - factory SpotubeAudioLosslessContainerQuality({ - required int bitDepth, // bit - required int sampleRate, // hz - }) = _SpotubeAudioLosslessContainerQuality; - - factory SpotubeAudioLosslessContainerQuality.fromJson( - Map json) => - _$SpotubeAudioLosslessContainerQualityFromJson(json); - - @override - toString() { - return "${bitDepth}bit • ${oneOptionalDecimalFormatter.format(sampleRate / 1000)}kHz"; - } -} - -@freezed -class SpotubeAudioSourceMatchObject with _$SpotubeAudioSourceMatchObject { - factory SpotubeAudioSourceMatchObject({ - required String id, - required String title, - required List artists, - required Duration duration, - String? thumbnail, - required String externalUri, - }) = _SpotubeAudioSourceMatchObject; - - factory SpotubeAudioSourceMatchObject.fromJson(Map json) => - _$SpotubeAudioSourceMatchObjectFromJson(json); -} - -@freezed -class SpotubeAudioSourceStreamObject with _$SpotubeAudioSourceStreamObject { - factory SpotubeAudioSourceStreamObject({ - required String url, - required String container, - required SpotubeMediaCompressionType type, - String? codec, - double? bitrate, - int? bitDepth, - double? sampleRate, - }) = _SpotubeAudioSourceStreamObject; - - factory SpotubeAudioSourceStreamObject.fromJson(Map json) => - _$SpotubeAudioSourceStreamObjectFromJson(json); -} diff --git a/lib/models/metadata/browse.dart b/lib/models/metadata/browse.dart deleted file mode 100644 index e2a69181..00000000 --- a/lib/models/metadata/browse.dart +++ /dev/null @@ -1,21 +0,0 @@ -part of 'metadata.dart'; - -@Freezed(genericArgumentFactories: true) -class SpotubeBrowseSectionObject with _$SpotubeBrowseSectionObject { - factory SpotubeBrowseSectionObject({ - required String id, - required String title, - required String externalUri, - required bool browseMore, - required List items, - }) = _SpotubeBrowseSectionObject; - - factory SpotubeBrowseSectionObject.fromJson( - Map json, - T Function(Map json) fromJsonT, - ) => - _$SpotubeBrowseSectionObjectFromJson( - json, - (json) => fromJsonT(json as Map), - ); -} diff --git a/lib/models/metadata/fields.dart b/lib/models/metadata/fields.dart deleted file mode 100644 index 11d6656d..00000000 --- a/lib/models/metadata/fields.dart +++ /dev/null @@ -1,26 +0,0 @@ -part of 'metadata.dart'; - -enum FormFieldVariant { text, password, number } - -@Freezed(unionKey: 'objectType') -class MetadataFormFieldObject with _$MetadataFormFieldObject { - @FreezedUnionValue("input") - factory MetadataFormFieldObject.input({ - required String objectType, - required String id, - @Default(FormFieldVariant.text) FormFieldVariant variant, - String? placeholder, - String? defaultValue, - bool? required, - String? regex, - }) = MetadataFormFieldInputObject; - - @FreezedUnionValue("text") - factory MetadataFormFieldObject.text({ - required String objectType, - required String text, - }) = MetadataFormFieldTextObject; - - factory MetadataFormFieldObject.fromJson(Map json) => - _$MetadataFormFieldObjectFromJson(json); -} diff --git a/lib/models/metadata/image.dart b/lib/models/metadata/image.dart deleted file mode 100644 index 9a4ee026..00000000 --- a/lib/models/metadata/image.dart +++ /dev/null @@ -1,95 +0,0 @@ -part of 'metadata.dart'; - -@freezed -class SpotubeImageObject with _$SpotubeImageObject { - factory SpotubeImageObject({ - required String url, - int? width, - int? height, - }) = _SpotubeImageObject; - - factory SpotubeImageObject.fromJson(Map json) => - _$SpotubeImageObjectFromJson(json); -} - -enum ImagePlaceholder { - albumArt, - artist, - collection, - online, -} - -final placeholderUrlMap = { - ImagePlaceholder.albumArt: Assets.images.albumPlaceholder.path, - ImagePlaceholder.artist: Assets.images.userPlaceholder.path, - ImagePlaceholder.collection: Assets.images.placeholder.path, - ImagePlaceholder.online: - "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png", -}; - -extension SpotubeImageExtensions on List? { - /// Returns the URL of the image at the specified index. - String asUrlString({ - int index = 1, - required ImagePlaceholder placeholder, - }) { - final sortedImage = - this?.sorted((a, b) => (a.width ?? 0).compareTo(b.width ?? 0)); - - return sortedImage != null && sortedImage.isNotEmpty - ? sortedImage[ - index > sortedImage.length - 1 ? sortedImage.length - 1 : index] - .url - : placeholderUrlMap[placeholder]!; - } - - Uri asUri({ - int index = 1, - required ImagePlaceholder placeholder, - }) { - final url = asUrlString(placeholder: placeholder, index: index); - if (url.startsWith("http")) { - return Uri.parse(url); - } - return Uri.file(url); - } - - String smallest(ImagePlaceholder placeholder) { - final sortedImage = this?.sorted((a, b) { - final widthComparison = (a.width ?? 0).compareTo(b.width ?? 0); - if (widthComparison != 0) return widthComparison; - return (a.height ?? 0).compareTo(b.height ?? 0); - }); - - return sortedImage != null && sortedImage.isNotEmpty - ? sortedImage.first.url - : placeholderUrlMap[placeholder]!; - } - - String from200PxTo300PxOrSmallestImage([ - ImagePlaceholder placeholder = ImagePlaceholder.albumArt, - ]) { - final placeholderUrl = placeholderUrlMap[placeholder]!; - - // Sort images by width and height to find the smallest one - final sortedImage = this?.sorted((a, b) { - final widthComparison = (a.width ?? 0).compareTo(b.width ?? 0); - if (widthComparison != 0) return widthComparison; - return (a.height ?? 0).compareTo(b.height ?? 0); - }); - - return sortedImage != null && sortedImage.isNotEmpty - ? sortedImage.firstWhere( - (image) { - final width = image.width ?? 0; - final height = image.height ?? 0; - return width >= 200 && - height >= 200 && - width <= 300 && - height <= 300; - }, - orElse: () => sortedImage.first, - ).url - : placeholderUrl; - } -} diff --git a/lib/models/metadata/market.dart b/lib/models/metadata/market.dart deleted file mode 100644 index caaef957..00000000 --- a/lib/models/metadata/market.dart +++ /dev/null @@ -1,252 +0,0 @@ -enum Market { - AD, - AE, - AF, - AG, - AI, - AL, - AM, - AO, - AQ, - AR, - AS, - AT, - AU, - AW, - AX, - AZ, - BA, - BB, - BD, - BE, - BF, - BG, - BH, - BI, - BJ, - BL, - BM, - BN, - BO, - BQ, - BR, - BS, - BT, - BV, - BW, - BY, - BZ, - CA, - CC, - CD, - CF, - CG, - CH, - CI, - CK, - CL, - CM, - CN, - CO, - CR, - CU, - CV, - CW, - CX, - CY, - CZ, - DE, - DJ, - DK, - DM, - DO, - DZ, - EC, - EE, - EG, - EH, - ER, - ES, - ET, - FI, - FJ, - FK, - FM, - FO, - FR, - GA, - GB, - GD, - GE, - GF, - GG, - GH, - GI, - GL, - GM, - GN, - GP, - GQ, - GR, - GS, - GT, - GU, - GW, - GY, - HK, - HM, - HN, - HR, - HT, - HU, - ID, - IE, - IL, - IM, - IN, - IO, - IQ, - IR, - IS, - IT, - JE, - JM, - JO, - JP, - KE, - KG, - KH, - KI, - KM, - KN, - KP, - KR, - KW, - KY, - KZ, - LA, - LB, - LC, - LI, - LK, - LR, - LS, - LT, - LU, - LV, - LY, - MA, - MC, - MD, - ME, - MF, - MG, - MH, - MK, - ML, - MM, - MN, - MO, - MP, - MQ, - MR, - MS, - MT, - MU, - MV, - MW, - MX, - MY, - MZ, - NA, - NC, - NE, - NF, - NG, - NI, - NL, - NO, - NP, - NR, - NU, - NZ, - OM, - PA, - PE, - PF, - PG, - PH, - PK, - PL, - PM, - PN, - PR, - PS, - PT, - PW, - PY, - QA, - RE, - RO, - RS, - RU, - RW, - SA, - SB, - SC, - SD, - SE, - SG, - SH, - SI, - SJ, - SK, - SL, - SM, - SN, - SO, - SR, - SS, - ST, - SV, - SX, - SY, - SZ, - TC, - TD, - TF, - TG, - TH, - TJ, - TK, - TL, - TM, - TN, - TO, - TR, - TT, - TV, - TW, - TZ, - UA, - UG, - UM, - US, - UY, - UZ, - VA, - VC, - VE, - VG, - VI, - VN, - VU, - WF, - WS, - XK, - YE, - YT, - ZA, - ZM, - ZW, -} diff --git a/lib/models/metadata/metadata.dart b/lib/models/metadata/metadata.dart deleted file mode 100644 index e68bcd14..00000000 --- a/lib/models/metadata/metadata.dart +++ /dev/null @@ -1,32 +0,0 @@ -library metadata_objects; - -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:collection/collection.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:intl/intl.dart'; -import 'package:metadata_god/metadata_god.dart'; -import 'package:mime/mime.dart'; -import 'package:path/path.dart'; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/utils/primitive_utils.dart'; - -part 'metadata.g.dart'; -part 'metadata.freezed.dart'; - -part 'audio_source.dart'; -part 'album.dart'; -part 'artist.dart'; -part 'browse.dart'; -part 'fields.dart'; -part 'image.dart'; -part 'pagination.dart'; -part 'playlist.dart'; -part 'search.dart'; -part 'track.dart'; -part 'user.dart'; - -part 'plugin.dart'; -part 'repository.dart'; diff --git a/lib/models/metadata/metadata.freezed.dart b/lib/models/metadata/metadata.freezed.dart deleted file mode 100644 index fee1cbc2..00000000 --- a/lib/models/metadata/metadata.freezed.dart +++ /dev/null @@ -1,6774 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'metadata.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -SpotubeAudioSourceContainerPreset _$SpotubeAudioSourceContainerPresetFromJson( - Map json) { - switch (json['type']) { - case 'lossy': - return SpotubeAudioSourceContainerPresetLossy.fromJson(json); - case 'lossless': - return SpotubeAudioSourceContainerPresetLossless.fromJson(json); - - default: - throw CheckedFromJsonException( - json, - 'type', - 'SpotubeAudioSourceContainerPreset', - 'Invalid union type "${json['type']}"!'); - } -} - -/// @nodoc -mixin _$SpotubeAudioSourceContainerPreset { - SpotubeMediaCompressionType get type => throw _privateConstructorUsedError; - String get name => throw _privateConstructorUsedError; - List get qualities => throw _privateConstructorUsedError; - @optionalTypeArgs - TResult when({ - required TResult Function(SpotubeMediaCompressionType type, String name, - List qualities) - lossy, - required TResult Function(SpotubeMediaCompressionType type, String name, - List qualities) - lossless, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function(SpotubeMediaCompressionType type, String name, - List qualities)? - lossy, - TResult? Function(SpotubeMediaCompressionType type, String name, - List qualities)? - lossless, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeWhen({ - TResult Function(SpotubeMediaCompressionType type, String name, - List qualities)? - lossy, - TResult Function(SpotubeMediaCompressionType type, String name, - List qualities)? - lossless, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult map({ - required TResult Function(SpotubeAudioSourceContainerPresetLossy value) - lossy, - required TResult Function(SpotubeAudioSourceContainerPresetLossless value) - lossless, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(SpotubeAudioSourceContainerPresetLossy value)? lossy, - TResult? Function(SpotubeAudioSourceContainerPresetLossless value)? - lossless, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeMap({ - TResult Function(SpotubeAudioSourceContainerPresetLossy value)? lossy, - TResult Function(SpotubeAudioSourceContainerPresetLossless value)? lossless, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - - /// Serializes this SpotubeAudioSourceContainerPreset to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SpotubeAudioSourceContainerPreset - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeAudioSourceContainerPresetCopyWith - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeAudioSourceContainerPresetCopyWith<$Res> { - factory $SpotubeAudioSourceContainerPresetCopyWith( - SpotubeAudioSourceContainerPreset value, - $Res Function(SpotubeAudioSourceContainerPreset) then) = - _$SpotubeAudioSourceContainerPresetCopyWithImpl<$Res, - SpotubeAudioSourceContainerPreset>; - @useResult - $Res call({SpotubeMediaCompressionType type, String name}); -} - -/// @nodoc -class _$SpotubeAudioSourceContainerPresetCopyWithImpl<$Res, - $Val extends SpotubeAudioSourceContainerPreset> - implements $SpotubeAudioSourceContainerPresetCopyWith<$Res> { - _$SpotubeAudioSourceContainerPresetCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeAudioSourceContainerPreset - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? type = null, - Object? name = null, - }) { - return _then(_value.copyWith( - type: null == type - ? _value.type - : type // ignore: cast_nullable_to_non_nullable - as SpotubeMediaCompressionType, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$SpotubeAudioSourceContainerPresetLossyImplCopyWith<$Res> - implements $SpotubeAudioSourceContainerPresetCopyWith<$Res> { - factory _$$SpotubeAudioSourceContainerPresetLossyImplCopyWith( - _$SpotubeAudioSourceContainerPresetLossyImpl value, - $Res Function(_$SpotubeAudioSourceContainerPresetLossyImpl) then) = - __$$SpotubeAudioSourceContainerPresetLossyImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {SpotubeMediaCompressionType type, - String name, - List qualities}); -} - -/// @nodoc -class __$$SpotubeAudioSourceContainerPresetLossyImplCopyWithImpl<$Res> - extends _$SpotubeAudioSourceContainerPresetCopyWithImpl<$Res, - _$SpotubeAudioSourceContainerPresetLossyImpl> - implements _$$SpotubeAudioSourceContainerPresetLossyImplCopyWith<$Res> { - __$$SpotubeAudioSourceContainerPresetLossyImplCopyWithImpl( - _$SpotubeAudioSourceContainerPresetLossyImpl _value, - $Res Function(_$SpotubeAudioSourceContainerPresetLossyImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeAudioSourceContainerPreset - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? type = null, - Object? name = null, - Object? qualities = null, - }) { - return _then(_$SpotubeAudioSourceContainerPresetLossyImpl( - type: null == type - ? _value.type - : type // ignore: cast_nullable_to_non_nullable - as SpotubeMediaCompressionType, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - qualities: null == qualities - ? _value._qualities - : qualities // ignore: cast_nullable_to_non_nullable - as List, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeAudioSourceContainerPresetLossyImpl - extends SpotubeAudioSourceContainerPresetLossy { - _$SpotubeAudioSourceContainerPresetLossyImpl( - {required this.type, - required this.name, - required final List qualities}) - : _qualities = qualities, - super._(); - - factory _$SpotubeAudioSourceContainerPresetLossyImpl.fromJson( - Map json) => - _$$SpotubeAudioSourceContainerPresetLossyImplFromJson(json); - - @override - final SpotubeMediaCompressionType type; - @override - final String name; - final List _qualities; - @override - List get qualities { - if (_qualities is EqualUnmodifiableListView) return _qualities; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_qualities); - } - - @override - String toString() { - return 'SpotubeAudioSourceContainerPreset.lossy(type: $type, name: $name, qualities: $qualities)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeAudioSourceContainerPresetLossyImpl && - (identical(other.type, type) || other.type == type) && - (identical(other.name, name) || other.name == name) && - const DeepCollectionEquality() - .equals(other._qualities, _qualities)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash( - runtimeType, type, name, const DeepCollectionEquality().hash(_qualities)); - - /// Create a copy of SpotubeAudioSourceContainerPreset - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeAudioSourceContainerPresetLossyImplCopyWith< - _$SpotubeAudioSourceContainerPresetLossyImpl> - get copyWith => - __$$SpotubeAudioSourceContainerPresetLossyImplCopyWithImpl< - _$SpotubeAudioSourceContainerPresetLossyImpl>(this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function(SpotubeMediaCompressionType type, String name, - List qualities) - lossy, - required TResult Function(SpotubeMediaCompressionType type, String name, - List qualities) - lossless, - }) { - return lossy(type, name, qualities); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function(SpotubeMediaCompressionType type, String name, - List qualities)? - lossy, - TResult? Function(SpotubeMediaCompressionType type, String name, - List qualities)? - lossless, - }) { - return lossy?.call(type, name, qualities); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function(SpotubeMediaCompressionType type, String name, - List qualities)? - lossy, - TResult Function(SpotubeMediaCompressionType type, String name, - List qualities)? - lossless, - required TResult orElse(), - }) { - if (lossy != null) { - return lossy(type, name, qualities); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(SpotubeAudioSourceContainerPresetLossy value) - lossy, - required TResult Function(SpotubeAudioSourceContainerPresetLossless value) - lossless, - }) { - return lossy(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(SpotubeAudioSourceContainerPresetLossy value)? lossy, - TResult? Function(SpotubeAudioSourceContainerPresetLossless value)? - lossless, - }) { - return lossy?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(SpotubeAudioSourceContainerPresetLossy value)? lossy, - TResult Function(SpotubeAudioSourceContainerPresetLossless value)? lossless, - required TResult orElse(), - }) { - if (lossy != null) { - return lossy(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$SpotubeAudioSourceContainerPresetLossyImplToJson( - this, - ); - } -} - -abstract class SpotubeAudioSourceContainerPresetLossy - extends SpotubeAudioSourceContainerPreset { - factory SpotubeAudioSourceContainerPresetLossy( - {required final SpotubeMediaCompressionType type, - required final String name, - required final List qualities}) = - _$SpotubeAudioSourceContainerPresetLossyImpl; - SpotubeAudioSourceContainerPresetLossy._() : super._(); - - factory SpotubeAudioSourceContainerPresetLossy.fromJson( - Map json) = - _$SpotubeAudioSourceContainerPresetLossyImpl.fromJson; - - @override - SpotubeMediaCompressionType get type; - @override - String get name; - @override - List get qualities; - - /// Create a copy of SpotubeAudioSourceContainerPreset - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeAudioSourceContainerPresetLossyImplCopyWith< - _$SpotubeAudioSourceContainerPresetLossyImpl> - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class _$$SpotubeAudioSourceContainerPresetLosslessImplCopyWith<$Res> - implements $SpotubeAudioSourceContainerPresetCopyWith<$Res> { - factory _$$SpotubeAudioSourceContainerPresetLosslessImplCopyWith( - _$SpotubeAudioSourceContainerPresetLosslessImpl value, - $Res Function(_$SpotubeAudioSourceContainerPresetLosslessImpl) then) = - __$$SpotubeAudioSourceContainerPresetLosslessImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {SpotubeMediaCompressionType type, - String name, - List qualities}); -} - -/// @nodoc -class __$$SpotubeAudioSourceContainerPresetLosslessImplCopyWithImpl<$Res> - extends _$SpotubeAudioSourceContainerPresetCopyWithImpl<$Res, - _$SpotubeAudioSourceContainerPresetLosslessImpl> - implements _$$SpotubeAudioSourceContainerPresetLosslessImplCopyWith<$Res> { - __$$SpotubeAudioSourceContainerPresetLosslessImplCopyWithImpl( - _$SpotubeAudioSourceContainerPresetLosslessImpl _value, - $Res Function(_$SpotubeAudioSourceContainerPresetLosslessImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeAudioSourceContainerPreset - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? type = null, - Object? name = null, - Object? qualities = null, - }) { - return _then(_$SpotubeAudioSourceContainerPresetLosslessImpl( - type: null == type - ? _value.type - : type // ignore: cast_nullable_to_non_nullable - as SpotubeMediaCompressionType, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - qualities: null == qualities - ? _value._qualities - : qualities // ignore: cast_nullable_to_non_nullable - as List, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeAudioSourceContainerPresetLosslessImpl - extends SpotubeAudioSourceContainerPresetLossless { - _$SpotubeAudioSourceContainerPresetLosslessImpl( - {required this.type, - required this.name, - required final List qualities}) - : _qualities = qualities, - super._(); - - factory _$SpotubeAudioSourceContainerPresetLosslessImpl.fromJson( - Map json) => - _$$SpotubeAudioSourceContainerPresetLosslessImplFromJson(json); - - @override - final SpotubeMediaCompressionType type; - @override - final String name; - final List _qualities; - @override - List get qualities { - if (_qualities is EqualUnmodifiableListView) return _qualities; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_qualities); - } - - @override - String toString() { - return 'SpotubeAudioSourceContainerPreset.lossless(type: $type, name: $name, qualities: $qualities)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeAudioSourceContainerPresetLosslessImpl && - (identical(other.type, type) || other.type == type) && - (identical(other.name, name) || other.name == name) && - const DeepCollectionEquality() - .equals(other._qualities, _qualities)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash( - runtimeType, type, name, const DeepCollectionEquality().hash(_qualities)); - - /// Create a copy of SpotubeAudioSourceContainerPreset - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeAudioSourceContainerPresetLosslessImplCopyWith< - _$SpotubeAudioSourceContainerPresetLosslessImpl> - get copyWith => - __$$SpotubeAudioSourceContainerPresetLosslessImplCopyWithImpl< - _$SpotubeAudioSourceContainerPresetLosslessImpl>( - this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function(SpotubeMediaCompressionType type, String name, - List qualities) - lossy, - required TResult Function(SpotubeMediaCompressionType type, String name, - List qualities) - lossless, - }) { - return lossless(type, name, qualities); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function(SpotubeMediaCompressionType type, String name, - List qualities)? - lossy, - TResult? Function(SpotubeMediaCompressionType type, String name, - List qualities)? - lossless, - }) { - return lossless?.call(type, name, qualities); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function(SpotubeMediaCompressionType type, String name, - List qualities)? - lossy, - TResult Function(SpotubeMediaCompressionType type, String name, - List qualities)? - lossless, - required TResult orElse(), - }) { - if (lossless != null) { - return lossless(type, name, qualities); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(SpotubeAudioSourceContainerPresetLossy value) - lossy, - required TResult Function(SpotubeAudioSourceContainerPresetLossless value) - lossless, - }) { - return lossless(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(SpotubeAudioSourceContainerPresetLossy value)? lossy, - TResult? Function(SpotubeAudioSourceContainerPresetLossless value)? - lossless, - }) { - return lossless?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(SpotubeAudioSourceContainerPresetLossy value)? lossy, - TResult Function(SpotubeAudioSourceContainerPresetLossless value)? lossless, - required TResult orElse(), - }) { - if (lossless != null) { - return lossless(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$SpotubeAudioSourceContainerPresetLosslessImplToJson( - this, - ); - } -} - -abstract class SpotubeAudioSourceContainerPresetLossless - extends SpotubeAudioSourceContainerPreset { - factory SpotubeAudioSourceContainerPresetLossless( - {required final SpotubeMediaCompressionType type, - required final String name, - required final List - qualities}) = _$SpotubeAudioSourceContainerPresetLosslessImpl; - SpotubeAudioSourceContainerPresetLossless._() : super._(); - - factory SpotubeAudioSourceContainerPresetLossless.fromJson( - Map json) = - _$SpotubeAudioSourceContainerPresetLosslessImpl.fromJson; - - @override - SpotubeMediaCompressionType get type; - @override - String get name; - @override - List get qualities; - - /// Create a copy of SpotubeAudioSourceContainerPreset - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeAudioSourceContainerPresetLosslessImplCopyWith< - _$SpotubeAudioSourceContainerPresetLosslessImpl> - get copyWith => throw _privateConstructorUsedError; -} - -SpotubeAudioLossyContainerQuality _$SpotubeAudioLossyContainerQualityFromJson( - Map json) { - return _SpotubeAudioLossyContainerQuality.fromJson(json); -} - -/// @nodoc -mixin _$SpotubeAudioLossyContainerQuality { - int get bitrate => throw _privateConstructorUsedError; - - /// Serializes this SpotubeAudioLossyContainerQuality to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SpotubeAudioLossyContainerQuality - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeAudioLossyContainerQualityCopyWith - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeAudioLossyContainerQualityCopyWith<$Res> { - factory $SpotubeAudioLossyContainerQualityCopyWith( - SpotubeAudioLossyContainerQuality value, - $Res Function(SpotubeAudioLossyContainerQuality) then) = - _$SpotubeAudioLossyContainerQualityCopyWithImpl<$Res, - SpotubeAudioLossyContainerQuality>; - @useResult - $Res call({int bitrate}); -} - -/// @nodoc -class _$SpotubeAudioLossyContainerQualityCopyWithImpl<$Res, - $Val extends SpotubeAudioLossyContainerQuality> - implements $SpotubeAudioLossyContainerQualityCopyWith<$Res> { - _$SpotubeAudioLossyContainerQualityCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeAudioLossyContainerQuality - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? bitrate = null, - }) { - return _then(_value.copyWith( - bitrate: null == bitrate - ? _value.bitrate - : bitrate // ignore: cast_nullable_to_non_nullable - as int, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$SpotubeAudioLossyContainerQualityImplCopyWith<$Res> - implements $SpotubeAudioLossyContainerQualityCopyWith<$Res> { - factory _$$SpotubeAudioLossyContainerQualityImplCopyWith( - _$SpotubeAudioLossyContainerQualityImpl value, - $Res Function(_$SpotubeAudioLossyContainerQualityImpl) then) = - __$$SpotubeAudioLossyContainerQualityImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({int bitrate}); -} - -/// @nodoc -class __$$SpotubeAudioLossyContainerQualityImplCopyWithImpl<$Res> - extends _$SpotubeAudioLossyContainerQualityCopyWithImpl<$Res, - _$SpotubeAudioLossyContainerQualityImpl> - implements _$$SpotubeAudioLossyContainerQualityImplCopyWith<$Res> { - __$$SpotubeAudioLossyContainerQualityImplCopyWithImpl( - _$SpotubeAudioLossyContainerQualityImpl _value, - $Res Function(_$SpotubeAudioLossyContainerQualityImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeAudioLossyContainerQuality - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? bitrate = null, - }) { - return _then(_$SpotubeAudioLossyContainerQualityImpl( - bitrate: null == bitrate - ? _value.bitrate - : bitrate // ignore: cast_nullable_to_non_nullable - as int, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeAudioLossyContainerQualityImpl - extends _SpotubeAudioLossyContainerQuality { - _$SpotubeAudioLossyContainerQualityImpl({required this.bitrate}) : super._(); - - factory _$SpotubeAudioLossyContainerQualityImpl.fromJson( - Map json) => - _$$SpotubeAudioLossyContainerQualityImplFromJson(json); - - @override - final int bitrate; - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeAudioLossyContainerQualityImpl && - (identical(other.bitrate, bitrate) || other.bitrate == bitrate)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, bitrate); - - /// Create a copy of SpotubeAudioLossyContainerQuality - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeAudioLossyContainerQualityImplCopyWith< - _$SpotubeAudioLossyContainerQualityImpl> - get copyWith => __$$SpotubeAudioLossyContainerQualityImplCopyWithImpl< - _$SpotubeAudioLossyContainerQualityImpl>(this, _$identity); - - @override - Map toJson() { - return _$$SpotubeAudioLossyContainerQualityImplToJson( - this, - ); - } -} - -abstract class _SpotubeAudioLossyContainerQuality - extends SpotubeAudioLossyContainerQuality { - factory _SpotubeAudioLossyContainerQuality({required final int bitrate}) = - _$SpotubeAudioLossyContainerQualityImpl; - _SpotubeAudioLossyContainerQuality._() : super._(); - - factory _SpotubeAudioLossyContainerQuality.fromJson( - Map json) = - _$SpotubeAudioLossyContainerQualityImpl.fromJson; - - @override - int get bitrate; - - /// Create a copy of SpotubeAudioLossyContainerQuality - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeAudioLossyContainerQualityImplCopyWith< - _$SpotubeAudioLossyContainerQualityImpl> - get copyWith => throw _privateConstructorUsedError; -} - -SpotubeAudioLosslessContainerQuality - _$SpotubeAudioLosslessContainerQualityFromJson(Map json) { - return _SpotubeAudioLosslessContainerQuality.fromJson(json); -} - -/// @nodoc -mixin _$SpotubeAudioLosslessContainerQuality { - int get bitDepth => throw _privateConstructorUsedError; // bit - int get sampleRate => throw _privateConstructorUsedError; - - /// Serializes this SpotubeAudioLosslessContainerQuality to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SpotubeAudioLosslessContainerQuality - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeAudioLosslessContainerQualityCopyWith< - SpotubeAudioLosslessContainerQuality> - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeAudioLosslessContainerQualityCopyWith<$Res> { - factory $SpotubeAudioLosslessContainerQualityCopyWith( - SpotubeAudioLosslessContainerQuality value, - $Res Function(SpotubeAudioLosslessContainerQuality) then) = - _$SpotubeAudioLosslessContainerQualityCopyWithImpl<$Res, - SpotubeAudioLosslessContainerQuality>; - @useResult - $Res call({int bitDepth, int sampleRate}); -} - -/// @nodoc -class _$SpotubeAudioLosslessContainerQualityCopyWithImpl<$Res, - $Val extends SpotubeAudioLosslessContainerQuality> - implements $SpotubeAudioLosslessContainerQualityCopyWith<$Res> { - _$SpotubeAudioLosslessContainerQualityCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeAudioLosslessContainerQuality - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? bitDepth = null, - Object? sampleRate = null, - }) { - return _then(_value.copyWith( - bitDepth: null == bitDepth - ? _value.bitDepth - : bitDepth // ignore: cast_nullable_to_non_nullable - as int, - sampleRate: null == sampleRate - ? _value.sampleRate - : sampleRate // ignore: cast_nullable_to_non_nullable - as int, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$SpotubeAudioLosslessContainerQualityImplCopyWith<$Res> - implements $SpotubeAudioLosslessContainerQualityCopyWith<$Res> { - factory _$$SpotubeAudioLosslessContainerQualityImplCopyWith( - _$SpotubeAudioLosslessContainerQualityImpl value, - $Res Function(_$SpotubeAudioLosslessContainerQualityImpl) then) = - __$$SpotubeAudioLosslessContainerQualityImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({int bitDepth, int sampleRate}); -} - -/// @nodoc -class __$$SpotubeAudioLosslessContainerQualityImplCopyWithImpl<$Res> - extends _$SpotubeAudioLosslessContainerQualityCopyWithImpl<$Res, - _$SpotubeAudioLosslessContainerQualityImpl> - implements _$$SpotubeAudioLosslessContainerQualityImplCopyWith<$Res> { - __$$SpotubeAudioLosslessContainerQualityImplCopyWithImpl( - _$SpotubeAudioLosslessContainerQualityImpl _value, - $Res Function(_$SpotubeAudioLosslessContainerQualityImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeAudioLosslessContainerQuality - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? bitDepth = null, - Object? sampleRate = null, - }) { - return _then(_$SpotubeAudioLosslessContainerQualityImpl( - bitDepth: null == bitDepth - ? _value.bitDepth - : bitDepth // ignore: cast_nullable_to_non_nullable - as int, - sampleRate: null == sampleRate - ? _value.sampleRate - : sampleRate // ignore: cast_nullable_to_non_nullable - as int, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeAudioLosslessContainerQualityImpl - extends _SpotubeAudioLosslessContainerQuality { - _$SpotubeAudioLosslessContainerQualityImpl( - {required this.bitDepth, required this.sampleRate}) - : super._(); - - factory _$SpotubeAudioLosslessContainerQualityImpl.fromJson( - Map json) => - _$$SpotubeAudioLosslessContainerQualityImplFromJson(json); - - @override - final int bitDepth; -// bit - @override - final int sampleRate; - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeAudioLosslessContainerQualityImpl && - (identical(other.bitDepth, bitDepth) || - other.bitDepth == bitDepth) && - (identical(other.sampleRate, sampleRate) || - other.sampleRate == sampleRate)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, bitDepth, sampleRate); - - /// Create a copy of SpotubeAudioLosslessContainerQuality - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeAudioLosslessContainerQualityImplCopyWith< - _$SpotubeAudioLosslessContainerQualityImpl> - get copyWith => __$$SpotubeAudioLosslessContainerQualityImplCopyWithImpl< - _$SpotubeAudioLosslessContainerQualityImpl>(this, _$identity); - - @override - Map toJson() { - return _$$SpotubeAudioLosslessContainerQualityImplToJson( - this, - ); - } -} - -abstract class _SpotubeAudioLosslessContainerQuality - extends SpotubeAudioLosslessContainerQuality { - factory _SpotubeAudioLosslessContainerQuality( - {required final int bitDepth, required final int sampleRate}) = - _$SpotubeAudioLosslessContainerQualityImpl; - _SpotubeAudioLosslessContainerQuality._() : super._(); - - factory _SpotubeAudioLosslessContainerQuality.fromJson( - Map json) = - _$SpotubeAudioLosslessContainerQualityImpl.fromJson; - - @override - int get bitDepth; // bit - @override - int get sampleRate; - - /// Create a copy of SpotubeAudioLosslessContainerQuality - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeAudioLosslessContainerQualityImplCopyWith< - _$SpotubeAudioLosslessContainerQualityImpl> - get copyWith => throw _privateConstructorUsedError; -} - -SpotubeAudioSourceMatchObject _$SpotubeAudioSourceMatchObjectFromJson( - Map json) { - return _SpotubeAudioSourceMatchObject.fromJson(json); -} - -/// @nodoc -mixin _$SpotubeAudioSourceMatchObject { - String get id => throw _privateConstructorUsedError; - String get title => throw _privateConstructorUsedError; - List get artists => throw _privateConstructorUsedError; - Duration get duration => throw _privateConstructorUsedError; - String? get thumbnail => throw _privateConstructorUsedError; - String get externalUri => throw _privateConstructorUsedError; - - /// Serializes this SpotubeAudioSourceMatchObject to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SpotubeAudioSourceMatchObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeAudioSourceMatchObjectCopyWith - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeAudioSourceMatchObjectCopyWith<$Res> { - factory $SpotubeAudioSourceMatchObjectCopyWith( - SpotubeAudioSourceMatchObject value, - $Res Function(SpotubeAudioSourceMatchObject) then) = - _$SpotubeAudioSourceMatchObjectCopyWithImpl<$Res, - SpotubeAudioSourceMatchObject>; - @useResult - $Res call( - {String id, - String title, - List artists, - Duration duration, - String? thumbnail, - String externalUri}); -} - -/// @nodoc -class _$SpotubeAudioSourceMatchObjectCopyWithImpl<$Res, - $Val extends SpotubeAudioSourceMatchObject> - implements $SpotubeAudioSourceMatchObjectCopyWith<$Res> { - _$SpotubeAudioSourceMatchObjectCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeAudioSourceMatchObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? title = null, - Object? artists = null, - Object? duration = null, - Object? thumbnail = freezed, - Object? externalUri = null, - }) { - return _then(_value.copyWith( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - title: null == title - ? _value.title - : title // ignore: cast_nullable_to_non_nullable - as String, - artists: null == artists - ? _value.artists - : artists // ignore: cast_nullable_to_non_nullable - as List, - duration: null == duration - ? _value.duration - : duration // ignore: cast_nullable_to_non_nullable - as Duration, - thumbnail: freezed == thumbnail - ? _value.thumbnail - : thumbnail // ignore: cast_nullable_to_non_nullable - as String?, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$SpotubeAudioSourceMatchObjectImplCopyWith<$Res> - implements $SpotubeAudioSourceMatchObjectCopyWith<$Res> { - factory _$$SpotubeAudioSourceMatchObjectImplCopyWith( - _$SpotubeAudioSourceMatchObjectImpl value, - $Res Function(_$SpotubeAudioSourceMatchObjectImpl) then) = - __$$SpotubeAudioSourceMatchObjectImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String id, - String title, - List artists, - Duration duration, - String? thumbnail, - String externalUri}); -} - -/// @nodoc -class __$$SpotubeAudioSourceMatchObjectImplCopyWithImpl<$Res> - extends _$SpotubeAudioSourceMatchObjectCopyWithImpl<$Res, - _$SpotubeAudioSourceMatchObjectImpl> - implements _$$SpotubeAudioSourceMatchObjectImplCopyWith<$Res> { - __$$SpotubeAudioSourceMatchObjectImplCopyWithImpl( - _$SpotubeAudioSourceMatchObjectImpl _value, - $Res Function(_$SpotubeAudioSourceMatchObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeAudioSourceMatchObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? title = null, - Object? artists = null, - Object? duration = null, - Object? thumbnail = freezed, - Object? externalUri = null, - }) { - return _then(_$SpotubeAudioSourceMatchObjectImpl( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - title: null == title - ? _value.title - : title // ignore: cast_nullable_to_non_nullable - as String, - artists: null == artists - ? _value._artists - : artists // ignore: cast_nullable_to_non_nullable - as List, - duration: null == duration - ? _value.duration - : duration // ignore: cast_nullable_to_non_nullable - as Duration, - thumbnail: freezed == thumbnail - ? _value.thumbnail - : thumbnail // ignore: cast_nullable_to_non_nullable - as String?, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeAudioSourceMatchObjectImpl - implements _SpotubeAudioSourceMatchObject { - _$SpotubeAudioSourceMatchObjectImpl( - {required this.id, - required this.title, - required final List artists, - required this.duration, - this.thumbnail, - required this.externalUri}) - : _artists = artists; - - factory _$SpotubeAudioSourceMatchObjectImpl.fromJson( - Map json) => - _$$SpotubeAudioSourceMatchObjectImplFromJson(json); - - @override - final String id; - @override - final String title; - final List _artists; - @override - List get artists { - if (_artists is EqualUnmodifiableListView) return _artists; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_artists); - } - - @override - final Duration duration; - @override - final String? thumbnail; - @override - final String externalUri; - - @override - String toString() { - return 'SpotubeAudioSourceMatchObject(id: $id, title: $title, artists: $artists, duration: $duration, thumbnail: $thumbnail, externalUri: $externalUri)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeAudioSourceMatchObjectImpl && - (identical(other.id, id) || other.id == id) && - (identical(other.title, title) || other.title == title) && - const DeepCollectionEquality().equals(other._artists, _artists) && - (identical(other.duration, duration) || - other.duration == duration) && - (identical(other.thumbnail, thumbnail) || - other.thumbnail == thumbnail) && - (identical(other.externalUri, externalUri) || - other.externalUri == externalUri)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash( - runtimeType, - id, - title, - const DeepCollectionEquality().hash(_artists), - duration, - thumbnail, - externalUri); - - /// Create a copy of SpotubeAudioSourceMatchObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeAudioSourceMatchObjectImplCopyWith< - _$SpotubeAudioSourceMatchObjectImpl> - get copyWith => __$$SpotubeAudioSourceMatchObjectImplCopyWithImpl< - _$SpotubeAudioSourceMatchObjectImpl>(this, _$identity); - - @override - Map toJson() { - return _$$SpotubeAudioSourceMatchObjectImplToJson( - this, - ); - } -} - -abstract class _SpotubeAudioSourceMatchObject - implements SpotubeAudioSourceMatchObject { - factory _SpotubeAudioSourceMatchObject( - {required final String id, - required final String title, - required final List artists, - required final Duration duration, - final String? thumbnail, - required final String externalUri}) = _$SpotubeAudioSourceMatchObjectImpl; - - factory _SpotubeAudioSourceMatchObject.fromJson(Map json) = - _$SpotubeAudioSourceMatchObjectImpl.fromJson; - - @override - String get id; - @override - String get title; - @override - List get artists; - @override - Duration get duration; - @override - String? get thumbnail; - @override - String get externalUri; - - /// Create a copy of SpotubeAudioSourceMatchObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeAudioSourceMatchObjectImplCopyWith< - _$SpotubeAudioSourceMatchObjectImpl> - get copyWith => throw _privateConstructorUsedError; -} - -SpotubeAudioSourceStreamObject _$SpotubeAudioSourceStreamObjectFromJson( - Map json) { - return _SpotubeAudioSourceStreamObject.fromJson(json); -} - -/// @nodoc -mixin _$SpotubeAudioSourceStreamObject { - String get url => throw _privateConstructorUsedError; - String get container => throw _privateConstructorUsedError; - SpotubeMediaCompressionType get type => throw _privateConstructorUsedError; - String? get codec => throw _privateConstructorUsedError; - double? get bitrate => throw _privateConstructorUsedError; - int? get bitDepth => throw _privateConstructorUsedError; - double? get sampleRate => throw _privateConstructorUsedError; - - /// Serializes this SpotubeAudioSourceStreamObject to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SpotubeAudioSourceStreamObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeAudioSourceStreamObjectCopyWith - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeAudioSourceStreamObjectCopyWith<$Res> { - factory $SpotubeAudioSourceStreamObjectCopyWith( - SpotubeAudioSourceStreamObject value, - $Res Function(SpotubeAudioSourceStreamObject) then) = - _$SpotubeAudioSourceStreamObjectCopyWithImpl<$Res, - SpotubeAudioSourceStreamObject>; - @useResult - $Res call( - {String url, - String container, - SpotubeMediaCompressionType type, - String? codec, - double? bitrate, - int? bitDepth, - double? sampleRate}); -} - -/// @nodoc -class _$SpotubeAudioSourceStreamObjectCopyWithImpl<$Res, - $Val extends SpotubeAudioSourceStreamObject> - implements $SpotubeAudioSourceStreamObjectCopyWith<$Res> { - _$SpotubeAudioSourceStreamObjectCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeAudioSourceStreamObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? url = null, - Object? container = null, - Object? type = null, - Object? codec = freezed, - Object? bitrate = freezed, - Object? bitDepth = freezed, - Object? sampleRate = freezed, - }) { - return _then(_value.copyWith( - url: null == url - ? _value.url - : url // ignore: cast_nullable_to_non_nullable - as String, - container: null == container - ? _value.container - : container // ignore: cast_nullable_to_non_nullable - as String, - type: null == type - ? _value.type - : type // ignore: cast_nullable_to_non_nullable - as SpotubeMediaCompressionType, - codec: freezed == codec - ? _value.codec - : codec // ignore: cast_nullable_to_non_nullable - as String?, - bitrate: freezed == bitrate - ? _value.bitrate - : bitrate // ignore: cast_nullable_to_non_nullable - as double?, - bitDepth: freezed == bitDepth - ? _value.bitDepth - : bitDepth // ignore: cast_nullable_to_non_nullable - as int?, - sampleRate: freezed == sampleRate - ? _value.sampleRate - : sampleRate // ignore: cast_nullable_to_non_nullable - as double?, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$SpotubeAudioSourceStreamObjectImplCopyWith<$Res> - implements $SpotubeAudioSourceStreamObjectCopyWith<$Res> { - factory _$$SpotubeAudioSourceStreamObjectImplCopyWith( - _$SpotubeAudioSourceStreamObjectImpl value, - $Res Function(_$SpotubeAudioSourceStreamObjectImpl) then) = - __$$SpotubeAudioSourceStreamObjectImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String url, - String container, - SpotubeMediaCompressionType type, - String? codec, - double? bitrate, - int? bitDepth, - double? sampleRate}); -} - -/// @nodoc -class __$$SpotubeAudioSourceStreamObjectImplCopyWithImpl<$Res> - extends _$SpotubeAudioSourceStreamObjectCopyWithImpl<$Res, - _$SpotubeAudioSourceStreamObjectImpl> - implements _$$SpotubeAudioSourceStreamObjectImplCopyWith<$Res> { - __$$SpotubeAudioSourceStreamObjectImplCopyWithImpl( - _$SpotubeAudioSourceStreamObjectImpl _value, - $Res Function(_$SpotubeAudioSourceStreamObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeAudioSourceStreamObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? url = null, - Object? container = null, - Object? type = null, - Object? codec = freezed, - Object? bitrate = freezed, - Object? bitDepth = freezed, - Object? sampleRate = freezed, - }) { - return _then(_$SpotubeAudioSourceStreamObjectImpl( - url: null == url - ? _value.url - : url // ignore: cast_nullable_to_non_nullable - as String, - container: null == container - ? _value.container - : container // ignore: cast_nullable_to_non_nullable - as String, - type: null == type - ? _value.type - : type // ignore: cast_nullable_to_non_nullable - as SpotubeMediaCompressionType, - codec: freezed == codec - ? _value.codec - : codec // ignore: cast_nullable_to_non_nullable - as String?, - bitrate: freezed == bitrate - ? _value.bitrate - : bitrate // ignore: cast_nullable_to_non_nullable - as double?, - bitDepth: freezed == bitDepth - ? _value.bitDepth - : bitDepth // ignore: cast_nullable_to_non_nullable - as int?, - sampleRate: freezed == sampleRate - ? _value.sampleRate - : sampleRate // ignore: cast_nullable_to_non_nullable - as double?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeAudioSourceStreamObjectImpl - implements _SpotubeAudioSourceStreamObject { - _$SpotubeAudioSourceStreamObjectImpl( - {required this.url, - required this.container, - required this.type, - this.codec, - this.bitrate, - this.bitDepth, - this.sampleRate}); - - factory _$SpotubeAudioSourceStreamObjectImpl.fromJson( - Map json) => - _$$SpotubeAudioSourceStreamObjectImplFromJson(json); - - @override - final String url; - @override - final String container; - @override - final SpotubeMediaCompressionType type; - @override - final String? codec; - @override - final double? bitrate; - @override - final int? bitDepth; - @override - final double? sampleRate; - - @override - String toString() { - return 'SpotubeAudioSourceStreamObject(url: $url, container: $container, type: $type, codec: $codec, bitrate: $bitrate, bitDepth: $bitDepth, sampleRate: $sampleRate)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeAudioSourceStreamObjectImpl && - (identical(other.url, url) || other.url == url) && - (identical(other.container, container) || - other.container == container) && - (identical(other.type, type) || other.type == type) && - (identical(other.codec, codec) || other.codec == codec) && - (identical(other.bitrate, bitrate) || other.bitrate == bitrate) && - (identical(other.bitDepth, bitDepth) || - other.bitDepth == bitDepth) && - (identical(other.sampleRate, sampleRate) || - other.sampleRate == sampleRate)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash( - runtimeType, url, container, type, codec, bitrate, bitDepth, sampleRate); - - /// Create a copy of SpotubeAudioSourceStreamObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeAudioSourceStreamObjectImplCopyWith< - _$SpotubeAudioSourceStreamObjectImpl> - get copyWith => __$$SpotubeAudioSourceStreamObjectImplCopyWithImpl< - _$SpotubeAudioSourceStreamObjectImpl>(this, _$identity); - - @override - Map toJson() { - return _$$SpotubeAudioSourceStreamObjectImplToJson( - this, - ); - } -} - -abstract class _SpotubeAudioSourceStreamObject - implements SpotubeAudioSourceStreamObject { - factory _SpotubeAudioSourceStreamObject( - {required final String url, - required final String container, - required final SpotubeMediaCompressionType type, - final String? codec, - final double? bitrate, - final int? bitDepth, - final double? sampleRate}) = _$SpotubeAudioSourceStreamObjectImpl; - - factory _SpotubeAudioSourceStreamObject.fromJson(Map json) = - _$SpotubeAudioSourceStreamObjectImpl.fromJson; - - @override - String get url; - @override - String get container; - @override - SpotubeMediaCompressionType get type; - @override - String? get codec; - @override - double? get bitrate; - @override - int? get bitDepth; - @override - double? get sampleRate; - - /// Create a copy of SpotubeAudioSourceStreamObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeAudioSourceStreamObjectImplCopyWith< - _$SpotubeAudioSourceStreamObjectImpl> - get copyWith => throw _privateConstructorUsedError; -} - -SpotubeFullAlbumObject _$SpotubeFullAlbumObjectFromJson( - Map json) { - return _SpotubeFullAlbumObject.fromJson(json); -} - -/// @nodoc -mixin _$SpotubeFullAlbumObject { - String get id => throw _privateConstructorUsedError; - String get name => throw _privateConstructorUsedError; - List get artists => - throw _privateConstructorUsedError; - List get images => throw _privateConstructorUsedError; - String get releaseDate => throw _privateConstructorUsedError; - String get externalUri => throw _privateConstructorUsedError; - int get totalTracks => throw _privateConstructorUsedError; - SpotubeAlbumType get albumType => throw _privateConstructorUsedError; - String? get recordLabel => throw _privateConstructorUsedError; - List? get genres => throw _privateConstructorUsedError; - - /// Serializes this SpotubeFullAlbumObject to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SpotubeFullAlbumObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeFullAlbumObjectCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeFullAlbumObjectCopyWith<$Res> { - factory $SpotubeFullAlbumObjectCopyWith(SpotubeFullAlbumObject value, - $Res Function(SpotubeFullAlbumObject) then) = - _$SpotubeFullAlbumObjectCopyWithImpl<$Res, SpotubeFullAlbumObject>; - @useResult - $Res call( - {String id, - String name, - List artists, - List images, - String releaseDate, - String externalUri, - int totalTracks, - SpotubeAlbumType albumType, - String? recordLabel, - List? genres}); -} - -/// @nodoc -class _$SpotubeFullAlbumObjectCopyWithImpl<$Res, - $Val extends SpotubeFullAlbumObject> - implements $SpotubeFullAlbumObjectCopyWith<$Res> { - _$SpotubeFullAlbumObjectCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeFullAlbumObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? artists = null, - Object? images = null, - Object? releaseDate = null, - Object? externalUri = null, - Object? totalTracks = null, - Object? albumType = null, - Object? recordLabel = freezed, - Object? genres = freezed, - }) { - return _then(_value.copyWith( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - artists: null == artists - ? _value.artists - : artists // ignore: cast_nullable_to_non_nullable - as List, - images: null == images - ? _value.images - : images // ignore: cast_nullable_to_non_nullable - as List, - releaseDate: null == releaseDate - ? _value.releaseDate - : releaseDate // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - totalTracks: null == totalTracks - ? _value.totalTracks - : totalTracks // ignore: cast_nullable_to_non_nullable - as int, - albumType: null == albumType - ? _value.albumType - : albumType // ignore: cast_nullable_to_non_nullable - as SpotubeAlbumType, - recordLabel: freezed == recordLabel - ? _value.recordLabel - : recordLabel // ignore: cast_nullable_to_non_nullable - as String?, - genres: freezed == genres - ? _value.genres - : genres // ignore: cast_nullable_to_non_nullable - as List?, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$SpotubeFullAlbumObjectImplCopyWith<$Res> - implements $SpotubeFullAlbumObjectCopyWith<$Res> { - factory _$$SpotubeFullAlbumObjectImplCopyWith( - _$SpotubeFullAlbumObjectImpl value, - $Res Function(_$SpotubeFullAlbumObjectImpl) then) = - __$$SpotubeFullAlbumObjectImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String id, - String name, - List artists, - List images, - String releaseDate, - String externalUri, - int totalTracks, - SpotubeAlbumType albumType, - String? recordLabel, - List? genres}); -} - -/// @nodoc -class __$$SpotubeFullAlbumObjectImplCopyWithImpl<$Res> - extends _$SpotubeFullAlbumObjectCopyWithImpl<$Res, - _$SpotubeFullAlbumObjectImpl> - implements _$$SpotubeFullAlbumObjectImplCopyWith<$Res> { - __$$SpotubeFullAlbumObjectImplCopyWithImpl( - _$SpotubeFullAlbumObjectImpl _value, - $Res Function(_$SpotubeFullAlbumObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeFullAlbumObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? artists = null, - Object? images = null, - Object? releaseDate = null, - Object? externalUri = null, - Object? totalTracks = null, - Object? albumType = null, - Object? recordLabel = freezed, - Object? genres = freezed, - }) { - return _then(_$SpotubeFullAlbumObjectImpl( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - artists: null == artists - ? _value._artists - : artists // ignore: cast_nullable_to_non_nullable - as List, - images: null == images - ? _value._images - : images // ignore: cast_nullable_to_non_nullable - as List, - releaseDate: null == releaseDate - ? _value.releaseDate - : releaseDate // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - totalTracks: null == totalTracks - ? _value.totalTracks - : totalTracks // ignore: cast_nullable_to_non_nullable - as int, - albumType: null == albumType - ? _value.albumType - : albumType // ignore: cast_nullable_to_non_nullable - as SpotubeAlbumType, - recordLabel: freezed == recordLabel - ? _value.recordLabel - : recordLabel // ignore: cast_nullable_to_non_nullable - as String?, - genres: freezed == genres - ? _value._genres - : genres // ignore: cast_nullable_to_non_nullable - as List?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeFullAlbumObjectImpl implements _SpotubeFullAlbumObject { - _$SpotubeFullAlbumObjectImpl( - {required this.id, - required this.name, - required final List artists, - final List images = const [], - required this.releaseDate, - required this.externalUri, - required this.totalTracks, - required this.albumType, - this.recordLabel, - final List? genres}) - : _artists = artists, - _images = images, - _genres = genres; - - factory _$SpotubeFullAlbumObjectImpl.fromJson(Map json) => - _$$SpotubeFullAlbumObjectImplFromJson(json); - - @override - final String id; - @override - final String name; - final List _artists; - @override - List get artists { - if (_artists is EqualUnmodifiableListView) return _artists; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_artists); - } - - final List _images; - @override - @JsonKey() - List get images { - if (_images is EqualUnmodifiableListView) return _images; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_images); - } - - @override - final String releaseDate; - @override - final String externalUri; - @override - final int totalTracks; - @override - final SpotubeAlbumType albumType; - @override - final String? recordLabel; - final List? _genres; - @override - List? get genres { - final value = _genres; - if (value == null) return null; - if (_genres is EqualUnmodifiableListView) return _genres; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(value); - } - - @override - String toString() { - return 'SpotubeFullAlbumObject(id: $id, name: $name, artists: $artists, images: $images, releaseDate: $releaseDate, externalUri: $externalUri, totalTracks: $totalTracks, albumType: $albumType, recordLabel: $recordLabel, genres: $genres)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeFullAlbumObjectImpl && - (identical(other.id, id) || other.id == id) && - (identical(other.name, name) || other.name == name) && - const DeepCollectionEquality().equals(other._artists, _artists) && - const DeepCollectionEquality().equals(other._images, _images) && - (identical(other.releaseDate, releaseDate) || - other.releaseDate == releaseDate) && - (identical(other.externalUri, externalUri) || - other.externalUri == externalUri) && - (identical(other.totalTracks, totalTracks) || - other.totalTracks == totalTracks) && - (identical(other.albumType, albumType) || - other.albumType == albumType) && - (identical(other.recordLabel, recordLabel) || - other.recordLabel == recordLabel) && - const DeepCollectionEquality().equals(other._genres, _genres)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash( - runtimeType, - id, - name, - const DeepCollectionEquality().hash(_artists), - const DeepCollectionEquality().hash(_images), - releaseDate, - externalUri, - totalTracks, - albumType, - recordLabel, - const DeepCollectionEquality().hash(_genres)); - - /// Create a copy of SpotubeFullAlbumObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeFullAlbumObjectImplCopyWith<_$SpotubeFullAlbumObjectImpl> - get copyWith => __$$SpotubeFullAlbumObjectImplCopyWithImpl< - _$SpotubeFullAlbumObjectImpl>(this, _$identity); - - @override - Map toJson() { - return _$$SpotubeFullAlbumObjectImplToJson( - this, - ); - } -} - -abstract class _SpotubeFullAlbumObject implements SpotubeFullAlbumObject { - factory _SpotubeFullAlbumObject( - {required final String id, - required final String name, - required final List artists, - final List images, - required final String releaseDate, - required final String externalUri, - required final int totalTracks, - required final SpotubeAlbumType albumType, - final String? recordLabel, - final List? genres}) = _$SpotubeFullAlbumObjectImpl; - - factory _SpotubeFullAlbumObject.fromJson(Map json) = - _$SpotubeFullAlbumObjectImpl.fromJson; - - @override - String get id; - @override - String get name; - @override - List get artists; - @override - List get images; - @override - String get releaseDate; - @override - String get externalUri; - @override - int get totalTracks; - @override - SpotubeAlbumType get albumType; - @override - String? get recordLabel; - @override - List? get genres; - - /// Create a copy of SpotubeFullAlbumObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeFullAlbumObjectImplCopyWith<_$SpotubeFullAlbumObjectImpl> - get copyWith => throw _privateConstructorUsedError; -} - -SpotubeSimpleAlbumObject _$SpotubeSimpleAlbumObjectFromJson( - Map json) { - return _SpotubeSimpleAlbumObject.fromJson(json); -} - -/// @nodoc -mixin _$SpotubeSimpleAlbumObject { - String get id => throw _privateConstructorUsedError; - String get name => throw _privateConstructorUsedError; - String get externalUri => throw _privateConstructorUsedError; - List get artists => - throw _privateConstructorUsedError; - List get images => throw _privateConstructorUsedError; - SpotubeAlbumType get albumType => throw _privateConstructorUsedError; - String? get releaseDate => throw _privateConstructorUsedError; - - /// Serializes this SpotubeSimpleAlbumObject to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SpotubeSimpleAlbumObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeSimpleAlbumObjectCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeSimpleAlbumObjectCopyWith<$Res> { - factory $SpotubeSimpleAlbumObjectCopyWith(SpotubeSimpleAlbumObject value, - $Res Function(SpotubeSimpleAlbumObject) then) = - _$SpotubeSimpleAlbumObjectCopyWithImpl<$Res, SpotubeSimpleAlbumObject>; - @useResult - $Res call( - {String id, - String name, - String externalUri, - List artists, - List images, - SpotubeAlbumType albumType, - String? releaseDate}); -} - -/// @nodoc -class _$SpotubeSimpleAlbumObjectCopyWithImpl<$Res, - $Val extends SpotubeSimpleAlbumObject> - implements $SpotubeSimpleAlbumObjectCopyWith<$Res> { - _$SpotubeSimpleAlbumObjectCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeSimpleAlbumObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? externalUri = null, - Object? artists = null, - Object? images = null, - Object? albumType = null, - Object? releaseDate = freezed, - }) { - return _then(_value.copyWith( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - artists: null == artists - ? _value.artists - : artists // ignore: cast_nullable_to_non_nullable - as List, - images: null == images - ? _value.images - : images // ignore: cast_nullable_to_non_nullable - as List, - albumType: null == albumType - ? _value.albumType - : albumType // ignore: cast_nullable_to_non_nullable - as SpotubeAlbumType, - releaseDate: freezed == releaseDate - ? _value.releaseDate - : releaseDate // ignore: cast_nullable_to_non_nullable - as String?, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$SpotubeSimpleAlbumObjectImplCopyWith<$Res> - implements $SpotubeSimpleAlbumObjectCopyWith<$Res> { - factory _$$SpotubeSimpleAlbumObjectImplCopyWith( - _$SpotubeSimpleAlbumObjectImpl value, - $Res Function(_$SpotubeSimpleAlbumObjectImpl) then) = - __$$SpotubeSimpleAlbumObjectImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String id, - String name, - String externalUri, - List artists, - List images, - SpotubeAlbumType albumType, - String? releaseDate}); -} - -/// @nodoc -class __$$SpotubeSimpleAlbumObjectImplCopyWithImpl<$Res> - extends _$SpotubeSimpleAlbumObjectCopyWithImpl<$Res, - _$SpotubeSimpleAlbumObjectImpl> - implements _$$SpotubeSimpleAlbumObjectImplCopyWith<$Res> { - __$$SpotubeSimpleAlbumObjectImplCopyWithImpl( - _$SpotubeSimpleAlbumObjectImpl _value, - $Res Function(_$SpotubeSimpleAlbumObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeSimpleAlbumObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? externalUri = null, - Object? artists = null, - Object? images = null, - Object? albumType = null, - Object? releaseDate = freezed, - }) { - return _then(_$SpotubeSimpleAlbumObjectImpl( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - artists: null == artists - ? _value._artists - : artists // ignore: cast_nullable_to_non_nullable - as List, - images: null == images - ? _value._images - : images // ignore: cast_nullable_to_non_nullable - as List, - albumType: null == albumType - ? _value.albumType - : albumType // ignore: cast_nullable_to_non_nullable - as SpotubeAlbumType, - releaseDate: freezed == releaseDate - ? _value.releaseDate - : releaseDate // ignore: cast_nullable_to_non_nullable - as String?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeSimpleAlbumObjectImpl implements _SpotubeSimpleAlbumObject { - _$SpotubeSimpleAlbumObjectImpl( - {required this.id, - required this.name, - required this.externalUri, - required final List artists, - final List images = const [], - required this.albumType, - this.releaseDate}) - : _artists = artists, - _images = images; - - factory _$SpotubeSimpleAlbumObjectImpl.fromJson(Map json) => - _$$SpotubeSimpleAlbumObjectImplFromJson(json); - - @override - final String id; - @override - final String name; - @override - final String externalUri; - final List _artists; - @override - List get artists { - if (_artists is EqualUnmodifiableListView) return _artists; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_artists); - } - - final List _images; - @override - @JsonKey() - List get images { - if (_images is EqualUnmodifiableListView) return _images; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_images); - } - - @override - final SpotubeAlbumType albumType; - @override - final String? releaseDate; - - @override - String toString() { - return 'SpotubeSimpleAlbumObject(id: $id, name: $name, externalUri: $externalUri, artists: $artists, images: $images, albumType: $albumType, releaseDate: $releaseDate)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeSimpleAlbumObjectImpl && - (identical(other.id, id) || other.id == id) && - (identical(other.name, name) || other.name == name) && - (identical(other.externalUri, externalUri) || - other.externalUri == externalUri) && - const DeepCollectionEquality().equals(other._artists, _artists) && - const DeepCollectionEquality().equals(other._images, _images) && - (identical(other.albumType, albumType) || - other.albumType == albumType) && - (identical(other.releaseDate, releaseDate) || - other.releaseDate == releaseDate)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash( - runtimeType, - id, - name, - externalUri, - const DeepCollectionEquality().hash(_artists), - const DeepCollectionEquality().hash(_images), - albumType, - releaseDate); - - /// Create a copy of SpotubeSimpleAlbumObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeSimpleAlbumObjectImplCopyWith<_$SpotubeSimpleAlbumObjectImpl> - get copyWith => __$$SpotubeSimpleAlbumObjectImplCopyWithImpl< - _$SpotubeSimpleAlbumObjectImpl>(this, _$identity); - - @override - Map toJson() { - return _$$SpotubeSimpleAlbumObjectImplToJson( - this, - ); - } -} - -abstract class _SpotubeSimpleAlbumObject implements SpotubeSimpleAlbumObject { - factory _SpotubeSimpleAlbumObject( - {required final String id, - required final String name, - required final String externalUri, - required final List artists, - final List images, - required final SpotubeAlbumType albumType, - final String? releaseDate}) = _$SpotubeSimpleAlbumObjectImpl; - - factory _SpotubeSimpleAlbumObject.fromJson(Map json) = - _$SpotubeSimpleAlbumObjectImpl.fromJson; - - @override - String get id; - @override - String get name; - @override - String get externalUri; - @override - List get artists; - @override - List get images; - @override - SpotubeAlbumType get albumType; - @override - String? get releaseDate; - - /// Create a copy of SpotubeSimpleAlbumObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeSimpleAlbumObjectImplCopyWith<_$SpotubeSimpleAlbumObjectImpl> - get copyWith => throw _privateConstructorUsedError; -} - -SpotubeFullArtistObject _$SpotubeFullArtistObjectFromJson( - Map json) { - return _SpotubeFullArtistObject.fromJson(json); -} - -/// @nodoc -mixin _$SpotubeFullArtistObject { - String get id => throw _privateConstructorUsedError; - String get name => throw _privateConstructorUsedError; - String get externalUri => throw _privateConstructorUsedError; - List get images => throw _privateConstructorUsedError; - List? get genres => throw _privateConstructorUsedError; - int? get followers => throw _privateConstructorUsedError; - - /// Serializes this SpotubeFullArtistObject to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SpotubeFullArtistObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeFullArtistObjectCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeFullArtistObjectCopyWith<$Res> { - factory $SpotubeFullArtistObjectCopyWith(SpotubeFullArtistObject value, - $Res Function(SpotubeFullArtistObject) then) = - _$SpotubeFullArtistObjectCopyWithImpl<$Res, SpotubeFullArtistObject>; - @useResult - $Res call( - {String id, - String name, - String externalUri, - List images, - List? genres, - int? followers}); -} - -/// @nodoc -class _$SpotubeFullArtistObjectCopyWithImpl<$Res, - $Val extends SpotubeFullArtistObject> - implements $SpotubeFullArtistObjectCopyWith<$Res> { - _$SpotubeFullArtistObjectCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeFullArtistObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? externalUri = null, - Object? images = null, - Object? genres = freezed, - Object? followers = freezed, - }) { - return _then(_value.copyWith( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - images: null == images - ? _value.images - : images // ignore: cast_nullable_to_non_nullable - as List, - genres: freezed == genres - ? _value.genres - : genres // ignore: cast_nullable_to_non_nullable - as List?, - followers: freezed == followers - ? _value.followers - : followers // ignore: cast_nullable_to_non_nullable - as int?, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$SpotubeFullArtistObjectImplCopyWith<$Res> - implements $SpotubeFullArtistObjectCopyWith<$Res> { - factory _$$SpotubeFullArtistObjectImplCopyWith( - _$SpotubeFullArtistObjectImpl value, - $Res Function(_$SpotubeFullArtistObjectImpl) then) = - __$$SpotubeFullArtistObjectImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String id, - String name, - String externalUri, - List images, - List? genres, - int? followers}); -} - -/// @nodoc -class __$$SpotubeFullArtistObjectImplCopyWithImpl<$Res> - extends _$SpotubeFullArtistObjectCopyWithImpl<$Res, - _$SpotubeFullArtistObjectImpl> - implements _$$SpotubeFullArtistObjectImplCopyWith<$Res> { - __$$SpotubeFullArtistObjectImplCopyWithImpl( - _$SpotubeFullArtistObjectImpl _value, - $Res Function(_$SpotubeFullArtistObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeFullArtistObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? externalUri = null, - Object? images = null, - Object? genres = freezed, - Object? followers = freezed, - }) { - return _then(_$SpotubeFullArtistObjectImpl( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - images: null == images - ? _value._images - : images // ignore: cast_nullable_to_non_nullable - as List, - genres: freezed == genres - ? _value._genres - : genres // ignore: cast_nullable_to_non_nullable - as List?, - followers: freezed == followers - ? _value.followers - : followers // ignore: cast_nullable_to_non_nullable - as int?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeFullArtistObjectImpl implements _SpotubeFullArtistObject { - _$SpotubeFullArtistObjectImpl( - {required this.id, - required this.name, - required this.externalUri, - final List images = const [], - final List? genres, - this.followers}) - : _images = images, - _genres = genres; - - factory _$SpotubeFullArtistObjectImpl.fromJson(Map json) => - _$$SpotubeFullArtistObjectImplFromJson(json); - - @override - final String id; - @override - final String name; - @override - final String externalUri; - final List _images; - @override - @JsonKey() - List get images { - if (_images is EqualUnmodifiableListView) return _images; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_images); - } - - final List? _genres; - @override - List? get genres { - final value = _genres; - if (value == null) return null; - if (_genres is EqualUnmodifiableListView) return _genres; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(value); - } - - @override - final int? followers; - - @override - String toString() { - return 'SpotubeFullArtistObject(id: $id, name: $name, externalUri: $externalUri, images: $images, genres: $genres, followers: $followers)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeFullArtistObjectImpl && - (identical(other.id, id) || other.id == id) && - (identical(other.name, name) || other.name == name) && - (identical(other.externalUri, externalUri) || - other.externalUri == externalUri) && - const DeepCollectionEquality().equals(other._images, _images) && - const DeepCollectionEquality().equals(other._genres, _genres) && - (identical(other.followers, followers) || - other.followers == followers)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash( - runtimeType, - id, - name, - externalUri, - const DeepCollectionEquality().hash(_images), - const DeepCollectionEquality().hash(_genres), - followers); - - /// Create a copy of SpotubeFullArtistObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeFullArtistObjectImplCopyWith<_$SpotubeFullArtistObjectImpl> - get copyWith => __$$SpotubeFullArtistObjectImplCopyWithImpl< - _$SpotubeFullArtistObjectImpl>(this, _$identity); - - @override - Map toJson() { - return _$$SpotubeFullArtistObjectImplToJson( - this, - ); - } -} - -abstract class _SpotubeFullArtistObject implements SpotubeFullArtistObject { - factory _SpotubeFullArtistObject( - {required final String id, - required final String name, - required final String externalUri, - final List images, - final List? genres, - final int? followers}) = _$SpotubeFullArtistObjectImpl; - - factory _SpotubeFullArtistObject.fromJson(Map json) = - _$SpotubeFullArtistObjectImpl.fromJson; - - @override - String get id; - @override - String get name; - @override - String get externalUri; - @override - List get images; - @override - List? get genres; - @override - int? get followers; - - /// Create a copy of SpotubeFullArtistObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeFullArtistObjectImplCopyWith<_$SpotubeFullArtistObjectImpl> - get copyWith => throw _privateConstructorUsedError; -} - -SpotubeSimpleArtistObject _$SpotubeSimpleArtistObjectFromJson( - Map json) { - return _SpotubeSimpleArtistObject.fromJson(json); -} - -/// @nodoc -mixin _$SpotubeSimpleArtistObject { - String get id => throw _privateConstructorUsedError; - String get name => throw _privateConstructorUsedError; - String get externalUri => throw _privateConstructorUsedError; - List? get images => throw _privateConstructorUsedError; - - /// Serializes this SpotubeSimpleArtistObject to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SpotubeSimpleArtistObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeSimpleArtistObjectCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeSimpleArtistObjectCopyWith<$Res> { - factory $SpotubeSimpleArtistObjectCopyWith(SpotubeSimpleArtistObject value, - $Res Function(SpotubeSimpleArtistObject) then) = - _$SpotubeSimpleArtistObjectCopyWithImpl<$Res, SpotubeSimpleArtistObject>; - @useResult - $Res call( - {String id, - String name, - String externalUri, - List? images}); -} - -/// @nodoc -class _$SpotubeSimpleArtistObjectCopyWithImpl<$Res, - $Val extends SpotubeSimpleArtistObject> - implements $SpotubeSimpleArtistObjectCopyWith<$Res> { - _$SpotubeSimpleArtistObjectCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeSimpleArtistObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? externalUri = null, - Object? images = freezed, - }) { - return _then(_value.copyWith( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - images: freezed == images - ? _value.images - : images // ignore: cast_nullable_to_non_nullable - as List?, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$SpotubeSimpleArtistObjectImplCopyWith<$Res> - implements $SpotubeSimpleArtistObjectCopyWith<$Res> { - factory _$$SpotubeSimpleArtistObjectImplCopyWith( - _$SpotubeSimpleArtistObjectImpl value, - $Res Function(_$SpotubeSimpleArtistObjectImpl) then) = - __$$SpotubeSimpleArtistObjectImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String id, - String name, - String externalUri, - List? images}); -} - -/// @nodoc -class __$$SpotubeSimpleArtistObjectImplCopyWithImpl<$Res> - extends _$SpotubeSimpleArtistObjectCopyWithImpl<$Res, - _$SpotubeSimpleArtistObjectImpl> - implements _$$SpotubeSimpleArtistObjectImplCopyWith<$Res> { - __$$SpotubeSimpleArtistObjectImplCopyWithImpl( - _$SpotubeSimpleArtistObjectImpl _value, - $Res Function(_$SpotubeSimpleArtistObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeSimpleArtistObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? externalUri = null, - Object? images = freezed, - }) { - return _then(_$SpotubeSimpleArtistObjectImpl( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - images: freezed == images - ? _value._images - : images // ignore: cast_nullable_to_non_nullable - as List?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeSimpleArtistObjectImpl implements _SpotubeSimpleArtistObject { - _$SpotubeSimpleArtistObjectImpl( - {required this.id, - required this.name, - required this.externalUri, - final List? images}) - : _images = images; - - factory _$SpotubeSimpleArtistObjectImpl.fromJson(Map json) => - _$$SpotubeSimpleArtistObjectImplFromJson(json); - - @override - final String id; - @override - final String name; - @override - final String externalUri; - final List? _images; - @override - List? get images { - final value = _images; - if (value == null) return null; - if (_images is EqualUnmodifiableListView) return _images; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(value); - } - - @override - String toString() { - return 'SpotubeSimpleArtistObject(id: $id, name: $name, externalUri: $externalUri, images: $images)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeSimpleArtistObjectImpl && - (identical(other.id, id) || other.id == id) && - (identical(other.name, name) || other.name == name) && - (identical(other.externalUri, externalUri) || - other.externalUri == externalUri) && - const DeepCollectionEquality().equals(other._images, _images)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, id, name, externalUri, - const DeepCollectionEquality().hash(_images)); - - /// Create a copy of SpotubeSimpleArtistObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeSimpleArtistObjectImplCopyWith<_$SpotubeSimpleArtistObjectImpl> - get copyWith => __$$SpotubeSimpleArtistObjectImplCopyWithImpl< - _$SpotubeSimpleArtistObjectImpl>(this, _$identity); - - @override - Map toJson() { - return _$$SpotubeSimpleArtistObjectImplToJson( - this, - ); - } -} - -abstract class _SpotubeSimpleArtistObject implements SpotubeSimpleArtistObject { - factory _SpotubeSimpleArtistObject( - {required final String id, - required final String name, - required final String externalUri, - final List? images}) = - _$SpotubeSimpleArtistObjectImpl; - - factory _SpotubeSimpleArtistObject.fromJson(Map json) = - _$SpotubeSimpleArtistObjectImpl.fromJson; - - @override - String get id; - @override - String get name; - @override - String get externalUri; - @override - List? get images; - - /// Create a copy of SpotubeSimpleArtistObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeSimpleArtistObjectImplCopyWith<_$SpotubeSimpleArtistObjectImpl> - get copyWith => throw _privateConstructorUsedError; -} - -SpotubeBrowseSectionObject _$SpotubeBrowseSectionObjectFromJson( - Map json, T Function(Object?) fromJsonT) { - return _SpotubeBrowseSectionObject.fromJson(json, fromJsonT); -} - -/// @nodoc -mixin _$SpotubeBrowseSectionObject { - String get id => throw _privateConstructorUsedError; - String get title => throw _privateConstructorUsedError; - String get externalUri => throw _privateConstructorUsedError; - bool get browseMore => throw _privateConstructorUsedError; - List get items => throw _privateConstructorUsedError; - - /// Serializes this SpotubeBrowseSectionObject to a JSON map. - Map toJson(Object? Function(T) toJsonT) => - throw _privateConstructorUsedError; - - /// Create a copy of SpotubeBrowseSectionObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeBrowseSectionObjectCopyWith> - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeBrowseSectionObjectCopyWith { - factory $SpotubeBrowseSectionObjectCopyWith( - SpotubeBrowseSectionObject value, - $Res Function(SpotubeBrowseSectionObject) then) = - _$SpotubeBrowseSectionObjectCopyWithImpl>; - @useResult - $Res call( - {String id, - String title, - String externalUri, - bool browseMore, - List items}); -} - -/// @nodoc -class _$SpotubeBrowseSectionObjectCopyWithImpl> - implements $SpotubeBrowseSectionObjectCopyWith { - _$SpotubeBrowseSectionObjectCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeBrowseSectionObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? title = null, - Object? externalUri = null, - Object? browseMore = null, - Object? items = null, - }) { - return _then(_value.copyWith( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - title: null == title - ? _value.title - : title // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - browseMore: null == browseMore - ? _value.browseMore - : browseMore // ignore: cast_nullable_to_non_nullable - as bool, - items: null == items - ? _value.items - : items // ignore: cast_nullable_to_non_nullable - as List, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$SpotubeBrowseSectionObjectImplCopyWith - implements $SpotubeBrowseSectionObjectCopyWith { - factory _$$SpotubeBrowseSectionObjectImplCopyWith( - _$SpotubeBrowseSectionObjectImpl value, - $Res Function(_$SpotubeBrowseSectionObjectImpl) then) = - __$$SpotubeBrowseSectionObjectImplCopyWithImpl; - @override - @useResult - $Res call( - {String id, - String title, - String externalUri, - bool browseMore, - List items}); -} - -/// @nodoc -class __$$SpotubeBrowseSectionObjectImplCopyWithImpl - extends _$SpotubeBrowseSectionObjectCopyWithImpl> - implements _$$SpotubeBrowseSectionObjectImplCopyWith { - __$$SpotubeBrowseSectionObjectImplCopyWithImpl( - _$SpotubeBrowseSectionObjectImpl _value, - $Res Function(_$SpotubeBrowseSectionObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeBrowseSectionObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? title = null, - Object? externalUri = null, - Object? browseMore = null, - Object? items = null, - }) { - return _then(_$SpotubeBrowseSectionObjectImpl( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - title: null == title - ? _value.title - : title // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - browseMore: null == browseMore - ? _value.browseMore - : browseMore // ignore: cast_nullable_to_non_nullable - as bool, - items: null == items - ? _value._items - : items // ignore: cast_nullable_to_non_nullable - as List, - )); - } -} - -/// @nodoc -@JsonSerializable(genericArgumentFactories: true) -class _$SpotubeBrowseSectionObjectImpl - implements _SpotubeBrowseSectionObject { - _$SpotubeBrowseSectionObjectImpl( - {required this.id, - required this.title, - required this.externalUri, - required this.browseMore, - required final List items}) - : _items = items; - - factory _$SpotubeBrowseSectionObjectImpl.fromJson( - Map json, T Function(Object?) fromJsonT) => - _$$SpotubeBrowseSectionObjectImplFromJson(json, fromJsonT); - - @override - final String id; - @override - final String title; - @override - final String externalUri; - @override - final bool browseMore; - final List _items; - @override - List get items { - if (_items is EqualUnmodifiableListView) return _items; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_items); - } - - @override - String toString() { - return 'SpotubeBrowseSectionObject<$T>(id: $id, title: $title, externalUri: $externalUri, browseMore: $browseMore, items: $items)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeBrowseSectionObjectImpl && - (identical(other.id, id) || other.id == id) && - (identical(other.title, title) || other.title == title) && - (identical(other.externalUri, externalUri) || - other.externalUri == externalUri) && - (identical(other.browseMore, browseMore) || - other.browseMore == browseMore) && - const DeepCollectionEquality().equals(other._items, _items)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, id, title, externalUri, - browseMore, const DeepCollectionEquality().hash(_items)); - - /// Create a copy of SpotubeBrowseSectionObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeBrowseSectionObjectImplCopyWith> - get copyWith => __$$SpotubeBrowseSectionObjectImplCopyWithImpl>(this, _$identity); - - @override - Map toJson(Object? Function(T) toJsonT) { - return _$$SpotubeBrowseSectionObjectImplToJson(this, toJsonT); - } -} - -abstract class _SpotubeBrowseSectionObject - implements SpotubeBrowseSectionObject { - factory _SpotubeBrowseSectionObject( - {required final String id, - required final String title, - required final String externalUri, - required final bool browseMore, - required final List items}) = _$SpotubeBrowseSectionObjectImpl; - - factory _SpotubeBrowseSectionObject.fromJson( - Map json, T Function(Object?) fromJsonT) = - _$SpotubeBrowseSectionObjectImpl.fromJson; - - @override - String get id; - @override - String get title; - @override - String get externalUri; - @override - bool get browseMore; - @override - List get items; - - /// Create a copy of SpotubeBrowseSectionObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeBrowseSectionObjectImplCopyWith> - get copyWith => throw _privateConstructorUsedError; -} - -MetadataFormFieldObject _$MetadataFormFieldObjectFromJson( - Map json) { - switch (json['objectType']) { - case 'input': - return MetadataFormFieldInputObject.fromJson(json); - case 'text': - return MetadataFormFieldTextObject.fromJson(json); - - default: - throw CheckedFromJsonException( - json, - 'objectType', - 'MetadataFormFieldObject', - 'Invalid union type "${json['objectType']}"!'); - } -} - -/// @nodoc -mixin _$MetadataFormFieldObject { - String get objectType => throw _privateConstructorUsedError; - @optionalTypeArgs - TResult when({ - required TResult Function( - String objectType, - String id, - FormFieldVariant variant, - String? placeholder, - String? defaultValue, - bool? required, - String? regex) - input, - required TResult Function(String objectType, String text) text, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function( - String objectType, - String id, - FormFieldVariant variant, - String? placeholder, - String? defaultValue, - bool? required, - String? regex)? - input, - TResult? Function(String objectType, String text)? text, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeWhen({ - TResult Function( - String objectType, - String id, - FormFieldVariant variant, - String? placeholder, - String? defaultValue, - bool? required, - String? regex)? - input, - TResult Function(String objectType, String text)? text, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult map({ - required TResult Function(MetadataFormFieldInputObject value) input, - required TResult Function(MetadataFormFieldTextObject value) text, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(MetadataFormFieldInputObject value)? input, - TResult? Function(MetadataFormFieldTextObject value)? text, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeMap({ - TResult Function(MetadataFormFieldInputObject value)? input, - TResult Function(MetadataFormFieldTextObject value)? text, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - - /// Serializes this MetadataFormFieldObject to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of MetadataFormFieldObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $MetadataFormFieldObjectCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $MetadataFormFieldObjectCopyWith<$Res> { - factory $MetadataFormFieldObjectCopyWith(MetadataFormFieldObject value, - $Res Function(MetadataFormFieldObject) then) = - _$MetadataFormFieldObjectCopyWithImpl<$Res, MetadataFormFieldObject>; - @useResult - $Res call({String objectType}); -} - -/// @nodoc -class _$MetadataFormFieldObjectCopyWithImpl<$Res, - $Val extends MetadataFormFieldObject> - implements $MetadataFormFieldObjectCopyWith<$Res> { - _$MetadataFormFieldObjectCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of MetadataFormFieldObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? objectType = null, - }) { - return _then(_value.copyWith( - objectType: null == objectType - ? _value.objectType - : objectType // ignore: cast_nullable_to_non_nullable - as String, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$MetadataFormFieldInputObjectImplCopyWith<$Res> - implements $MetadataFormFieldObjectCopyWith<$Res> { - factory _$$MetadataFormFieldInputObjectImplCopyWith( - _$MetadataFormFieldInputObjectImpl value, - $Res Function(_$MetadataFormFieldInputObjectImpl) then) = - __$$MetadataFormFieldInputObjectImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String objectType, - String id, - FormFieldVariant variant, - String? placeholder, - String? defaultValue, - bool? required, - String? regex}); -} - -/// @nodoc -class __$$MetadataFormFieldInputObjectImplCopyWithImpl<$Res> - extends _$MetadataFormFieldObjectCopyWithImpl<$Res, - _$MetadataFormFieldInputObjectImpl> - implements _$$MetadataFormFieldInputObjectImplCopyWith<$Res> { - __$$MetadataFormFieldInputObjectImplCopyWithImpl( - _$MetadataFormFieldInputObjectImpl _value, - $Res Function(_$MetadataFormFieldInputObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of MetadataFormFieldObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? objectType = null, - Object? id = null, - Object? variant = null, - Object? placeholder = freezed, - Object? defaultValue = freezed, - Object? required = freezed, - Object? regex = freezed, - }) { - return _then(_$MetadataFormFieldInputObjectImpl( - objectType: null == objectType - ? _value.objectType - : objectType // ignore: cast_nullable_to_non_nullable - as String, - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - variant: null == variant - ? _value.variant - : variant // ignore: cast_nullable_to_non_nullable - as FormFieldVariant, - placeholder: freezed == placeholder - ? _value.placeholder - : placeholder // ignore: cast_nullable_to_non_nullable - as String?, - defaultValue: freezed == defaultValue - ? _value.defaultValue - : defaultValue // ignore: cast_nullable_to_non_nullable - as String?, - required: freezed == required - ? _value.required - : required // ignore: cast_nullable_to_non_nullable - as bool?, - regex: freezed == regex - ? _value.regex - : regex // ignore: cast_nullable_to_non_nullable - as String?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$MetadataFormFieldInputObjectImpl - implements MetadataFormFieldInputObject { - _$MetadataFormFieldInputObjectImpl( - {required this.objectType, - required this.id, - this.variant = FormFieldVariant.text, - this.placeholder, - this.defaultValue, - this.required, - this.regex}); - - factory _$MetadataFormFieldInputObjectImpl.fromJson( - Map json) => - _$$MetadataFormFieldInputObjectImplFromJson(json); - - @override - final String objectType; - @override - final String id; - @override - @JsonKey() - final FormFieldVariant variant; - @override - final String? placeholder; - @override - final String? defaultValue; - @override - final bool? required; - @override - final String? regex; - - @override - String toString() { - return 'MetadataFormFieldObject.input(objectType: $objectType, id: $id, variant: $variant, placeholder: $placeholder, defaultValue: $defaultValue, required: $required, regex: $regex)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$MetadataFormFieldInputObjectImpl && - (identical(other.objectType, objectType) || - other.objectType == objectType) && - (identical(other.id, id) || other.id == id) && - (identical(other.variant, variant) || other.variant == variant) && - (identical(other.placeholder, placeholder) || - other.placeholder == placeholder) && - (identical(other.defaultValue, defaultValue) || - other.defaultValue == defaultValue) && - (identical(other.required, required) || - other.required == required) && - (identical(other.regex, regex) || other.regex == regex)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, objectType, id, variant, - placeholder, defaultValue, required, regex); - - /// Create a copy of MetadataFormFieldObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$MetadataFormFieldInputObjectImplCopyWith< - _$MetadataFormFieldInputObjectImpl> - get copyWith => __$$MetadataFormFieldInputObjectImplCopyWithImpl< - _$MetadataFormFieldInputObjectImpl>(this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function( - String objectType, - String id, - FormFieldVariant variant, - String? placeholder, - String? defaultValue, - bool? required, - String? regex) - input, - required TResult Function(String objectType, String text) text, - }) { - return input( - objectType, id, variant, placeholder, defaultValue, required, regex); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function( - String objectType, - String id, - FormFieldVariant variant, - String? placeholder, - String? defaultValue, - bool? required, - String? regex)? - input, - TResult? Function(String objectType, String text)? text, - }) { - return input?.call( - objectType, id, variant, placeholder, defaultValue, required, regex); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function( - String objectType, - String id, - FormFieldVariant variant, - String? placeholder, - String? defaultValue, - bool? required, - String? regex)? - input, - TResult Function(String objectType, String text)? text, - required TResult orElse(), - }) { - if (input != null) { - return input( - objectType, id, variant, placeholder, defaultValue, required, regex); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(MetadataFormFieldInputObject value) input, - required TResult Function(MetadataFormFieldTextObject value) text, - }) { - return input(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(MetadataFormFieldInputObject value)? input, - TResult? Function(MetadataFormFieldTextObject value)? text, - }) { - return input?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(MetadataFormFieldInputObject value)? input, - TResult Function(MetadataFormFieldTextObject value)? text, - required TResult orElse(), - }) { - if (input != null) { - return input(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$MetadataFormFieldInputObjectImplToJson( - this, - ); - } -} - -abstract class MetadataFormFieldInputObject implements MetadataFormFieldObject { - factory MetadataFormFieldInputObject( - {required final String objectType, - required final String id, - final FormFieldVariant variant, - final String? placeholder, - final String? defaultValue, - final bool? required, - final String? regex}) = _$MetadataFormFieldInputObjectImpl; - - factory MetadataFormFieldInputObject.fromJson(Map json) = - _$MetadataFormFieldInputObjectImpl.fromJson; - - @override - String get objectType; - String get id; - FormFieldVariant get variant; - String? get placeholder; - String? get defaultValue; - bool? get required; - String? get regex; - - /// Create a copy of MetadataFormFieldObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$MetadataFormFieldInputObjectImplCopyWith< - _$MetadataFormFieldInputObjectImpl> - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class _$$MetadataFormFieldTextObjectImplCopyWith<$Res> - implements $MetadataFormFieldObjectCopyWith<$Res> { - factory _$$MetadataFormFieldTextObjectImplCopyWith( - _$MetadataFormFieldTextObjectImpl value, - $Res Function(_$MetadataFormFieldTextObjectImpl) then) = - __$$MetadataFormFieldTextObjectImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({String objectType, String text}); -} - -/// @nodoc -class __$$MetadataFormFieldTextObjectImplCopyWithImpl<$Res> - extends _$MetadataFormFieldObjectCopyWithImpl<$Res, - _$MetadataFormFieldTextObjectImpl> - implements _$$MetadataFormFieldTextObjectImplCopyWith<$Res> { - __$$MetadataFormFieldTextObjectImplCopyWithImpl( - _$MetadataFormFieldTextObjectImpl _value, - $Res Function(_$MetadataFormFieldTextObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of MetadataFormFieldObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? objectType = null, - Object? text = null, - }) { - return _then(_$MetadataFormFieldTextObjectImpl( - objectType: null == objectType - ? _value.objectType - : objectType // ignore: cast_nullable_to_non_nullable - as String, - text: null == text - ? _value.text - : text // ignore: cast_nullable_to_non_nullable - as String, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$MetadataFormFieldTextObjectImpl implements MetadataFormFieldTextObject { - _$MetadataFormFieldTextObjectImpl( - {required this.objectType, required this.text}); - - factory _$MetadataFormFieldTextObjectImpl.fromJson( - Map json) => - _$$MetadataFormFieldTextObjectImplFromJson(json); - - @override - final String objectType; - @override - final String text; - - @override - String toString() { - return 'MetadataFormFieldObject.text(objectType: $objectType, text: $text)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$MetadataFormFieldTextObjectImpl && - (identical(other.objectType, objectType) || - other.objectType == objectType) && - (identical(other.text, text) || other.text == text)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, objectType, text); - - /// Create a copy of MetadataFormFieldObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$MetadataFormFieldTextObjectImplCopyWith<_$MetadataFormFieldTextObjectImpl> - get copyWith => __$$MetadataFormFieldTextObjectImplCopyWithImpl< - _$MetadataFormFieldTextObjectImpl>(this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function( - String objectType, - String id, - FormFieldVariant variant, - String? placeholder, - String? defaultValue, - bool? required, - String? regex) - input, - required TResult Function(String objectType, String text) text, - }) { - return text(objectType, this.text); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function( - String objectType, - String id, - FormFieldVariant variant, - String? placeholder, - String? defaultValue, - bool? required, - String? regex)? - input, - TResult? Function(String objectType, String text)? text, - }) { - return text?.call(objectType, this.text); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function( - String objectType, - String id, - FormFieldVariant variant, - String? placeholder, - String? defaultValue, - bool? required, - String? regex)? - input, - TResult Function(String objectType, String text)? text, - required TResult orElse(), - }) { - if (text != null) { - return text(objectType, this.text); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(MetadataFormFieldInputObject value) input, - required TResult Function(MetadataFormFieldTextObject value) text, - }) { - return text(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(MetadataFormFieldInputObject value)? input, - TResult? Function(MetadataFormFieldTextObject value)? text, - }) { - return text?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(MetadataFormFieldInputObject value)? input, - TResult Function(MetadataFormFieldTextObject value)? text, - required TResult orElse(), - }) { - if (text != null) { - return text(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$MetadataFormFieldTextObjectImplToJson( - this, - ); - } -} - -abstract class MetadataFormFieldTextObject implements MetadataFormFieldObject { - factory MetadataFormFieldTextObject( - {required final String objectType, - required final String text}) = _$MetadataFormFieldTextObjectImpl; - - factory MetadataFormFieldTextObject.fromJson(Map json) = - _$MetadataFormFieldTextObjectImpl.fromJson; - - @override - String get objectType; - String get text; - - /// Create a copy of MetadataFormFieldObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$MetadataFormFieldTextObjectImplCopyWith<_$MetadataFormFieldTextObjectImpl> - get copyWith => throw _privateConstructorUsedError; -} - -SpotubeImageObject _$SpotubeImageObjectFromJson(Map json) { - return _SpotubeImageObject.fromJson(json); -} - -/// @nodoc -mixin _$SpotubeImageObject { - String get url => throw _privateConstructorUsedError; - int? get width => throw _privateConstructorUsedError; - int? get height => throw _privateConstructorUsedError; - - /// Serializes this SpotubeImageObject to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SpotubeImageObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeImageObjectCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeImageObjectCopyWith<$Res> { - factory $SpotubeImageObjectCopyWith( - SpotubeImageObject value, $Res Function(SpotubeImageObject) then) = - _$SpotubeImageObjectCopyWithImpl<$Res, SpotubeImageObject>; - @useResult - $Res call({String url, int? width, int? height}); -} - -/// @nodoc -class _$SpotubeImageObjectCopyWithImpl<$Res, $Val extends SpotubeImageObject> - implements $SpotubeImageObjectCopyWith<$Res> { - _$SpotubeImageObjectCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeImageObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? url = null, - Object? width = freezed, - Object? height = freezed, - }) { - return _then(_value.copyWith( - url: null == url - ? _value.url - : url // ignore: cast_nullable_to_non_nullable - as String, - width: freezed == width - ? _value.width - : width // ignore: cast_nullable_to_non_nullable - as int?, - height: freezed == height - ? _value.height - : height // ignore: cast_nullable_to_non_nullable - as int?, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$SpotubeImageObjectImplCopyWith<$Res> - implements $SpotubeImageObjectCopyWith<$Res> { - factory _$$SpotubeImageObjectImplCopyWith(_$SpotubeImageObjectImpl value, - $Res Function(_$SpotubeImageObjectImpl) then) = - __$$SpotubeImageObjectImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({String url, int? width, int? height}); -} - -/// @nodoc -class __$$SpotubeImageObjectImplCopyWithImpl<$Res> - extends _$SpotubeImageObjectCopyWithImpl<$Res, _$SpotubeImageObjectImpl> - implements _$$SpotubeImageObjectImplCopyWith<$Res> { - __$$SpotubeImageObjectImplCopyWithImpl(_$SpotubeImageObjectImpl _value, - $Res Function(_$SpotubeImageObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeImageObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? url = null, - Object? width = freezed, - Object? height = freezed, - }) { - return _then(_$SpotubeImageObjectImpl( - url: null == url - ? _value.url - : url // ignore: cast_nullable_to_non_nullable - as String, - width: freezed == width - ? _value.width - : width // ignore: cast_nullable_to_non_nullable - as int?, - height: freezed == height - ? _value.height - : height // ignore: cast_nullable_to_non_nullable - as int?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeImageObjectImpl implements _SpotubeImageObject { - _$SpotubeImageObjectImpl({required this.url, this.width, this.height}); - - factory _$SpotubeImageObjectImpl.fromJson(Map json) => - _$$SpotubeImageObjectImplFromJson(json); - - @override - final String url; - @override - final int? width; - @override - final int? height; - - @override - String toString() { - return 'SpotubeImageObject(url: $url, width: $width, height: $height)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeImageObjectImpl && - (identical(other.url, url) || other.url == url) && - (identical(other.width, width) || other.width == width) && - (identical(other.height, height) || other.height == height)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, url, width, height); - - /// Create a copy of SpotubeImageObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeImageObjectImplCopyWith<_$SpotubeImageObjectImpl> get copyWith => - __$$SpotubeImageObjectImplCopyWithImpl<_$SpotubeImageObjectImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$SpotubeImageObjectImplToJson( - this, - ); - } -} - -abstract class _SpotubeImageObject implements SpotubeImageObject { - factory _SpotubeImageObject( - {required final String url, - final int? width, - final int? height}) = _$SpotubeImageObjectImpl; - - factory _SpotubeImageObject.fromJson(Map json) = - _$SpotubeImageObjectImpl.fromJson; - - @override - String get url; - @override - int? get width; - @override - int? get height; - - /// Create a copy of SpotubeImageObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeImageObjectImplCopyWith<_$SpotubeImageObjectImpl> get copyWith => - throw _privateConstructorUsedError; -} - -SpotubePaginationResponseObject _$SpotubePaginationResponseObjectFromJson( - Map json, T Function(Object?) fromJsonT) { - return _SpotubePaginationResponseObject.fromJson(json, fromJsonT); -} - -/// @nodoc -mixin _$SpotubePaginationResponseObject { - int get limit => throw _privateConstructorUsedError; - int? get nextOffset => throw _privateConstructorUsedError; - int get total => throw _privateConstructorUsedError; - bool get hasMore => throw _privateConstructorUsedError; - List get items => throw _privateConstructorUsedError; - - /// Serializes this SpotubePaginationResponseObject to a JSON map. - Map toJson(Object? Function(T) toJsonT) => - throw _privateConstructorUsedError; - - /// Create a copy of SpotubePaginationResponseObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubePaginationResponseObjectCopyWith> - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubePaginationResponseObjectCopyWith { - factory $SpotubePaginationResponseObjectCopyWith( - SpotubePaginationResponseObject value, - $Res Function(SpotubePaginationResponseObject) then) = - _$SpotubePaginationResponseObjectCopyWithImpl>; - @useResult - $Res call( - {int limit, int? nextOffset, int total, bool hasMore, List items}); -} - -/// @nodoc -class _$SpotubePaginationResponseObjectCopyWithImpl> - implements $SpotubePaginationResponseObjectCopyWith { - _$SpotubePaginationResponseObjectCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubePaginationResponseObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? limit = null, - Object? nextOffset = freezed, - Object? total = null, - Object? hasMore = null, - Object? items = null, - }) { - return _then(_value.copyWith( - limit: null == limit - ? _value.limit - : limit // ignore: cast_nullable_to_non_nullable - as int, - nextOffset: freezed == nextOffset - ? _value.nextOffset - : nextOffset // ignore: cast_nullable_to_non_nullable - as int?, - total: null == total - ? _value.total - : total // ignore: cast_nullable_to_non_nullable - as int, - hasMore: null == hasMore - ? _value.hasMore - : hasMore // ignore: cast_nullable_to_non_nullable - as bool, - items: null == items - ? _value.items - : items // ignore: cast_nullable_to_non_nullable - as List, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$SpotubePaginationResponseObjectImplCopyWith - implements $SpotubePaginationResponseObjectCopyWith { - factory _$$SpotubePaginationResponseObjectImplCopyWith( - _$SpotubePaginationResponseObjectImpl value, - $Res Function(_$SpotubePaginationResponseObjectImpl) then) = - __$$SpotubePaginationResponseObjectImplCopyWithImpl; - @override - @useResult - $Res call( - {int limit, int? nextOffset, int total, bool hasMore, List items}); -} - -/// @nodoc -class __$$SpotubePaginationResponseObjectImplCopyWithImpl - extends _$SpotubePaginationResponseObjectCopyWithImpl> - implements _$$SpotubePaginationResponseObjectImplCopyWith { - __$$SpotubePaginationResponseObjectImplCopyWithImpl( - _$SpotubePaginationResponseObjectImpl _value, - $Res Function(_$SpotubePaginationResponseObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubePaginationResponseObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? limit = null, - Object? nextOffset = freezed, - Object? total = null, - Object? hasMore = null, - Object? items = null, - }) { - return _then(_$SpotubePaginationResponseObjectImpl( - limit: null == limit - ? _value.limit - : limit // ignore: cast_nullable_to_non_nullable - as int, - nextOffset: freezed == nextOffset - ? _value.nextOffset - : nextOffset // ignore: cast_nullable_to_non_nullable - as int?, - total: null == total - ? _value.total - : total // ignore: cast_nullable_to_non_nullable - as int, - hasMore: null == hasMore - ? _value.hasMore - : hasMore // ignore: cast_nullable_to_non_nullable - as bool, - items: null == items - ? _value._items - : items // ignore: cast_nullable_to_non_nullable - as List, - )); - } -} - -/// @nodoc -@JsonSerializable(genericArgumentFactories: true) -class _$SpotubePaginationResponseObjectImpl - implements _SpotubePaginationResponseObject { - _$SpotubePaginationResponseObjectImpl( - {required this.limit, - required this.nextOffset, - required this.total, - required this.hasMore, - required final List items}) - : _items = items; - - factory _$SpotubePaginationResponseObjectImpl.fromJson( - Map json, T Function(Object?) fromJsonT) => - _$$SpotubePaginationResponseObjectImplFromJson(json, fromJsonT); - - @override - final int limit; - @override - final int? nextOffset; - @override - final int total; - @override - final bool hasMore; - final List _items; - @override - List get items { - if (_items is EqualUnmodifiableListView) return _items; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_items); - } - - @override - String toString() { - return 'SpotubePaginationResponseObject<$T>(limit: $limit, nextOffset: $nextOffset, total: $total, hasMore: $hasMore, items: $items)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubePaginationResponseObjectImpl && - (identical(other.limit, limit) || other.limit == limit) && - (identical(other.nextOffset, nextOffset) || - other.nextOffset == nextOffset) && - (identical(other.total, total) || other.total == total) && - (identical(other.hasMore, hasMore) || other.hasMore == hasMore) && - const DeepCollectionEquality().equals(other._items, _items)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, limit, nextOffset, total, - hasMore, const DeepCollectionEquality().hash(_items)); - - /// Create a copy of SpotubePaginationResponseObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubePaginationResponseObjectImplCopyWith> - get copyWith => __$$SpotubePaginationResponseObjectImplCopyWithImpl>(this, _$identity); - - @override - Map toJson(Object? Function(T) toJsonT) { - return _$$SpotubePaginationResponseObjectImplToJson(this, toJsonT); - } -} - -abstract class _SpotubePaginationResponseObject - implements SpotubePaginationResponseObject { - factory _SpotubePaginationResponseObject( - {required final int limit, - required final int? nextOffset, - required final int total, - required final bool hasMore, - required final List items}) = _$SpotubePaginationResponseObjectImpl; - - factory _SpotubePaginationResponseObject.fromJson( - Map json, T Function(Object?) fromJsonT) = - _$SpotubePaginationResponseObjectImpl.fromJson; - - @override - int get limit; - @override - int? get nextOffset; - @override - int get total; - @override - bool get hasMore; - @override - List get items; - - /// Create a copy of SpotubePaginationResponseObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubePaginationResponseObjectImplCopyWith> - get copyWith => throw _privateConstructorUsedError; -} - -SpotubeFullPlaylistObject _$SpotubeFullPlaylistObjectFromJson( - Map json) { - return _SpotubeFullPlaylistObject.fromJson(json); -} - -/// @nodoc -mixin _$SpotubeFullPlaylistObject { - String get id => throw _privateConstructorUsedError; - String get name => throw _privateConstructorUsedError; - String get description => throw _privateConstructorUsedError; - String get externalUri => throw _privateConstructorUsedError; - SpotubeUserObject get owner => throw _privateConstructorUsedError; - List get images => throw _privateConstructorUsedError; - List get collaborators => - throw _privateConstructorUsedError; - bool get collaborative => throw _privateConstructorUsedError; - bool get public => throw _privateConstructorUsedError; - - /// Serializes this SpotubeFullPlaylistObject to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SpotubeFullPlaylistObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeFullPlaylistObjectCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeFullPlaylistObjectCopyWith<$Res> { - factory $SpotubeFullPlaylistObjectCopyWith(SpotubeFullPlaylistObject value, - $Res Function(SpotubeFullPlaylistObject) then) = - _$SpotubeFullPlaylistObjectCopyWithImpl<$Res, SpotubeFullPlaylistObject>; - @useResult - $Res call( - {String id, - String name, - String description, - String externalUri, - SpotubeUserObject owner, - List images, - List collaborators, - bool collaborative, - bool public}); - - $SpotubeUserObjectCopyWith<$Res> get owner; -} - -/// @nodoc -class _$SpotubeFullPlaylistObjectCopyWithImpl<$Res, - $Val extends SpotubeFullPlaylistObject> - implements $SpotubeFullPlaylistObjectCopyWith<$Res> { - _$SpotubeFullPlaylistObjectCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeFullPlaylistObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? description = null, - Object? externalUri = null, - Object? owner = null, - Object? images = null, - Object? collaborators = null, - Object? collaborative = null, - Object? public = null, - }) { - return _then(_value.copyWith( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - description: null == description - ? _value.description - : description // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - owner: null == owner - ? _value.owner - : owner // ignore: cast_nullable_to_non_nullable - as SpotubeUserObject, - images: null == images - ? _value.images - : images // ignore: cast_nullable_to_non_nullable - as List, - collaborators: null == collaborators - ? _value.collaborators - : collaborators // ignore: cast_nullable_to_non_nullable - as List, - collaborative: null == collaborative - ? _value.collaborative - : collaborative // ignore: cast_nullable_to_non_nullable - as bool, - public: null == public - ? _value.public - : public // ignore: cast_nullable_to_non_nullable - as bool, - ) as $Val); - } - - /// Create a copy of SpotubeFullPlaylistObject - /// with the given fields replaced by the non-null parameter values. - @override - @pragma('vm:prefer-inline') - $SpotubeUserObjectCopyWith<$Res> get owner { - return $SpotubeUserObjectCopyWith<$Res>(_value.owner, (value) { - return _then(_value.copyWith(owner: value) as $Val); - }); - } -} - -/// @nodoc -abstract class _$$SpotubeFullPlaylistObjectImplCopyWith<$Res> - implements $SpotubeFullPlaylistObjectCopyWith<$Res> { - factory _$$SpotubeFullPlaylistObjectImplCopyWith( - _$SpotubeFullPlaylistObjectImpl value, - $Res Function(_$SpotubeFullPlaylistObjectImpl) then) = - __$$SpotubeFullPlaylistObjectImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String id, - String name, - String description, - String externalUri, - SpotubeUserObject owner, - List images, - List collaborators, - bool collaborative, - bool public}); - - @override - $SpotubeUserObjectCopyWith<$Res> get owner; -} - -/// @nodoc -class __$$SpotubeFullPlaylistObjectImplCopyWithImpl<$Res> - extends _$SpotubeFullPlaylistObjectCopyWithImpl<$Res, - _$SpotubeFullPlaylistObjectImpl> - implements _$$SpotubeFullPlaylistObjectImplCopyWith<$Res> { - __$$SpotubeFullPlaylistObjectImplCopyWithImpl( - _$SpotubeFullPlaylistObjectImpl _value, - $Res Function(_$SpotubeFullPlaylistObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeFullPlaylistObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? description = null, - Object? externalUri = null, - Object? owner = null, - Object? images = null, - Object? collaborators = null, - Object? collaborative = null, - Object? public = null, - }) { - return _then(_$SpotubeFullPlaylistObjectImpl( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - description: null == description - ? _value.description - : description // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - owner: null == owner - ? _value.owner - : owner // ignore: cast_nullable_to_non_nullable - as SpotubeUserObject, - images: null == images - ? _value._images - : images // ignore: cast_nullable_to_non_nullable - as List, - collaborators: null == collaborators - ? _value._collaborators - : collaborators // ignore: cast_nullable_to_non_nullable - as List, - collaborative: null == collaborative - ? _value.collaborative - : collaborative // ignore: cast_nullable_to_non_nullable - as bool, - public: null == public - ? _value.public - : public // ignore: cast_nullable_to_non_nullable - as bool, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeFullPlaylistObjectImpl implements _SpotubeFullPlaylistObject { - _$SpotubeFullPlaylistObjectImpl( - {required this.id, - required this.name, - required this.description, - required this.externalUri, - required this.owner, - final List images = const [], - final List collaborators = const [], - this.collaborative = false, - this.public = false}) - : _images = images, - _collaborators = collaborators; - - factory _$SpotubeFullPlaylistObjectImpl.fromJson(Map json) => - _$$SpotubeFullPlaylistObjectImplFromJson(json); - - @override - final String id; - @override - final String name; - @override - final String description; - @override - final String externalUri; - @override - final SpotubeUserObject owner; - final List _images; - @override - @JsonKey() - List get images { - if (_images is EqualUnmodifiableListView) return _images; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_images); - } - - final List _collaborators; - @override - @JsonKey() - List get collaborators { - if (_collaborators is EqualUnmodifiableListView) return _collaborators; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_collaborators); - } - - @override - @JsonKey() - final bool collaborative; - @override - @JsonKey() - final bool public; - - @override - String toString() { - return 'SpotubeFullPlaylistObject(id: $id, name: $name, description: $description, externalUri: $externalUri, owner: $owner, images: $images, collaborators: $collaborators, collaborative: $collaborative, public: $public)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeFullPlaylistObjectImpl && - (identical(other.id, id) || other.id == id) && - (identical(other.name, name) || other.name == name) && - (identical(other.description, description) || - other.description == description) && - (identical(other.externalUri, externalUri) || - other.externalUri == externalUri) && - (identical(other.owner, owner) || other.owner == owner) && - const DeepCollectionEquality().equals(other._images, _images) && - const DeepCollectionEquality() - .equals(other._collaborators, _collaborators) && - (identical(other.collaborative, collaborative) || - other.collaborative == collaborative) && - (identical(other.public, public) || other.public == public)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash( - runtimeType, - id, - name, - description, - externalUri, - owner, - const DeepCollectionEquality().hash(_images), - const DeepCollectionEquality().hash(_collaborators), - collaborative, - public); - - /// Create a copy of SpotubeFullPlaylistObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeFullPlaylistObjectImplCopyWith<_$SpotubeFullPlaylistObjectImpl> - get copyWith => __$$SpotubeFullPlaylistObjectImplCopyWithImpl< - _$SpotubeFullPlaylistObjectImpl>(this, _$identity); - - @override - Map toJson() { - return _$$SpotubeFullPlaylistObjectImplToJson( - this, - ); - } -} - -abstract class _SpotubeFullPlaylistObject implements SpotubeFullPlaylistObject { - factory _SpotubeFullPlaylistObject( - {required final String id, - required final String name, - required final String description, - required final String externalUri, - required final SpotubeUserObject owner, - final List images, - final List collaborators, - final bool collaborative, - final bool public}) = _$SpotubeFullPlaylistObjectImpl; - - factory _SpotubeFullPlaylistObject.fromJson(Map json) = - _$SpotubeFullPlaylistObjectImpl.fromJson; - - @override - String get id; - @override - String get name; - @override - String get description; - @override - String get externalUri; - @override - SpotubeUserObject get owner; - @override - List get images; - @override - List get collaborators; - @override - bool get collaborative; - @override - bool get public; - - /// Create a copy of SpotubeFullPlaylistObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeFullPlaylistObjectImplCopyWith<_$SpotubeFullPlaylistObjectImpl> - get copyWith => throw _privateConstructorUsedError; -} - -SpotubeSimplePlaylistObject _$SpotubeSimplePlaylistObjectFromJson( - Map json) { - return _SpotubeSimplePlaylistObject.fromJson(json); -} - -/// @nodoc -mixin _$SpotubeSimplePlaylistObject { - String get id => throw _privateConstructorUsedError; - String get name => throw _privateConstructorUsedError; - String get description => throw _privateConstructorUsedError; - String get externalUri => throw _privateConstructorUsedError; - SpotubeUserObject get owner => throw _privateConstructorUsedError; - List get images => throw _privateConstructorUsedError; - - /// Serializes this SpotubeSimplePlaylistObject to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SpotubeSimplePlaylistObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeSimplePlaylistObjectCopyWith - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeSimplePlaylistObjectCopyWith<$Res> { - factory $SpotubeSimplePlaylistObjectCopyWith( - SpotubeSimplePlaylistObject value, - $Res Function(SpotubeSimplePlaylistObject) then) = - _$SpotubeSimplePlaylistObjectCopyWithImpl<$Res, - SpotubeSimplePlaylistObject>; - @useResult - $Res call( - {String id, - String name, - String description, - String externalUri, - SpotubeUserObject owner, - List images}); - - $SpotubeUserObjectCopyWith<$Res> get owner; -} - -/// @nodoc -class _$SpotubeSimplePlaylistObjectCopyWithImpl<$Res, - $Val extends SpotubeSimplePlaylistObject> - implements $SpotubeSimplePlaylistObjectCopyWith<$Res> { - _$SpotubeSimplePlaylistObjectCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeSimplePlaylistObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? description = null, - Object? externalUri = null, - Object? owner = null, - Object? images = null, - }) { - return _then(_value.copyWith( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - description: null == description - ? _value.description - : description // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - owner: null == owner - ? _value.owner - : owner // ignore: cast_nullable_to_non_nullable - as SpotubeUserObject, - images: null == images - ? _value.images - : images // ignore: cast_nullable_to_non_nullable - as List, - ) as $Val); - } - - /// Create a copy of SpotubeSimplePlaylistObject - /// with the given fields replaced by the non-null parameter values. - @override - @pragma('vm:prefer-inline') - $SpotubeUserObjectCopyWith<$Res> get owner { - return $SpotubeUserObjectCopyWith<$Res>(_value.owner, (value) { - return _then(_value.copyWith(owner: value) as $Val); - }); - } -} - -/// @nodoc -abstract class _$$SpotubeSimplePlaylistObjectImplCopyWith<$Res> - implements $SpotubeSimplePlaylistObjectCopyWith<$Res> { - factory _$$SpotubeSimplePlaylistObjectImplCopyWith( - _$SpotubeSimplePlaylistObjectImpl value, - $Res Function(_$SpotubeSimplePlaylistObjectImpl) then) = - __$$SpotubeSimplePlaylistObjectImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String id, - String name, - String description, - String externalUri, - SpotubeUserObject owner, - List images}); - - @override - $SpotubeUserObjectCopyWith<$Res> get owner; -} - -/// @nodoc -class __$$SpotubeSimplePlaylistObjectImplCopyWithImpl<$Res> - extends _$SpotubeSimplePlaylistObjectCopyWithImpl<$Res, - _$SpotubeSimplePlaylistObjectImpl> - implements _$$SpotubeSimplePlaylistObjectImplCopyWith<$Res> { - __$$SpotubeSimplePlaylistObjectImplCopyWithImpl( - _$SpotubeSimplePlaylistObjectImpl _value, - $Res Function(_$SpotubeSimplePlaylistObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeSimplePlaylistObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? description = null, - Object? externalUri = null, - Object? owner = null, - Object? images = null, - }) { - return _then(_$SpotubeSimplePlaylistObjectImpl( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - description: null == description - ? _value.description - : description // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - owner: null == owner - ? _value.owner - : owner // ignore: cast_nullable_to_non_nullable - as SpotubeUserObject, - images: null == images - ? _value._images - : images // ignore: cast_nullable_to_non_nullable - as List, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeSimplePlaylistObjectImpl - implements _SpotubeSimplePlaylistObject { - _$SpotubeSimplePlaylistObjectImpl( - {required this.id, - required this.name, - required this.description, - required this.externalUri, - required this.owner, - final List images = const []}) - : _images = images; - - factory _$SpotubeSimplePlaylistObjectImpl.fromJson( - Map json) => - _$$SpotubeSimplePlaylistObjectImplFromJson(json); - - @override - final String id; - @override - final String name; - @override - final String description; - @override - final String externalUri; - @override - final SpotubeUserObject owner; - final List _images; - @override - @JsonKey() - List get images { - if (_images is EqualUnmodifiableListView) return _images; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_images); - } - - @override - String toString() { - return 'SpotubeSimplePlaylistObject(id: $id, name: $name, description: $description, externalUri: $externalUri, owner: $owner, images: $images)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeSimplePlaylistObjectImpl && - (identical(other.id, id) || other.id == id) && - (identical(other.name, name) || other.name == name) && - (identical(other.description, description) || - other.description == description) && - (identical(other.externalUri, externalUri) || - other.externalUri == externalUri) && - (identical(other.owner, owner) || other.owner == owner) && - const DeepCollectionEquality().equals(other._images, _images)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, id, name, description, - externalUri, owner, const DeepCollectionEquality().hash(_images)); - - /// Create a copy of SpotubeSimplePlaylistObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeSimplePlaylistObjectImplCopyWith<_$SpotubeSimplePlaylistObjectImpl> - get copyWith => __$$SpotubeSimplePlaylistObjectImplCopyWithImpl< - _$SpotubeSimplePlaylistObjectImpl>(this, _$identity); - - @override - Map toJson() { - return _$$SpotubeSimplePlaylistObjectImplToJson( - this, - ); - } -} - -abstract class _SpotubeSimplePlaylistObject - implements SpotubeSimplePlaylistObject { - factory _SpotubeSimplePlaylistObject( - {required final String id, - required final String name, - required final String description, - required final String externalUri, - required final SpotubeUserObject owner, - final List images}) = - _$SpotubeSimplePlaylistObjectImpl; - - factory _SpotubeSimplePlaylistObject.fromJson(Map json) = - _$SpotubeSimplePlaylistObjectImpl.fromJson; - - @override - String get id; - @override - String get name; - @override - String get description; - @override - String get externalUri; - @override - SpotubeUserObject get owner; - @override - List get images; - - /// Create a copy of SpotubeSimplePlaylistObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeSimplePlaylistObjectImplCopyWith<_$SpotubeSimplePlaylistObjectImpl> - get copyWith => throw _privateConstructorUsedError; -} - -SpotubeSearchResponseObject _$SpotubeSearchResponseObjectFromJson( - Map json) { - return _SpotubeSearchResponseObject.fromJson(json); -} - -/// @nodoc -mixin _$SpotubeSearchResponseObject { - List get albums => - throw _privateConstructorUsedError; - List get artists => - throw _privateConstructorUsedError; - List get playlists => - throw _privateConstructorUsedError; - List get tracks => throw _privateConstructorUsedError; - - /// Serializes this SpotubeSearchResponseObject to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SpotubeSearchResponseObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeSearchResponseObjectCopyWith - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeSearchResponseObjectCopyWith<$Res> { - factory $SpotubeSearchResponseObjectCopyWith( - SpotubeSearchResponseObject value, - $Res Function(SpotubeSearchResponseObject) then) = - _$SpotubeSearchResponseObjectCopyWithImpl<$Res, - SpotubeSearchResponseObject>; - @useResult - $Res call( - {List albums, - List artists, - List playlists, - List tracks}); -} - -/// @nodoc -class _$SpotubeSearchResponseObjectCopyWithImpl<$Res, - $Val extends SpotubeSearchResponseObject> - implements $SpotubeSearchResponseObjectCopyWith<$Res> { - _$SpotubeSearchResponseObjectCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeSearchResponseObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? albums = null, - Object? artists = null, - Object? playlists = null, - Object? tracks = null, - }) { - return _then(_value.copyWith( - albums: null == albums - ? _value.albums - : albums // ignore: cast_nullable_to_non_nullable - as List, - artists: null == artists - ? _value.artists - : artists // ignore: cast_nullable_to_non_nullable - as List, - playlists: null == playlists - ? _value.playlists - : playlists // ignore: cast_nullable_to_non_nullable - as List, - tracks: null == tracks - ? _value.tracks - : tracks // ignore: cast_nullable_to_non_nullable - as List, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$SpotubeSearchResponseObjectImplCopyWith<$Res> - implements $SpotubeSearchResponseObjectCopyWith<$Res> { - factory _$$SpotubeSearchResponseObjectImplCopyWith( - _$SpotubeSearchResponseObjectImpl value, - $Res Function(_$SpotubeSearchResponseObjectImpl) then) = - __$$SpotubeSearchResponseObjectImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {List albums, - List artists, - List playlists, - List tracks}); -} - -/// @nodoc -class __$$SpotubeSearchResponseObjectImplCopyWithImpl<$Res> - extends _$SpotubeSearchResponseObjectCopyWithImpl<$Res, - _$SpotubeSearchResponseObjectImpl> - implements _$$SpotubeSearchResponseObjectImplCopyWith<$Res> { - __$$SpotubeSearchResponseObjectImplCopyWithImpl( - _$SpotubeSearchResponseObjectImpl _value, - $Res Function(_$SpotubeSearchResponseObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeSearchResponseObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? albums = null, - Object? artists = null, - Object? playlists = null, - Object? tracks = null, - }) { - return _then(_$SpotubeSearchResponseObjectImpl( - albums: null == albums - ? _value._albums - : albums // ignore: cast_nullable_to_non_nullable - as List, - artists: null == artists - ? _value._artists - : artists // ignore: cast_nullable_to_non_nullable - as List, - playlists: null == playlists - ? _value._playlists - : playlists // ignore: cast_nullable_to_non_nullable - as List, - tracks: null == tracks - ? _value._tracks - : tracks // ignore: cast_nullable_to_non_nullable - as List, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeSearchResponseObjectImpl - implements _SpotubeSearchResponseObject { - _$SpotubeSearchResponseObjectImpl( - {required final List albums, - required final List artists, - required final List playlists, - required final List tracks}) - : _albums = albums, - _artists = artists, - _playlists = playlists, - _tracks = tracks; - - factory _$SpotubeSearchResponseObjectImpl.fromJson( - Map json) => - _$$SpotubeSearchResponseObjectImplFromJson(json); - - final List _albums; - @override - List get albums { - if (_albums is EqualUnmodifiableListView) return _albums; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_albums); - } - - final List _artists; - @override - List get artists { - if (_artists is EqualUnmodifiableListView) return _artists; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_artists); - } - - final List _playlists; - @override - List get playlists { - if (_playlists is EqualUnmodifiableListView) return _playlists; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_playlists); - } - - final List _tracks; - @override - List get tracks { - if (_tracks is EqualUnmodifiableListView) return _tracks; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_tracks); - } - - @override - String toString() { - return 'SpotubeSearchResponseObject(albums: $albums, artists: $artists, playlists: $playlists, tracks: $tracks)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeSearchResponseObjectImpl && - const DeepCollectionEquality().equals(other._albums, _albums) && - const DeepCollectionEquality().equals(other._artists, _artists) && - const DeepCollectionEquality() - .equals(other._playlists, _playlists) && - const DeepCollectionEquality().equals(other._tracks, _tracks)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash( - runtimeType, - const DeepCollectionEquality().hash(_albums), - const DeepCollectionEquality().hash(_artists), - const DeepCollectionEquality().hash(_playlists), - const DeepCollectionEquality().hash(_tracks)); - - /// Create a copy of SpotubeSearchResponseObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeSearchResponseObjectImplCopyWith<_$SpotubeSearchResponseObjectImpl> - get copyWith => __$$SpotubeSearchResponseObjectImplCopyWithImpl< - _$SpotubeSearchResponseObjectImpl>(this, _$identity); - - @override - Map toJson() { - return _$$SpotubeSearchResponseObjectImplToJson( - this, - ); - } -} - -abstract class _SpotubeSearchResponseObject - implements SpotubeSearchResponseObject { - factory _SpotubeSearchResponseObject( - {required final List albums, - required final List artists, - required final List playlists, - required final List tracks}) = - _$SpotubeSearchResponseObjectImpl; - - factory _SpotubeSearchResponseObject.fromJson(Map json) = - _$SpotubeSearchResponseObjectImpl.fromJson; - - @override - List get albums; - @override - List get artists; - @override - List get playlists; - @override - List get tracks; - - /// Create a copy of SpotubeSearchResponseObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeSearchResponseObjectImplCopyWith<_$SpotubeSearchResponseObjectImpl> - get copyWith => throw _privateConstructorUsedError; -} - -SpotubeTrackObject _$SpotubeTrackObjectFromJson(Map json) { - switch (json['runtimeType']) { - case 'local': - return SpotubeLocalTrackObject.fromJson(json); - case 'full': - return SpotubeFullTrackObject.fromJson(json); - - default: - throw CheckedFromJsonException(json, 'runtimeType', 'SpotubeTrackObject', - 'Invalid union type "${json['runtimeType']}"!'); - } -} - -/// @nodoc -mixin _$SpotubeTrackObject { - String get id => throw _privateConstructorUsedError; - String get name => throw _privateConstructorUsedError; - String get externalUri => throw _privateConstructorUsedError; - List get artists => - throw _privateConstructorUsedError; - SpotubeSimpleAlbumObject get album => throw _privateConstructorUsedError; - int get durationMs => throw _privateConstructorUsedError; - @optionalTypeArgs - TResult when({ - required TResult Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String path) - local, - required TResult Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String isrc, - bool explicit) - full, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String path)? - local, - TResult? Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String isrc, - bool explicit)? - full, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeWhen({ - TResult Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String path)? - local, - TResult Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String isrc, - bool explicit)? - full, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult map({ - required TResult Function(SpotubeLocalTrackObject value) local, - required TResult Function(SpotubeFullTrackObject value) full, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(SpotubeLocalTrackObject value)? local, - TResult? Function(SpotubeFullTrackObject value)? full, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeMap({ - TResult Function(SpotubeLocalTrackObject value)? local, - TResult Function(SpotubeFullTrackObject value)? full, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - - /// Serializes this SpotubeTrackObject to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SpotubeTrackObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeTrackObjectCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeTrackObjectCopyWith<$Res> { - factory $SpotubeTrackObjectCopyWith( - SpotubeTrackObject value, $Res Function(SpotubeTrackObject) then) = - _$SpotubeTrackObjectCopyWithImpl<$Res, SpotubeTrackObject>; - @useResult - $Res call( - {String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs}); - - $SpotubeSimpleAlbumObjectCopyWith<$Res> get album; -} - -/// @nodoc -class _$SpotubeTrackObjectCopyWithImpl<$Res, $Val extends SpotubeTrackObject> - implements $SpotubeTrackObjectCopyWith<$Res> { - _$SpotubeTrackObjectCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeTrackObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? externalUri = null, - Object? artists = null, - Object? album = null, - Object? durationMs = null, - }) { - return _then(_value.copyWith( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - artists: null == artists - ? _value.artists - : artists // ignore: cast_nullable_to_non_nullable - as List, - album: null == album - ? _value.album - : album // ignore: cast_nullable_to_non_nullable - as SpotubeSimpleAlbumObject, - durationMs: null == durationMs - ? _value.durationMs - : durationMs // ignore: cast_nullable_to_non_nullable - as int, - ) as $Val); - } - - /// Create a copy of SpotubeTrackObject - /// with the given fields replaced by the non-null parameter values. - @override - @pragma('vm:prefer-inline') - $SpotubeSimpleAlbumObjectCopyWith<$Res> get album { - return $SpotubeSimpleAlbumObjectCopyWith<$Res>(_value.album, (value) { - return _then(_value.copyWith(album: value) as $Val); - }); - } -} - -/// @nodoc -abstract class _$$SpotubeLocalTrackObjectImplCopyWith<$Res> - implements $SpotubeTrackObjectCopyWith<$Res> { - factory _$$SpotubeLocalTrackObjectImplCopyWith( - _$SpotubeLocalTrackObjectImpl value, - $Res Function(_$SpotubeLocalTrackObjectImpl) then) = - __$$SpotubeLocalTrackObjectImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String path}); - - @override - $SpotubeSimpleAlbumObjectCopyWith<$Res> get album; -} - -/// @nodoc -class __$$SpotubeLocalTrackObjectImplCopyWithImpl<$Res> - extends _$SpotubeTrackObjectCopyWithImpl<$Res, - _$SpotubeLocalTrackObjectImpl> - implements _$$SpotubeLocalTrackObjectImplCopyWith<$Res> { - __$$SpotubeLocalTrackObjectImplCopyWithImpl( - _$SpotubeLocalTrackObjectImpl _value, - $Res Function(_$SpotubeLocalTrackObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeTrackObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? externalUri = null, - Object? artists = null, - Object? album = null, - Object? durationMs = null, - Object? path = null, - }) { - return _then(_$SpotubeLocalTrackObjectImpl( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - artists: null == artists - ? _value._artists - : artists // ignore: cast_nullable_to_non_nullable - as List, - album: null == album - ? _value.album - : album // ignore: cast_nullable_to_non_nullable - as SpotubeSimpleAlbumObject, - durationMs: null == durationMs - ? _value.durationMs - : durationMs // ignore: cast_nullable_to_non_nullable - as int, - path: null == path - ? _value.path - : path // ignore: cast_nullable_to_non_nullable - as String, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeLocalTrackObjectImpl implements SpotubeLocalTrackObject { - _$SpotubeLocalTrackObjectImpl( - {required this.id, - required this.name, - required this.externalUri, - final List artists = const [], - required this.album, - required this.durationMs, - required this.path, - final String? $type}) - : _artists = artists, - $type = $type ?? 'local'; - - factory _$SpotubeLocalTrackObjectImpl.fromJson(Map json) => - _$$SpotubeLocalTrackObjectImplFromJson(json); - - @override - final String id; - @override - final String name; - @override - final String externalUri; - final List _artists; - @override - @JsonKey() - List get artists { - if (_artists is EqualUnmodifiableListView) return _artists; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_artists); - } - - @override - final SpotubeSimpleAlbumObject album; - @override - final int durationMs; - @override - final String path; - - @JsonKey(name: 'runtimeType') - final String $type; - - @override - String toString() { - return 'SpotubeTrackObject.local(id: $id, name: $name, externalUri: $externalUri, artists: $artists, album: $album, durationMs: $durationMs, path: $path)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeLocalTrackObjectImpl && - (identical(other.id, id) || other.id == id) && - (identical(other.name, name) || other.name == name) && - (identical(other.externalUri, externalUri) || - other.externalUri == externalUri) && - const DeepCollectionEquality().equals(other._artists, _artists) && - (identical(other.album, album) || other.album == album) && - (identical(other.durationMs, durationMs) || - other.durationMs == durationMs) && - (identical(other.path, path) || other.path == path)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, id, name, externalUri, - const DeepCollectionEquality().hash(_artists), album, durationMs, path); - - /// Create a copy of SpotubeTrackObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeLocalTrackObjectImplCopyWith<_$SpotubeLocalTrackObjectImpl> - get copyWith => __$$SpotubeLocalTrackObjectImplCopyWithImpl< - _$SpotubeLocalTrackObjectImpl>(this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String path) - local, - required TResult Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String isrc, - bool explicit) - full, - }) { - return local(id, name, externalUri, artists, album, durationMs, path); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String path)? - local, - TResult? Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String isrc, - bool explicit)? - full, - }) { - return local?.call(id, name, externalUri, artists, album, durationMs, path); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String path)? - local, - TResult Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String isrc, - bool explicit)? - full, - required TResult orElse(), - }) { - if (local != null) { - return local(id, name, externalUri, artists, album, durationMs, path); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(SpotubeLocalTrackObject value) local, - required TResult Function(SpotubeFullTrackObject value) full, - }) { - return local(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(SpotubeLocalTrackObject value)? local, - TResult? Function(SpotubeFullTrackObject value)? full, - }) { - return local?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(SpotubeLocalTrackObject value)? local, - TResult Function(SpotubeFullTrackObject value)? full, - required TResult orElse(), - }) { - if (local != null) { - return local(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$SpotubeLocalTrackObjectImplToJson( - this, - ); - } -} - -abstract class SpotubeLocalTrackObject implements SpotubeTrackObject { - factory SpotubeLocalTrackObject( - {required final String id, - required final String name, - required final String externalUri, - final List artists, - required final SpotubeSimpleAlbumObject album, - required final int durationMs, - required final String path}) = _$SpotubeLocalTrackObjectImpl; - - factory SpotubeLocalTrackObject.fromJson(Map json) = - _$SpotubeLocalTrackObjectImpl.fromJson; - - @override - String get id; - @override - String get name; - @override - String get externalUri; - @override - List get artists; - @override - SpotubeSimpleAlbumObject get album; - @override - int get durationMs; - String get path; - - /// Create a copy of SpotubeTrackObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeLocalTrackObjectImplCopyWith<_$SpotubeLocalTrackObjectImpl> - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class _$$SpotubeFullTrackObjectImplCopyWith<$Res> - implements $SpotubeTrackObjectCopyWith<$Res> { - factory _$$SpotubeFullTrackObjectImplCopyWith( - _$SpotubeFullTrackObjectImpl value, - $Res Function(_$SpotubeFullTrackObjectImpl) then) = - __$$SpotubeFullTrackObjectImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String isrc, - bool explicit}); - - @override - $SpotubeSimpleAlbumObjectCopyWith<$Res> get album; -} - -/// @nodoc -class __$$SpotubeFullTrackObjectImplCopyWithImpl<$Res> - extends _$SpotubeTrackObjectCopyWithImpl<$Res, _$SpotubeFullTrackObjectImpl> - implements _$$SpotubeFullTrackObjectImplCopyWith<$Res> { - __$$SpotubeFullTrackObjectImplCopyWithImpl( - _$SpotubeFullTrackObjectImpl _value, - $Res Function(_$SpotubeFullTrackObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeTrackObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? externalUri = null, - Object? artists = null, - Object? album = null, - Object? durationMs = null, - Object? isrc = null, - Object? explicit = null, - }) { - return _then(_$SpotubeFullTrackObjectImpl( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - artists: null == artists - ? _value._artists - : artists // ignore: cast_nullable_to_non_nullable - as List, - album: null == album - ? _value.album - : album // ignore: cast_nullable_to_non_nullable - as SpotubeSimpleAlbumObject, - durationMs: null == durationMs - ? _value.durationMs - : durationMs // ignore: cast_nullable_to_non_nullable - as int, - isrc: null == isrc - ? _value.isrc - : isrc // ignore: cast_nullable_to_non_nullable - as String, - explicit: null == explicit - ? _value.explicit - : explicit // ignore: cast_nullable_to_non_nullable - as bool, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeFullTrackObjectImpl implements SpotubeFullTrackObject { - _$SpotubeFullTrackObjectImpl( - {required this.id, - required this.name, - required this.externalUri, - final List artists = const [], - required this.album, - required this.durationMs, - required this.isrc, - required this.explicit, - final String? $type}) - : _artists = artists, - $type = $type ?? 'full'; - - factory _$SpotubeFullTrackObjectImpl.fromJson(Map json) => - _$$SpotubeFullTrackObjectImplFromJson(json); - - @override - final String id; - @override - final String name; - @override - final String externalUri; - final List _artists; - @override - @JsonKey() - List get artists { - if (_artists is EqualUnmodifiableListView) return _artists; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_artists); - } - - @override - final SpotubeSimpleAlbumObject album; - @override - final int durationMs; - @override - final String isrc; - @override - final bool explicit; - - @JsonKey(name: 'runtimeType') - final String $type; - - @override - String toString() { - return 'SpotubeTrackObject.full(id: $id, name: $name, externalUri: $externalUri, artists: $artists, album: $album, durationMs: $durationMs, isrc: $isrc, explicit: $explicit)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeFullTrackObjectImpl && - (identical(other.id, id) || other.id == id) && - (identical(other.name, name) || other.name == name) && - (identical(other.externalUri, externalUri) || - other.externalUri == externalUri) && - const DeepCollectionEquality().equals(other._artists, _artists) && - (identical(other.album, album) || other.album == album) && - (identical(other.durationMs, durationMs) || - other.durationMs == durationMs) && - (identical(other.isrc, isrc) || other.isrc == isrc) && - (identical(other.explicit, explicit) || - other.explicit == explicit)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash( - runtimeType, - id, - name, - externalUri, - const DeepCollectionEquality().hash(_artists), - album, - durationMs, - isrc, - explicit); - - /// Create a copy of SpotubeTrackObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeFullTrackObjectImplCopyWith<_$SpotubeFullTrackObjectImpl> - get copyWith => __$$SpotubeFullTrackObjectImplCopyWithImpl< - _$SpotubeFullTrackObjectImpl>(this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String path) - local, - required TResult Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String isrc, - bool explicit) - full, - }) { - return full( - id, name, externalUri, artists, album, durationMs, isrc, explicit); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String path)? - local, - TResult? Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String isrc, - bool explicit)? - full, - }) { - return full?.call( - id, name, externalUri, artists, album, durationMs, isrc, explicit); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String path)? - local, - TResult Function( - String id, - String name, - String externalUri, - List artists, - SpotubeSimpleAlbumObject album, - int durationMs, - String isrc, - bool explicit)? - full, - required TResult orElse(), - }) { - if (full != null) { - return full( - id, name, externalUri, artists, album, durationMs, isrc, explicit); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(SpotubeLocalTrackObject value) local, - required TResult Function(SpotubeFullTrackObject value) full, - }) { - return full(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(SpotubeLocalTrackObject value)? local, - TResult? Function(SpotubeFullTrackObject value)? full, - }) { - return full?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(SpotubeLocalTrackObject value)? local, - TResult Function(SpotubeFullTrackObject value)? full, - required TResult orElse(), - }) { - if (full != null) { - return full(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$SpotubeFullTrackObjectImplToJson( - this, - ); - } -} - -abstract class SpotubeFullTrackObject implements SpotubeTrackObject { - factory SpotubeFullTrackObject( - {required final String id, - required final String name, - required final String externalUri, - final List artists, - required final SpotubeSimpleAlbumObject album, - required final int durationMs, - required final String isrc, - required final bool explicit}) = _$SpotubeFullTrackObjectImpl; - - factory SpotubeFullTrackObject.fromJson(Map json) = - _$SpotubeFullTrackObjectImpl.fromJson; - - @override - String get id; - @override - String get name; - @override - String get externalUri; - @override - List get artists; - @override - SpotubeSimpleAlbumObject get album; - @override - int get durationMs; - String get isrc; - bool get explicit; - - /// Create a copy of SpotubeTrackObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeFullTrackObjectImplCopyWith<_$SpotubeFullTrackObjectImpl> - get copyWith => throw _privateConstructorUsedError; -} - -SpotubeUserObject _$SpotubeUserObjectFromJson(Map json) { - return _SpotubeUserObject.fromJson(json); -} - -/// @nodoc -mixin _$SpotubeUserObject { - String get id => throw _privateConstructorUsedError; - String get name => throw _privateConstructorUsedError; - List get images => throw _privateConstructorUsedError; - String get externalUri => throw _privateConstructorUsedError; - - /// Serializes this SpotubeUserObject to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SpotubeUserObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SpotubeUserObjectCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpotubeUserObjectCopyWith<$Res> { - factory $SpotubeUserObjectCopyWith( - SpotubeUserObject value, $Res Function(SpotubeUserObject) then) = - _$SpotubeUserObjectCopyWithImpl<$Res, SpotubeUserObject>; - @useResult - $Res call( - {String id, - String name, - List images, - String externalUri}); -} - -/// @nodoc -class _$SpotubeUserObjectCopyWithImpl<$Res, $Val extends SpotubeUserObject> - implements $SpotubeUserObjectCopyWith<$Res> { - _$SpotubeUserObjectCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SpotubeUserObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? images = null, - Object? externalUri = null, - }) { - return _then(_value.copyWith( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - images: null == images - ? _value.images - : images // ignore: cast_nullable_to_non_nullable - as List, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$SpotubeUserObjectImplCopyWith<$Res> - implements $SpotubeUserObjectCopyWith<$Res> { - factory _$$SpotubeUserObjectImplCopyWith(_$SpotubeUserObjectImpl value, - $Res Function(_$SpotubeUserObjectImpl) then) = - __$$SpotubeUserObjectImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String id, - String name, - List images, - String externalUri}); -} - -/// @nodoc -class __$$SpotubeUserObjectImplCopyWithImpl<$Res> - extends _$SpotubeUserObjectCopyWithImpl<$Res, _$SpotubeUserObjectImpl> - implements _$$SpotubeUserObjectImplCopyWith<$Res> { - __$$SpotubeUserObjectImplCopyWithImpl(_$SpotubeUserObjectImpl _value, - $Res Function(_$SpotubeUserObjectImpl) _then) - : super(_value, _then); - - /// Create a copy of SpotubeUserObject - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? images = null, - Object? externalUri = null, - }) { - return _then(_$SpotubeUserObjectImpl( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - images: null == images - ? _value._images - : images // ignore: cast_nullable_to_non_nullable - as List, - externalUri: null == externalUri - ? _value.externalUri - : externalUri // ignore: cast_nullable_to_non_nullable - as String, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SpotubeUserObjectImpl implements _SpotubeUserObject { - _$SpotubeUserObjectImpl( - {required this.id, - required this.name, - final List images = const [], - required this.externalUri}) - : _images = images; - - factory _$SpotubeUserObjectImpl.fromJson(Map json) => - _$$SpotubeUserObjectImplFromJson(json); - - @override - final String id; - @override - final String name; - final List _images; - @override - @JsonKey() - List get images { - if (_images is EqualUnmodifiableListView) return _images; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_images); - } - - @override - final String externalUri; - - @override - String toString() { - return 'SpotubeUserObject(id: $id, name: $name, images: $images, externalUri: $externalUri)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpotubeUserObjectImpl && - (identical(other.id, id) || other.id == id) && - (identical(other.name, name) || other.name == name) && - const DeepCollectionEquality().equals(other._images, _images) && - (identical(other.externalUri, externalUri) || - other.externalUri == externalUri)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, id, name, - const DeepCollectionEquality().hash(_images), externalUri); - - /// Create a copy of SpotubeUserObject - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SpotubeUserObjectImplCopyWith<_$SpotubeUserObjectImpl> get copyWith => - __$$SpotubeUserObjectImplCopyWithImpl<_$SpotubeUserObjectImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$SpotubeUserObjectImplToJson( - this, - ); - } -} - -abstract class _SpotubeUserObject implements SpotubeUserObject { - factory _SpotubeUserObject( - {required final String id, - required final String name, - final List images, - required final String externalUri}) = _$SpotubeUserObjectImpl; - - factory _SpotubeUserObject.fromJson(Map json) = - _$SpotubeUserObjectImpl.fromJson; - - @override - String get id; - @override - String get name; - @override - List get images; - @override - String get externalUri; - - /// Create a copy of SpotubeUserObject - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SpotubeUserObjectImplCopyWith<_$SpotubeUserObjectImpl> get copyWith => - throw _privateConstructorUsedError; -} - -PluginConfiguration _$PluginConfigurationFromJson(Map json) { - return _PluginConfiguration.fromJson(json); -} - -/// @nodoc -mixin _$PluginConfiguration { - String get name => throw _privateConstructorUsedError; - String get description => throw _privateConstructorUsedError; - String get version => throw _privateConstructorUsedError; - String get author => throw _privateConstructorUsedError; - String get entryPoint => throw _privateConstructorUsedError; - String get pluginApiVersion => throw _privateConstructorUsedError; - List get apis => throw _privateConstructorUsedError; - List get abilities => throw _privateConstructorUsedError; - String? get repository => throw _privateConstructorUsedError; - - /// Serializes this PluginConfiguration to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of PluginConfiguration - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $PluginConfigurationCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $PluginConfigurationCopyWith<$Res> { - factory $PluginConfigurationCopyWith( - PluginConfiguration value, $Res Function(PluginConfiguration) then) = - _$PluginConfigurationCopyWithImpl<$Res, PluginConfiguration>; - @useResult - $Res call( - {String name, - String description, - String version, - String author, - String entryPoint, - String pluginApiVersion, - List apis, - List abilities, - String? repository}); -} - -/// @nodoc -class _$PluginConfigurationCopyWithImpl<$Res, $Val extends PluginConfiguration> - implements $PluginConfigurationCopyWith<$Res> { - _$PluginConfigurationCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of PluginConfiguration - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? name = null, - Object? description = null, - Object? version = null, - Object? author = null, - Object? entryPoint = null, - Object? pluginApiVersion = null, - Object? apis = null, - Object? abilities = null, - Object? repository = freezed, - }) { - return _then(_value.copyWith( - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - description: null == description - ? _value.description - : description // ignore: cast_nullable_to_non_nullable - as String, - version: null == version - ? _value.version - : version // ignore: cast_nullable_to_non_nullable - as String, - author: null == author - ? _value.author - : author // ignore: cast_nullable_to_non_nullable - as String, - entryPoint: null == entryPoint - ? _value.entryPoint - : entryPoint // ignore: cast_nullable_to_non_nullable - as String, - pluginApiVersion: null == pluginApiVersion - ? _value.pluginApiVersion - : pluginApiVersion // ignore: cast_nullable_to_non_nullable - as String, - apis: null == apis - ? _value.apis - : apis // ignore: cast_nullable_to_non_nullable - as List, - abilities: null == abilities - ? _value.abilities - : abilities // ignore: cast_nullable_to_non_nullable - as List, - repository: freezed == repository - ? _value.repository - : repository // ignore: cast_nullable_to_non_nullable - as String?, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$PluginConfigurationImplCopyWith<$Res> - implements $PluginConfigurationCopyWith<$Res> { - factory _$$PluginConfigurationImplCopyWith(_$PluginConfigurationImpl value, - $Res Function(_$PluginConfigurationImpl) then) = - __$$PluginConfigurationImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String name, - String description, - String version, - String author, - String entryPoint, - String pluginApiVersion, - List apis, - List abilities, - String? repository}); -} - -/// @nodoc -class __$$PluginConfigurationImplCopyWithImpl<$Res> - extends _$PluginConfigurationCopyWithImpl<$Res, _$PluginConfigurationImpl> - implements _$$PluginConfigurationImplCopyWith<$Res> { - __$$PluginConfigurationImplCopyWithImpl(_$PluginConfigurationImpl _value, - $Res Function(_$PluginConfigurationImpl) _then) - : super(_value, _then); - - /// Create a copy of PluginConfiguration - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? name = null, - Object? description = null, - Object? version = null, - Object? author = null, - Object? entryPoint = null, - Object? pluginApiVersion = null, - Object? apis = null, - Object? abilities = null, - Object? repository = freezed, - }) { - return _then(_$PluginConfigurationImpl( - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - description: null == description - ? _value.description - : description // ignore: cast_nullable_to_non_nullable - as String, - version: null == version - ? _value.version - : version // ignore: cast_nullable_to_non_nullable - as String, - author: null == author - ? _value.author - : author // ignore: cast_nullable_to_non_nullable - as String, - entryPoint: null == entryPoint - ? _value.entryPoint - : entryPoint // ignore: cast_nullable_to_non_nullable - as String, - pluginApiVersion: null == pluginApiVersion - ? _value.pluginApiVersion - : pluginApiVersion // ignore: cast_nullable_to_non_nullable - as String, - apis: null == apis - ? _value._apis - : apis // ignore: cast_nullable_to_non_nullable - as List, - abilities: null == abilities - ? _value._abilities - : abilities // ignore: cast_nullable_to_non_nullable - as List, - repository: freezed == repository - ? _value.repository - : repository // ignore: cast_nullable_to_non_nullable - as String?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$PluginConfigurationImpl extends _PluginConfiguration { - _$PluginConfigurationImpl( - {required this.name, - required this.description, - required this.version, - required this.author, - required this.entryPoint, - required this.pluginApiVersion, - final List apis = const [], - final List abilities = const [], - this.repository}) - : _apis = apis, - _abilities = abilities, - super._(); - - factory _$PluginConfigurationImpl.fromJson(Map json) => - _$$PluginConfigurationImplFromJson(json); - - @override - final String name; - @override - final String description; - @override - final String version; - @override - final String author; - @override - final String entryPoint; - @override - final String pluginApiVersion; - final List _apis; - @override - @JsonKey() - List get apis { - if (_apis is EqualUnmodifiableListView) return _apis; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_apis); - } - - final List _abilities; - @override - @JsonKey() - List get abilities { - if (_abilities is EqualUnmodifiableListView) return _abilities; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_abilities); - } - - @override - final String? repository; - - @override - String toString() { - return 'PluginConfiguration(name: $name, description: $description, version: $version, author: $author, entryPoint: $entryPoint, pluginApiVersion: $pluginApiVersion, apis: $apis, abilities: $abilities, repository: $repository)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$PluginConfigurationImpl && - (identical(other.name, name) || other.name == name) && - (identical(other.description, description) || - other.description == description) && - (identical(other.version, version) || other.version == version) && - (identical(other.author, author) || other.author == author) && - (identical(other.entryPoint, entryPoint) || - other.entryPoint == entryPoint) && - (identical(other.pluginApiVersion, pluginApiVersion) || - other.pluginApiVersion == pluginApiVersion) && - const DeepCollectionEquality().equals(other._apis, _apis) && - const DeepCollectionEquality() - .equals(other._abilities, _abilities) && - (identical(other.repository, repository) || - other.repository == repository)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash( - runtimeType, - name, - description, - version, - author, - entryPoint, - pluginApiVersion, - const DeepCollectionEquality().hash(_apis), - const DeepCollectionEquality().hash(_abilities), - repository); - - /// Create a copy of PluginConfiguration - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$PluginConfigurationImplCopyWith<_$PluginConfigurationImpl> get copyWith => - __$$PluginConfigurationImplCopyWithImpl<_$PluginConfigurationImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$PluginConfigurationImplToJson( - this, - ); - } -} - -abstract class _PluginConfiguration extends PluginConfiguration { - factory _PluginConfiguration( - {required final String name, - required final String description, - required final String version, - required final String author, - required final String entryPoint, - required final String pluginApiVersion, - final List apis, - final List abilities, - final String? repository}) = _$PluginConfigurationImpl; - _PluginConfiguration._() : super._(); - - factory _PluginConfiguration.fromJson(Map json) = - _$PluginConfigurationImpl.fromJson; - - @override - String get name; - @override - String get description; - @override - String get version; - @override - String get author; - @override - String get entryPoint; - @override - String get pluginApiVersion; - @override - List get apis; - @override - List get abilities; - @override - String? get repository; - - /// Create a copy of PluginConfiguration - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$PluginConfigurationImplCopyWith<_$PluginConfigurationImpl> get copyWith => - throw _privateConstructorUsedError; -} - -PluginUpdateAvailable _$PluginUpdateAvailableFromJson( - Map json) { - return _PluginUpdateAvailable.fromJson(json); -} - -/// @nodoc -mixin _$PluginUpdateAvailable { - String get downloadUrl => throw _privateConstructorUsedError; - String get version => throw _privateConstructorUsedError; - String? get changelog => throw _privateConstructorUsedError; - - /// Serializes this PluginUpdateAvailable to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of PluginUpdateAvailable - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $PluginUpdateAvailableCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $PluginUpdateAvailableCopyWith<$Res> { - factory $PluginUpdateAvailableCopyWith(PluginUpdateAvailable value, - $Res Function(PluginUpdateAvailable) then) = - _$PluginUpdateAvailableCopyWithImpl<$Res, PluginUpdateAvailable>; - @useResult - $Res call({String downloadUrl, String version, String? changelog}); -} - -/// @nodoc -class _$PluginUpdateAvailableCopyWithImpl<$Res, - $Val extends PluginUpdateAvailable> - implements $PluginUpdateAvailableCopyWith<$Res> { - _$PluginUpdateAvailableCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of PluginUpdateAvailable - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? downloadUrl = null, - Object? version = null, - Object? changelog = freezed, - }) { - return _then(_value.copyWith( - downloadUrl: null == downloadUrl - ? _value.downloadUrl - : downloadUrl // ignore: cast_nullable_to_non_nullable - as String, - version: null == version - ? _value.version - : version // ignore: cast_nullable_to_non_nullable - as String, - changelog: freezed == changelog - ? _value.changelog - : changelog // ignore: cast_nullable_to_non_nullable - as String?, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$PluginUpdateAvailableImplCopyWith<$Res> - implements $PluginUpdateAvailableCopyWith<$Res> { - factory _$$PluginUpdateAvailableImplCopyWith( - _$PluginUpdateAvailableImpl value, - $Res Function(_$PluginUpdateAvailableImpl) then) = - __$$PluginUpdateAvailableImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({String downloadUrl, String version, String? changelog}); -} - -/// @nodoc -class __$$PluginUpdateAvailableImplCopyWithImpl<$Res> - extends _$PluginUpdateAvailableCopyWithImpl<$Res, - _$PluginUpdateAvailableImpl> - implements _$$PluginUpdateAvailableImplCopyWith<$Res> { - __$$PluginUpdateAvailableImplCopyWithImpl(_$PluginUpdateAvailableImpl _value, - $Res Function(_$PluginUpdateAvailableImpl) _then) - : super(_value, _then); - - /// Create a copy of PluginUpdateAvailable - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? downloadUrl = null, - Object? version = null, - Object? changelog = freezed, - }) { - return _then(_$PluginUpdateAvailableImpl( - downloadUrl: null == downloadUrl - ? _value.downloadUrl - : downloadUrl // ignore: cast_nullable_to_non_nullable - as String, - version: null == version - ? _value.version - : version // ignore: cast_nullable_to_non_nullable - as String, - changelog: freezed == changelog - ? _value.changelog - : changelog // ignore: cast_nullable_to_non_nullable - as String?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$PluginUpdateAvailableImpl implements _PluginUpdateAvailable { - _$PluginUpdateAvailableImpl( - {required this.downloadUrl, required this.version, this.changelog}); - - factory _$PluginUpdateAvailableImpl.fromJson(Map json) => - _$$PluginUpdateAvailableImplFromJson(json); - - @override - final String downloadUrl; - @override - final String version; - @override - final String? changelog; - - @override - String toString() { - return 'PluginUpdateAvailable(downloadUrl: $downloadUrl, version: $version, changelog: $changelog)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$PluginUpdateAvailableImpl && - (identical(other.downloadUrl, downloadUrl) || - other.downloadUrl == downloadUrl) && - (identical(other.version, version) || other.version == version) && - (identical(other.changelog, changelog) || - other.changelog == changelog)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, downloadUrl, version, changelog); - - /// Create a copy of PluginUpdateAvailable - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$PluginUpdateAvailableImplCopyWith<_$PluginUpdateAvailableImpl> - get copyWith => __$$PluginUpdateAvailableImplCopyWithImpl< - _$PluginUpdateAvailableImpl>(this, _$identity); - - @override - Map toJson() { - return _$$PluginUpdateAvailableImplToJson( - this, - ); - } -} - -abstract class _PluginUpdateAvailable implements PluginUpdateAvailable { - factory _PluginUpdateAvailable( - {required final String downloadUrl, - required final String version, - final String? changelog}) = _$PluginUpdateAvailableImpl; - - factory _PluginUpdateAvailable.fromJson(Map json) = - _$PluginUpdateAvailableImpl.fromJson; - - @override - String get downloadUrl; - @override - String get version; - @override - String? get changelog; - - /// Create a copy of PluginUpdateAvailable - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$PluginUpdateAvailableImplCopyWith<_$PluginUpdateAvailableImpl> - get copyWith => throw _privateConstructorUsedError; -} - -MetadataPluginRepository _$MetadataPluginRepositoryFromJson( - Map json) { - return _MetadataPluginRepository.fromJson(json); -} - -/// @nodoc -mixin _$MetadataPluginRepository { - String get name => throw _privateConstructorUsedError; - String get owner => throw _privateConstructorUsedError; - String get description => throw _privateConstructorUsedError; - String get repoUrl => throw _privateConstructorUsedError; - List get topics => throw _privateConstructorUsedError; - - /// Serializes this MetadataPluginRepository to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of MetadataPluginRepository - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $MetadataPluginRepositoryCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $MetadataPluginRepositoryCopyWith<$Res> { - factory $MetadataPluginRepositoryCopyWith(MetadataPluginRepository value, - $Res Function(MetadataPluginRepository) then) = - _$MetadataPluginRepositoryCopyWithImpl<$Res, MetadataPluginRepository>; - @useResult - $Res call( - {String name, - String owner, - String description, - String repoUrl, - List topics}); -} - -/// @nodoc -class _$MetadataPluginRepositoryCopyWithImpl<$Res, - $Val extends MetadataPluginRepository> - implements $MetadataPluginRepositoryCopyWith<$Res> { - _$MetadataPluginRepositoryCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of MetadataPluginRepository - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? name = null, - Object? owner = null, - Object? description = null, - Object? repoUrl = null, - Object? topics = null, - }) { - return _then(_value.copyWith( - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - owner: null == owner - ? _value.owner - : owner // ignore: cast_nullable_to_non_nullable - as String, - description: null == description - ? _value.description - : description // ignore: cast_nullable_to_non_nullable - as String, - repoUrl: null == repoUrl - ? _value.repoUrl - : repoUrl // ignore: cast_nullable_to_non_nullable - as String, - topics: null == topics - ? _value.topics - : topics // ignore: cast_nullable_to_non_nullable - as List, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$MetadataPluginRepositoryImplCopyWith<$Res> - implements $MetadataPluginRepositoryCopyWith<$Res> { - factory _$$MetadataPluginRepositoryImplCopyWith( - _$MetadataPluginRepositoryImpl value, - $Res Function(_$MetadataPluginRepositoryImpl) then) = - __$$MetadataPluginRepositoryImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String name, - String owner, - String description, - String repoUrl, - List topics}); -} - -/// @nodoc -class __$$MetadataPluginRepositoryImplCopyWithImpl<$Res> - extends _$MetadataPluginRepositoryCopyWithImpl<$Res, - _$MetadataPluginRepositoryImpl> - implements _$$MetadataPluginRepositoryImplCopyWith<$Res> { - __$$MetadataPluginRepositoryImplCopyWithImpl( - _$MetadataPluginRepositoryImpl _value, - $Res Function(_$MetadataPluginRepositoryImpl) _then) - : super(_value, _then); - - /// Create a copy of MetadataPluginRepository - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? name = null, - Object? owner = null, - Object? description = null, - Object? repoUrl = null, - Object? topics = null, - }) { - return _then(_$MetadataPluginRepositoryImpl( - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - owner: null == owner - ? _value.owner - : owner // ignore: cast_nullable_to_non_nullable - as String, - description: null == description - ? _value.description - : description // ignore: cast_nullable_to_non_nullable - as String, - repoUrl: null == repoUrl - ? _value.repoUrl - : repoUrl // ignore: cast_nullable_to_non_nullable - as String, - topics: null == topics - ? _value._topics - : topics // ignore: cast_nullable_to_non_nullable - as List, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$MetadataPluginRepositoryImpl implements _MetadataPluginRepository { - _$MetadataPluginRepositoryImpl( - {required this.name, - required this.owner, - required this.description, - required this.repoUrl, - required final List topics}) - : _topics = topics; - - factory _$MetadataPluginRepositoryImpl.fromJson(Map json) => - _$$MetadataPluginRepositoryImplFromJson(json); - - @override - final String name; - @override - final String owner; - @override - final String description; - @override - final String repoUrl; - final List _topics; - @override - List get topics { - if (_topics is EqualUnmodifiableListView) return _topics; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_topics); - } - - @override - String toString() { - return 'MetadataPluginRepository(name: $name, owner: $owner, description: $description, repoUrl: $repoUrl, topics: $topics)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$MetadataPluginRepositoryImpl && - (identical(other.name, name) || other.name == name) && - (identical(other.owner, owner) || other.owner == owner) && - (identical(other.description, description) || - other.description == description) && - (identical(other.repoUrl, repoUrl) || other.repoUrl == repoUrl) && - const DeepCollectionEquality().equals(other._topics, _topics)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, name, owner, description, - repoUrl, const DeepCollectionEquality().hash(_topics)); - - /// Create a copy of MetadataPluginRepository - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$MetadataPluginRepositoryImplCopyWith<_$MetadataPluginRepositoryImpl> - get copyWith => __$$MetadataPluginRepositoryImplCopyWithImpl< - _$MetadataPluginRepositoryImpl>(this, _$identity); - - @override - Map toJson() { - return _$$MetadataPluginRepositoryImplToJson( - this, - ); - } -} - -abstract class _MetadataPluginRepository implements MetadataPluginRepository { - factory _MetadataPluginRepository( - {required final String name, - required final String owner, - required final String description, - required final String repoUrl, - required final List topics}) = _$MetadataPluginRepositoryImpl; - - factory _MetadataPluginRepository.fromJson(Map json) = - _$MetadataPluginRepositoryImpl.fromJson; - - @override - String get name; - @override - String get owner; - @override - String get description; - @override - String get repoUrl; - @override - List get topics; - - /// Create a copy of MetadataPluginRepository - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$MetadataPluginRepositoryImplCopyWith<_$MetadataPluginRepositoryImpl> - get copyWith => throw _privateConstructorUsedError; -} diff --git a/lib/models/metadata/metadata.g.dart b/lib/models/metadata/metadata.g.dart deleted file mode 100644 index 56783d80..00000000 --- a/lib/models/metadata/metadata.g.dart +++ /dev/null @@ -1,618 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'metadata.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$SpotubeAudioSourceContainerPresetLossyImpl - _$$SpotubeAudioSourceContainerPresetLossyImplFromJson(Map json) => - _$SpotubeAudioSourceContainerPresetLossyImpl( - type: $enumDecode(_$SpotubeMediaCompressionTypeEnumMap, json['type']), - name: json['name'] as String, - qualities: (json['qualities'] as List) - .map((e) => SpotubeAudioLossyContainerQuality.fromJson( - Map.from(e as Map))) - .toList(), - ); - -Map _$$SpotubeAudioSourceContainerPresetLossyImplToJson( - _$SpotubeAudioSourceContainerPresetLossyImpl instance) => - { - 'type': _$SpotubeMediaCompressionTypeEnumMap[instance.type]!, - 'name': instance.name, - 'qualities': instance.qualities.map((e) => e.toJson()).toList(), - }; - -const _$SpotubeMediaCompressionTypeEnumMap = { - SpotubeMediaCompressionType.lossy: 'lossy', - SpotubeMediaCompressionType.lossless: 'lossless', -}; - -_$SpotubeAudioSourceContainerPresetLosslessImpl - _$$SpotubeAudioSourceContainerPresetLosslessImplFromJson(Map json) => - _$SpotubeAudioSourceContainerPresetLosslessImpl( - type: $enumDecode(_$SpotubeMediaCompressionTypeEnumMap, json['type']), - name: json['name'] as String, - qualities: (json['qualities'] as List) - .map((e) => SpotubeAudioLosslessContainerQuality.fromJson( - Map.from(e as Map))) - .toList(), - ); - -Map _$$SpotubeAudioSourceContainerPresetLosslessImplToJson( - _$SpotubeAudioSourceContainerPresetLosslessImpl instance) => - { - 'type': _$SpotubeMediaCompressionTypeEnumMap[instance.type]!, - 'name': instance.name, - 'qualities': instance.qualities.map((e) => e.toJson()).toList(), - }; - -_$SpotubeAudioLossyContainerQualityImpl - _$$SpotubeAudioLossyContainerQualityImplFromJson(Map json) => - _$SpotubeAudioLossyContainerQualityImpl( - bitrate: (json['bitrate'] as num).toInt(), - ); - -Map _$$SpotubeAudioLossyContainerQualityImplToJson( - _$SpotubeAudioLossyContainerQualityImpl instance) => - { - 'bitrate': instance.bitrate, - }; - -_$SpotubeAudioLosslessContainerQualityImpl - _$$SpotubeAudioLosslessContainerQualityImplFromJson(Map json) => - _$SpotubeAudioLosslessContainerQualityImpl( - bitDepth: (json['bitDepth'] as num).toInt(), - sampleRate: (json['sampleRate'] as num).toInt(), - ); - -Map _$$SpotubeAudioLosslessContainerQualityImplToJson( - _$SpotubeAudioLosslessContainerQualityImpl instance) => - { - 'bitDepth': instance.bitDepth, - 'sampleRate': instance.sampleRate, - }; - -_$SpotubeAudioSourceMatchObjectImpl - _$$SpotubeAudioSourceMatchObjectImplFromJson(Map json) => - _$SpotubeAudioSourceMatchObjectImpl( - id: json['id'] as String, - title: json['title'] as String, - artists: (json['artists'] as List) - .map((e) => e as String) - .toList(), - duration: Duration(microseconds: (json['duration'] as num).toInt()), - thumbnail: json['thumbnail'] as String?, - externalUri: json['externalUri'] as String, - ); - -Map _$$SpotubeAudioSourceMatchObjectImplToJson( - _$SpotubeAudioSourceMatchObjectImpl instance) => - { - 'id': instance.id, - 'title': instance.title, - 'artists': instance.artists, - 'duration': instance.duration.inMicroseconds, - 'thumbnail': instance.thumbnail, - 'externalUri': instance.externalUri, - }; - -_$SpotubeAudioSourceStreamObjectImpl - _$$SpotubeAudioSourceStreamObjectImplFromJson(Map json) => - _$SpotubeAudioSourceStreamObjectImpl( - url: json['url'] as String, - container: json['container'] as String, - type: $enumDecode(_$SpotubeMediaCompressionTypeEnumMap, json['type']), - codec: json['codec'] as String?, - bitrate: (json['bitrate'] as num?)?.toDouble(), - bitDepth: (json['bitDepth'] as num?)?.toInt(), - sampleRate: (json['sampleRate'] as num?)?.toDouble(), - ); - -Map _$$SpotubeAudioSourceStreamObjectImplToJson( - _$SpotubeAudioSourceStreamObjectImpl instance) => - { - 'url': instance.url, - 'container': instance.container, - 'type': _$SpotubeMediaCompressionTypeEnumMap[instance.type]!, - 'codec': instance.codec, - 'bitrate': instance.bitrate, - 'bitDepth': instance.bitDepth, - 'sampleRate': instance.sampleRate, - }; - -_$SpotubeFullAlbumObjectImpl _$$SpotubeFullAlbumObjectImplFromJson(Map json) => - _$SpotubeFullAlbumObjectImpl( - id: json['id'] as String, - name: json['name'] as String, - artists: (json['artists'] as List) - .map((e) => SpotubeSimpleArtistObject.fromJson( - Map.from(e as Map))) - .toList(), - images: (json['images'] as List?) - ?.map((e) => SpotubeImageObject.fromJson( - Map.from(e as Map))) - .toList() ?? - const [], - releaseDate: json['releaseDate'] as String, - externalUri: json['externalUri'] as String, - totalTracks: (json['totalTracks'] as num).toInt(), - albumType: $enumDecode(_$SpotubeAlbumTypeEnumMap, json['albumType']), - recordLabel: json['recordLabel'] as String?, - genres: - (json['genres'] as List?)?.map((e) => e as String).toList(), - ); - -Map _$$SpotubeFullAlbumObjectImplToJson( - _$SpotubeFullAlbumObjectImpl instance) => - { - 'id': instance.id, - 'name': instance.name, - 'artists': instance.artists.map((e) => e.toJson()).toList(), - 'images': instance.images.map((e) => e.toJson()).toList(), - 'releaseDate': instance.releaseDate, - 'externalUri': instance.externalUri, - 'totalTracks': instance.totalTracks, - 'albumType': _$SpotubeAlbumTypeEnumMap[instance.albumType]!, - 'recordLabel': instance.recordLabel, - 'genres': instance.genres, - }; - -const _$SpotubeAlbumTypeEnumMap = { - SpotubeAlbumType.album: 'album', - SpotubeAlbumType.single: 'single', - SpotubeAlbumType.compilation: 'compilation', -}; - -_$SpotubeSimpleAlbumObjectImpl _$$SpotubeSimpleAlbumObjectImplFromJson( - Map json) => - _$SpotubeSimpleAlbumObjectImpl( - id: json['id'] as String, - name: json['name'] as String, - externalUri: json['externalUri'] as String, - artists: (json['artists'] as List) - .map((e) => SpotubeSimpleArtistObject.fromJson( - Map.from(e as Map))) - .toList(), - images: (json['images'] as List?) - ?.map((e) => SpotubeImageObject.fromJson( - Map.from(e as Map))) - .toList() ?? - const [], - albumType: $enumDecode(_$SpotubeAlbumTypeEnumMap, json['albumType']), - releaseDate: json['releaseDate'] as String?, - ); - -Map _$$SpotubeSimpleAlbumObjectImplToJson( - _$SpotubeSimpleAlbumObjectImpl instance) => - { - 'id': instance.id, - 'name': instance.name, - 'externalUri': instance.externalUri, - 'artists': instance.artists.map((e) => e.toJson()).toList(), - 'images': instance.images.map((e) => e.toJson()).toList(), - 'albumType': _$SpotubeAlbumTypeEnumMap[instance.albumType]!, - 'releaseDate': instance.releaseDate, - }; - -_$SpotubeFullArtistObjectImpl _$$SpotubeFullArtistObjectImplFromJson( - Map json) => - _$SpotubeFullArtistObjectImpl( - id: json['id'] as String, - name: json['name'] as String, - externalUri: json['externalUri'] as String, - images: (json['images'] as List?) - ?.map((e) => SpotubeImageObject.fromJson( - Map.from(e as Map))) - .toList() ?? - const [], - genres: - (json['genres'] as List?)?.map((e) => e as String).toList(), - followers: (json['followers'] as num?)?.toInt(), - ); - -Map _$$SpotubeFullArtistObjectImplToJson( - _$SpotubeFullArtistObjectImpl instance) => - { - 'id': instance.id, - 'name': instance.name, - 'externalUri': instance.externalUri, - 'images': instance.images.map((e) => e.toJson()).toList(), - 'genres': instance.genres, - 'followers': instance.followers, - }; - -_$SpotubeSimpleArtistObjectImpl _$$SpotubeSimpleArtistObjectImplFromJson( - Map json) => - _$SpotubeSimpleArtistObjectImpl( - id: json['id'] as String, - name: json['name'] as String, - externalUri: json['externalUri'] as String, - images: (json['images'] as List?) - ?.map((e) => - SpotubeImageObject.fromJson(Map.from(e as Map))) - .toList(), - ); - -Map _$$SpotubeSimpleArtistObjectImplToJson( - _$SpotubeSimpleArtistObjectImpl instance) => - { - 'id': instance.id, - 'name': instance.name, - 'externalUri': instance.externalUri, - 'images': instance.images?.map((e) => e.toJson()).toList(), - }; - -_$SpotubeBrowseSectionObjectImpl - _$$SpotubeBrowseSectionObjectImplFromJson( - Map json, - T Function(Object? json) fromJsonT, -) => - _$SpotubeBrowseSectionObjectImpl( - id: json['id'] as String, - title: json['title'] as String, - externalUri: json['externalUri'] as String, - browseMore: json['browseMore'] as bool, - items: (json['items'] as List).map(fromJsonT).toList(), - ); - -Map _$$SpotubeBrowseSectionObjectImplToJson( - _$SpotubeBrowseSectionObjectImpl instance, - Object? Function(T value) toJsonT, -) => - { - 'id': instance.id, - 'title': instance.title, - 'externalUri': instance.externalUri, - 'browseMore': instance.browseMore, - 'items': instance.items.map(toJsonT).toList(), - }; - -_$MetadataFormFieldInputObjectImpl _$$MetadataFormFieldInputObjectImplFromJson( - Map json) => - _$MetadataFormFieldInputObjectImpl( - objectType: json['objectType'] as String, - id: json['id'] as String, - variant: - $enumDecodeNullable(_$FormFieldVariantEnumMap, json['variant']) ?? - FormFieldVariant.text, - placeholder: json['placeholder'] as String?, - defaultValue: json['defaultValue'] as String?, - required: json['required'] as bool?, - regex: json['regex'] as String?, - ); - -Map _$$MetadataFormFieldInputObjectImplToJson( - _$MetadataFormFieldInputObjectImpl instance) => - { - 'objectType': instance.objectType, - 'id': instance.id, - 'variant': _$FormFieldVariantEnumMap[instance.variant]!, - 'placeholder': instance.placeholder, - 'defaultValue': instance.defaultValue, - 'required': instance.required, - 'regex': instance.regex, - }; - -const _$FormFieldVariantEnumMap = { - FormFieldVariant.text: 'text', - FormFieldVariant.password: 'password', - FormFieldVariant.number: 'number', -}; - -_$MetadataFormFieldTextObjectImpl _$$MetadataFormFieldTextObjectImplFromJson( - Map json) => - _$MetadataFormFieldTextObjectImpl( - objectType: json['objectType'] as String, - text: json['text'] as String, - ); - -Map _$$MetadataFormFieldTextObjectImplToJson( - _$MetadataFormFieldTextObjectImpl instance) => - { - 'objectType': instance.objectType, - 'text': instance.text, - }; - -_$SpotubeImageObjectImpl _$$SpotubeImageObjectImplFromJson(Map json) => - _$SpotubeImageObjectImpl( - url: json['url'] as String, - width: (json['width'] as num?)?.toInt(), - height: (json['height'] as num?)?.toInt(), - ); - -Map _$$SpotubeImageObjectImplToJson( - _$SpotubeImageObjectImpl instance) => - { - 'url': instance.url, - 'width': instance.width, - 'height': instance.height, - }; - -_$SpotubePaginationResponseObjectImpl - _$$SpotubePaginationResponseObjectImplFromJson( - Map json, - T Function(Object? json) fromJsonT, -) => - _$SpotubePaginationResponseObjectImpl( - limit: (json['limit'] as num).toInt(), - nextOffset: (json['nextOffset'] as num?)?.toInt(), - total: (json['total'] as num).toInt(), - hasMore: json['hasMore'] as bool, - items: (json['items'] as List).map(fromJsonT).toList(), - ); - -Map _$$SpotubePaginationResponseObjectImplToJson( - _$SpotubePaginationResponseObjectImpl instance, - Object? Function(T value) toJsonT, -) => - { - 'limit': instance.limit, - 'nextOffset': instance.nextOffset, - 'total': instance.total, - 'hasMore': instance.hasMore, - 'items': instance.items.map(toJsonT).toList(), - }; - -_$SpotubeFullPlaylistObjectImpl _$$SpotubeFullPlaylistObjectImplFromJson( - Map json) => - _$SpotubeFullPlaylistObjectImpl( - id: json['id'] as String, - name: json['name'] as String, - description: json['description'] as String, - externalUri: json['externalUri'] as String, - owner: SpotubeUserObject.fromJson( - Map.from(json['owner'] as Map)), - images: (json['images'] as List?) - ?.map((e) => SpotubeImageObject.fromJson( - Map.from(e as Map))) - .toList() ?? - const [], - collaborators: (json['collaborators'] as List?) - ?.map((e) => SpotubeUserObject.fromJson( - Map.from(e as Map))) - .toList() ?? - const [], - collaborative: json['collaborative'] as bool? ?? false, - public: json['public'] as bool? ?? false, - ); - -Map _$$SpotubeFullPlaylistObjectImplToJson( - _$SpotubeFullPlaylistObjectImpl instance) => - { - 'id': instance.id, - 'name': instance.name, - 'description': instance.description, - 'externalUri': instance.externalUri, - 'owner': instance.owner.toJson(), - 'images': instance.images.map((e) => e.toJson()).toList(), - 'collaborators': instance.collaborators.map((e) => e.toJson()).toList(), - 'collaborative': instance.collaborative, - 'public': instance.public, - }; - -_$SpotubeSimplePlaylistObjectImpl _$$SpotubeSimplePlaylistObjectImplFromJson( - Map json) => - _$SpotubeSimplePlaylistObjectImpl( - id: json['id'] as String, - name: json['name'] as String, - description: json['description'] as String, - externalUri: json['externalUri'] as String, - owner: SpotubeUserObject.fromJson( - Map.from(json['owner'] as Map)), - images: (json['images'] as List?) - ?.map((e) => SpotubeImageObject.fromJson( - Map.from(e as Map))) - .toList() ?? - const [], - ); - -Map _$$SpotubeSimplePlaylistObjectImplToJson( - _$SpotubeSimplePlaylistObjectImpl instance) => - { - 'id': instance.id, - 'name': instance.name, - 'description': instance.description, - 'externalUri': instance.externalUri, - 'owner': instance.owner.toJson(), - 'images': instance.images.map((e) => e.toJson()).toList(), - }; - -_$SpotubeSearchResponseObjectImpl _$$SpotubeSearchResponseObjectImplFromJson( - Map json) => - _$SpotubeSearchResponseObjectImpl( - albums: (json['albums'] as List) - .map((e) => SpotubeSimpleAlbumObject.fromJson( - Map.from(e as Map))) - .toList(), - artists: (json['artists'] as List) - .map((e) => SpotubeFullArtistObject.fromJson( - Map.from(e as Map))) - .toList(), - playlists: (json['playlists'] as List) - .map((e) => SpotubeSimplePlaylistObject.fromJson( - Map.from(e as Map))) - .toList(), - tracks: (json['tracks'] as List) - .map((e) => SpotubeFullTrackObject.fromJson( - Map.from(e as Map))) - .toList(), - ); - -Map _$$SpotubeSearchResponseObjectImplToJson( - _$SpotubeSearchResponseObjectImpl instance) => - { - 'albums': instance.albums.map((e) => e.toJson()).toList(), - 'artists': instance.artists.map((e) => e.toJson()).toList(), - 'playlists': instance.playlists.map((e) => e.toJson()).toList(), - 'tracks': instance.tracks.map((e) => e.toJson()).toList(), - }; - -_$SpotubeLocalTrackObjectImpl _$$SpotubeLocalTrackObjectImplFromJson( - Map json) => - _$SpotubeLocalTrackObjectImpl( - id: json['id'] as String, - name: json['name'] as String, - externalUri: json['externalUri'] as String, - artists: (json['artists'] as List?) - ?.map((e) => SpotubeSimpleArtistObject.fromJson( - Map.from(e as Map))) - .toList() ?? - const [], - album: SpotubeSimpleAlbumObject.fromJson( - Map.from(json['album'] as Map)), - durationMs: (json['durationMs'] as num).toInt(), - path: json['path'] as String, - $type: json['runtimeType'] as String?, - ); - -Map _$$SpotubeLocalTrackObjectImplToJson( - _$SpotubeLocalTrackObjectImpl instance) => - { - 'id': instance.id, - 'name': instance.name, - 'externalUri': instance.externalUri, - 'artists': instance.artists.map((e) => e.toJson()).toList(), - 'album': instance.album.toJson(), - 'durationMs': instance.durationMs, - 'path': instance.path, - 'runtimeType': instance.$type, - }; - -_$SpotubeFullTrackObjectImpl _$$SpotubeFullTrackObjectImplFromJson(Map json) => - _$SpotubeFullTrackObjectImpl( - id: json['id'] as String, - name: json['name'] as String, - externalUri: json['externalUri'] as String, - artists: (json['artists'] as List?) - ?.map((e) => SpotubeSimpleArtistObject.fromJson( - Map.from(e as Map))) - .toList() ?? - const [], - album: SpotubeSimpleAlbumObject.fromJson( - Map.from(json['album'] as Map)), - durationMs: (json['durationMs'] as num).toInt(), - isrc: json['isrc'] as String, - explicit: json['explicit'] as bool, - $type: json['runtimeType'] as String?, - ); - -Map _$$SpotubeFullTrackObjectImplToJson( - _$SpotubeFullTrackObjectImpl instance) => - { - 'id': instance.id, - 'name': instance.name, - 'externalUri': instance.externalUri, - 'artists': instance.artists.map((e) => e.toJson()).toList(), - 'album': instance.album.toJson(), - 'durationMs': instance.durationMs, - 'isrc': instance.isrc, - 'explicit': instance.explicit, - 'runtimeType': instance.$type, - }; - -_$SpotubeUserObjectImpl _$$SpotubeUserObjectImplFromJson(Map json) => - _$SpotubeUserObjectImpl( - id: json['id'] as String, - name: json['name'] as String, - images: (json['images'] as List?) - ?.map((e) => SpotubeImageObject.fromJson( - Map.from(e as Map))) - .toList() ?? - const [], - externalUri: json['externalUri'] as String, - ); - -Map _$$SpotubeUserObjectImplToJson( - _$SpotubeUserObjectImpl instance) => - { - 'id': instance.id, - 'name': instance.name, - 'images': instance.images.map((e) => e.toJson()).toList(), - 'externalUri': instance.externalUri, - }; - -_$PluginConfigurationImpl _$$PluginConfigurationImplFromJson(Map json) => - _$PluginConfigurationImpl( - name: json['name'] as String, - description: json['description'] as String, - version: json['version'] as String, - author: json['author'] as String, - entryPoint: json['entryPoint'] as String, - pluginApiVersion: json['pluginApiVersion'] as String, - apis: (json['apis'] as List?) - ?.map((e) => $enumDecode(_$PluginApisEnumMap, e)) - .toList() ?? - const [], - abilities: (json['abilities'] as List?) - ?.map((e) => $enumDecode(_$PluginAbilitiesEnumMap, e)) - .toList() ?? - const [], - repository: json['repository'] as String?, - ); - -Map _$$PluginConfigurationImplToJson( - _$PluginConfigurationImpl instance) => - { - 'name': instance.name, - 'description': instance.description, - 'version': instance.version, - 'author': instance.author, - 'entryPoint': instance.entryPoint, - 'pluginApiVersion': instance.pluginApiVersion, - 'apis': instance.apis.map((e) => _$PluginApisEnumMap[e]!).toList(), - 'abilities': - instance.abilities.map((e) => _$PluginAbilitiesEnumMap[e]!).toList(), - 'repository': instance.repository, - }; - -const _$PluginApisEnumMap = { - PluginApis.webview: 'webview', - PluginApis.localstorage: 'localstorage', - PluginApis.timezone: 'timezone', -}; - -const _$PluginAbilitiesEnumMap = { - PluginAbilities.authentication: 'authentication', - PluginAbilities.scrobbling: 'scrobbling', - PluginAbilities.metadata: 'metadata', - PluginAbilities.audioSource: 'audio-source', -}; - -_$PluginUpdateAvailableImpl _$$PluginUpdateAvailableImplFromJson(Map json) => - _$PluginUpdateAvailableImpl( - downloadUrl: json['downloadUrl'] as String, - version: json['version'] as String, - changelog: json['changelog'] as String?, - ); - -Map _$$PluginUpdateAvailableImplToJson( - _$PluginUpdateAvailableImpl instance) => - { - 'downloadUrl': instance.downloadUrl, - 'version': instance.version, - 'changelog': instance.changelog, - }; - -_$MetadataPluginRepositoryImpl _$$MetadataPluginRepositoryImplFromJson( - Map json) => - _$MetadataPluginRepositoryImpl( - name: json['name'] as String, - owner: json['owner'] as String, - description: json['description'] as String, - repoUrl: json['repoUrl'] as String, - topics: - (json['topics'] as List).map((e) => e as String).toList(), - ); - -Map _$$MetadataPluginRepositoryImplToJson( - _$MetadataPluginRepositoryImpl instance) => - { - 'name': instance.name, - 'owner': instance.owner, - 'description': instance.description, - 'repoUrl': instance.repoUrl, - 'topics': instance.topics, - }; diff --git a/lib/models/metadata/pagination.dart b/lib/models/metadata/pagination.dart deleted file mode 100644 index 093c1d2b..00000000 --- a/lib/models/metadata/pagination.dart +++ /dev/null @@ -1,22 +0,0 @@ -part of 'metadata.dart'; - -@Freezed(genericArgumentFactories: true) -class SpotubePaginationResponseObject - with _$SpotubePaginationResponseObject { - factory SpotubePaginationResponseObject({ - required int limit, - required int? nextOffset, - required int total, - required bool hasMore, - required List items, - }) = _SpotubePaginationResponseObject; - - factory SpotubePaginationResponseObject.fromJson( - Map json, - T Function(Map json) fromJsonT, - ) => - _$SpotubePaginationResponseObjectFromJson( - json, - (json) => fromJsonT(json as Map), - ); -} diff --git a/lib/models/metadata/playlist.dart b/lib/models/metadata/playlist.dart deleted file mode 100644 index 5bb8f1ae..00000000 --- a/lib/models/metadata/playlist.dart +++ /dev/null @@ -1,34 +0,0 @@ -part of 'metadata.dart'; - -@freezed -class SpotubeFullPlaylistObject with _$SpotubeFullPlaylistObject { - factory SpotubeFullPlaylistObject({ - required String id, - required String name, - required String description, - required String externalUri, - required SpotubeUserObject owner, - @Default([]) List images, - @Default([]) List collaborators, - @Default(false) bool collaborative, - @Default(false) bool public, - }) = _SpotubeFullPlaylistObject; - - factory SpotubeFullPlaylistObject.fromJson(Map json) => - _$SpotubeFullPlaylistObjectFromJson(json); -} - -@freezed -class SpotubeSimplePlaylistObject with _$SpotubeSimplePlaylistObject { - factory SpotubeSimplePlaylistObject({ - required String id, - required String name, - required String description, - required String externalUri, - required SpotubeUserObject owner, - @Default([]) List images, - }) = _SpotubeSimplePlaylistObject; - - factory SpotubeSimplePlaylistObject.fromJson(Map json) => - _$SpotubeSimplePlaylistObjectFromJson(json); -} diff --git a/lib/models/metadata/plugin.dart b/lib/models/metadata/plugin.dart deleted file mode 100644 index 6bc84160..00000000 --- a/lib/models/metadata/plugin.dart +++ /dev/null @@ -1,45 +0,0 @@ -part of 'metadata.dart'; - -enum PluginApis { webview, localstorage, timezone } - -enum PluginAbilities { - authentication, - scrobbling, - metadata, - @JsonValue('audio-source') - audioSource, -} - -@freezed -class PluginConfiguration with _$PluginConfiguration { - const PluginConfiguration._(); - - factory PluginConfiguration({ - required String name, - required String description, - required String version, - required String author, - required String entryPoint, - required String pluginApiVersion, - @Default([]) List apis, - @Default([]) List abilities, - String? repository, - }) = _PluginConfiguration; - - factory PluginConfiguration.fromJson(Map json) => - _$PluginConfigurationFromJson(json); - - String get slug => name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '-'); -} - -@freezed -class PluginUpdateAvailable with _$PluginUpdateAvailable { - factory PluginUpdateAvailable({ - required String downloadUrl, - required String version, - String? changelog, - }) = _PluginUpdateAvailable; - - factory PluginUpdateAvailable.fromJson(Map json) => - _$PluginUpdateAvailableFromJson(json); -} diff --git a/lib/models/metadata/repository.dart b/lib/models/metadata/repository.dart deleted file mode 100644 index 2a83f791..00000000 --- a/lib/models/metadata/repository.dart +++ /dev/null @@ -1,15 +0,0 @@ -part of './metadata.dart'; - -@freezed -class MetadataPluginRepository with _$MetadataPluginRepository { - factory MetadataPluginRepository({ - required String name, - required String owner, - required String description, - required String repoUrl, - required List topics, - }) = _MetadataPluginRepository; - - factory MetadataPluginRepository.fromJson(Map json) => - _$MetadataPluginRepositoryFromJson(json); -} diff --git a/lib/models/metadata/search.dart b/lib/models/metadata/search.dart deleted file mode 100644 index b39f063a..00000000 --- a/lib/models/metadata/search.dart +++ /dev/null @@ -1,14 +0,0 @@ -part of 'metadata.dart'; - -@freezed -class SpotubeSearchResponseObject with _$SpotubeSearchResponseObject { - factory SpotubeSearchResponseObject({ - required List albums, - required List artists, - required List playlists, - required List tracks, - }) = _SpotubeSearchResponseObject; - - factory SpotubeSearchResponseObject.fromJson(Map json) => - _$SpotubeSearchResponseObjectFromJson(json); -} diff --git a/lib/models/metadata/track.dart b/lib/models/metadata/track.dart deleted file mode 100644 index ecf7f0a2..00000000 --- a/lib/models/metadata/track.dart +++ /dev/null @@ -1,119 +0,0 @@ -part of 'metadata.dart'; - -@freezed -class SpotubeTrackObject with _$SpotubeTrackObject { - factory SpotubeTrackObject.local({ - required String id, - required String name, - required String externalUri, - @Default([]) List artists, - required SpotubeSimpleAlbumObject album, - required int durationMs, - required String path, - }) = SpotubeLocalTrackObject; - - factory SpotubeTrackObject.full({ - required String id, - required String name, - required String externalUri, - @Default([]) List artists, - required SpotubeSimpleAlbumObject album, - required int durationMs, - required String isrc, - required bool explicit, - }) = SpotubeFullTrackObject; - - factory SpotubeTrackObject.localTrackFromFile( - File file, { - Metadata? metadata, - String? art, - }) { - return SpotubeLocalTrackObject( - id: file.absolute.path, - name: metadata?.title ?? basenameWithoutExtension(file.path), - externalUri: "file://${file.absolute.path}", - artists: metadata?.artist?.split(",").map((a) { - return SpotubeSimpleArtistObject( - id: a.trim(), - name: a.trim(), - externalUri: "file://${file.absolute.path}", - ); - }).toList() ?? - [ - SpotubeSimpleArtistObject( - id: "unknown", - name: "Unknown Artist", - externalUri: "file://${file.absolute.path}", - ), - ], - album: SpotubeSimpleAlbumObject( - albumType: SpotubeAlbumType.album, - id: metadata?.album ?? "unknown", - name: metadata?.album ?? "Unknown Album", - externalUri: "file://${file.absolute.path}", - artists: [ - SpotubeSimpleArtistObject( - id: metadata?.albumArtist ?? "unknown", - name: metadata?.albumArtist ?? "Unknown Artist", - externalUri: "file://${file.absolute.path}", - ), - ], - releaseDate: - metadata?.year != null ? "${metadata!.year}-01-01" : "1970-01-01", - images: [ - if (art != null) - SpotubeImageObject( - url: art, - width: 300, - height: 300, - ), - ], - ), - durationMs: metadata?.durationMs?.toInt() ?? 0, - path: file.path, - ); - } - - factory SpotubeTrackObject.fromJson(Map json) => - _$SpotubeTrackObjectFromJson( - json.containsKey("path") - ? {...json, "runtimeType": "local"} - : {...json, "runtimeType": "full"}, - ); -} - -extension AsMediaListSpotubeTrackObject on Iterable { - List asMediaList() { - return map((track) => SpotubeMedia(track)).toList(); - } -} - -extension ToMetadataSpotubeFullTrackObject on SpotubeFullTrackObject { - Metadata toMetadata({ - required int fileLength, - Uint8List? imageBytes, - String? mimeType, - }) { - return Metadata( - title: name, - artist: artists.map((a) => a.name).join(", "), - album: album.name, - albumArtist: artists.map((a) => a.name).join(", "), - year: album.releaseDate == null - ? 1970 - : DateTime.tryParse(album.releaseDate!)?.year ?? - int.tryParse(album.releaseDate!) ?? - 1970, - durationMs: durationMs.toDouble(), - fileSize: BigInt.from(fileLength), - picture: imageBytes != null - ? Picture( - data: imageBytes, - mimeType: mimeType ?? - lookupMimeType("", headerBytes: imageBytes) ?? - "image/jpeg", - ) - : null, - ); - } -} diff --git a/lib/models/metadata/user.dart b/lib/models/metadata/user.dart deleted file mode 100644 index cd041f9c..00000000 --- a/lib/models/metadata/user.dart +++ /dev/null @@ -1,14 +0,0 @@ -part of 'metadata.dart'; - -@freezed -class SpotubeUserObject with _$SpotubeUserObject { - factory SpotubeUserObject({ - required final String id, - required final String name, - @Default([]) final List images, - required final String externalUri, - }) = _SpotubeUserObject; - - factory SpotubeUserObject.fromJson(Map json) => - _$SpotubeUserObjectFromJson(json); -} diff --git a/lib/models/parser/range_headers.dart b/lib/models/parser/range_headers.dart deleted file mode 100644 index 08025cbf..00000000 --- a/lib/models/parser/range_headers.dart +++ /dev/null @@ -1,71 +0,0 @@ -class ContentRangeHeader { - final int start; - final int end; - final int total; - - ContentRangeHeader(this.start, this.end, this.total); - - factory ContentRangeHeader.parse(String value) { - if (value.isEmpty) { - throw FormatException('Invalid Content-Range header: $value'); - } - - final parts = value.split(' '); - if (parts.length != 2) { - throw FormatException('Invalid Content-Range header: $value'); - } - - final rangeParts = parts[1].split('/'); - if (rangeParts.length != 2) { - throw FormatException('Invalid Content-Range header: $value'); - } - - final range = rangeParts[0].split('-'); - if (range.length != 2) { - throw FormatException('Invalid Content-Range header: $value'); - } - - return ContentRangeHeader( - int.parse(range[0]), - int.parse(range[1]), - int.parse(rangeParts[1]), - ); - } - - @override - String toString() { - return 'bytes $start-$end/$total'; - } -} - -class RangeHeader { - final int start; - final int? end; - - RangeHeader(this.start, this.end); - - factory RangeHeader.parse(String value) { - if (value.isEmpty) { - return RangeHeader(0, null); - } - - final parts = value.split('='); - if (parts.length != 2) { - throw FormatException('Invalid Range header: $value'); - } - - final ranges = parts[1].split('-'); - - return RangeHeader( - int.parse(ranges[0]), - ranges.elementAtOrNull(1) != null && ranges[1].isNotEmpty - ? int.parse(ranges[1]) - : null, - ); - } - - @override - String toString() { - return 'bytes=$start-${end ?? ""}'; - } -} diff --git a/lib/models/playback/track_sources.dart b/lib/models/playback/track_sources.dart deleted file mode 100644 index 677b34b8..00000000 --- a/lib/models/playback/track_sources.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -part 'track_sources.g.dart'; - -@JsonSerializable() -class BasicSourcedTrack { - final SpotubeFullTrackObject query; - final SpotubeAudioSourceMatchObject info; - final String source; - final List sources; - final List siblings; - BasicSourcedTrack({ - required this.query, - required this.source, - required this.info, - required this.sources, - this.siblings = const [], - }); - - factory BasicSourcedTrack.fromJson(Map json) => - _$BasicSourcedTrackFromJson(json); - Map toJson() => _$BasicSourcedTrackToJson(this); -} diff --git a/lib/models/playback/track_sources.g.dart b/lib/models/playback/track_sources.g.dart deleted file mode 100644 index 3088493a..00000000 --- a/lib/models/playback/track_sources.g.dart +++ /dev/null @@ -1,33 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'track_sources.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -BasicSourcedTrack _$BasicSourcedTrackFromJson(Map json) => BasicSourcedTrack( - query: SpotubeFullTrackObject.fromJson( - Map.from(json['query'] as Map)), - source: json['source'] as String, - info: SpotubeAudioSourceMatchObject.fromJson( - Map.from(json['info'] as Map)), - sources: (json['sources'] as List) - .map((e) => SpotubeAudioSourceStreamObject.fromJson( - Map.from(e as Map))) - .toList(), - siblings: (json['siblings'] as List?) - ?.map((e) => SpotubeAudioSourceMatchObject.fromJson( - Map.from(e as Map))) - .toList() ?? - const [], - ); - -Map _$BasicSourcedTrackToJson(BasicSourcedTrack instance) => - { - 'query': instance.query.toJson(), - 'info': instance.info.toJson(), - 'source': instance.source, - 'sources': instance.sources.map((e) => e.toJson()).toList(), - 'siblings': instance.siblings.map((e) => e.toJson()).toList(), - }; diff --git a/lib/modules/album/album_card.dart b/lib/modules/album/album_card.dart deleted file mode 100644 index 80dfd55b..00000000 --- a/lib/modules/album/album_card.dart +++ /dev/null @@ -1,188 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/playbutton_view/playbutton_card.dart'; -import 'package:spotube/components/playbutton_view/playbutton_tile.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/querying_track_info.dart'; -import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/metadata_plugin/tracks/album.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; - -extension FormattedAlbumType on SpotubeAlbumType { - String get formatted => name.replaceFirst(name[0], name[0].toUpperCase()); -} - -class AlbumCard extends HookConsumerWidget { - final SpotubeSimpleAlbumObject album; - final bool _isTile; - const AlbumCard( - this.album, { - super.key, - }) : _isTile = false; - - const AlbumCard.tile( - this.album, { - super.key, - }) : _isTile = true; - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch(audioPlayerProvider); - final playing = - useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.read(playbackHistoryActionsProvider); - final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); - - final isPlaylistPlaying = useMemoized( - () => playlist.containsCollection(album.id), - [playlist, album.id], - ); - - final updating = useState(false); - - final fetchAllTrack = useCallback(() async { - await ref.read(metadataPluginAlbumTracksProvider(album.id).future); - return ref - .read(metadataPluginAlbumTracksProvider(album.id).notifier) - .fetchAll(); - }, [album.id, ref]); - - final imageUrl = useMemoized( - () => album.images.from200PxTo300PxOrSmallestImage( - ImagePlaceholder.collection, - ), - [album.images], - ); - - final isLoading = - (isPlaylistPlaying && isFetchingActiveTrack) || updating.value; - final description = "${album.albumType.name} • ${album.artists.asString()}"; - - final onTap = useCallback(() { - context.navigateTo(AlbumRoute(id: album.id, album: album)); - }, [context, album]); - - final onPlaybuttonPressed = useCallback(() async { - updating.value = true; - try { - if (isPlaylistPlaying) { - return playing ? audioPlayer.pause() : audioPlayer.resume(); - } - - final fetchedTracks = await fetchAllTrack(); - - if (fetchedTracks.isEmpty || !context.mounted) return; - - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - if (isRemoteDevice == null) return; - if (isRemoteDevice) { - final remotePlayback = ref.read(connectProvider.notifier); - await remotePlayback.load( - WebSocketLoadEventData.album( - tracks: fetchedTracks, - collection: album, - ), - ); - } else { - await playlistNotifier.load(fetchedTracks, autoPlay: true); - playlistNotifier.addCollection(album.id); - historyNotifier.addAlbums([album]); - } - } finally { - updating.value = false; - } - }, [ - isPlaylistPlaying, - playing, - audioPlayer, - fetchAllTrack, - context, - ref, - playlistNotifier, - album, - historyNotifier, - updating - ]); - - final onAddToQueuePressed = useCallback(() async { - if (isPlaylistPlaying) { - return; - } - - updating.value = true; - try { - final fetchedTracks = await fetchAllTrack(); - - if (fetchedTracks.isEmpty) return; - playlistNotifier.addTracks(fetchedTracks); - playlistNotifier.addCollection(album.id); - historyNotifier.addAlbums([album]); - if (context.mounted) { - showToast( - context: context, - builder: (context, overlay) { - return SurfaceCard( - child: Basic( - content: Text( - context.l10n.added_to_queue(fetchedTracks.length), - ), - trailing: Button.outline( - child: Text(context.l10n.undo), - onPressed: () { - playlistNotifier - .removeTracks(fetchedTracks.map((e) => e.id)); - }, - ), - ), - ); - }, - ); - } - } finally { - updating.value = false; - } - }, [ - isPlaylistPlaying, - updating.value, - fetchAllTrack, - playlistNotifier, - album.id, - historyNotifier, - album, - context - ]); - - if (_isTile) { - return PlaybuttonTile( - imageUrl: imageUrl, - isPlaying: isPlaylistPlaying, - isLoading: isLoading, - title: album.name, - description: description, - onTap: onTap, - onPlaybuttonPressed: onPlaybuttonPressed, - onAddToQueuePressed: onAddToQueuePressed, - ); - } - - return PlaybuttonCard( - imageUrl: imageUrl, - isPlaying: isPlaylistPlaying, - isLoading: isLoading, - title: album.name, - description: description, - onTap: onTap, - onPlaybuttonPressed: onPlaybuttonPressed, - onAddToQueuePressed: onAddToQueuePressed, - ); - } -} diff --git a/lib/modules/artist/artist_album_list.dart b/lib/modules/artist/artist_album_list.dart deleted file mode 100644 index 8d228905..00000000 --- a/lib/modules/artist/artist_album_list.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/artist/albums.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; - -class ArtistAlbumList extends HookConsumerWidget { - final String artistId; - - const ArtistAlbumList( - this.artistId, { - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final albumsQuery = ref.watch(metadataPluginArtistAlbumsProvider(artistId)); - final albumsQueryNotifier = - ref.watch(metadataPluginArtistAlbumsProvider(artistId).notifier); - - final albums = albumsQuery.asData?.value.items ?? []; - - final theme = Theme.of(context); - - return HorizontalPlaybuttonCardView( - isLoadingNextPage: albumsQuery.isLoadingNextPage, - hasNextPage: albumsQuery.asData?.value.hasMore ?? false, - items: albums, - onFetchMore: albumsQueryNotifier.fetchMore, - title: Text( - context.l10n.albums, - style: theme.typography.h4, - ), - ); - } -} diff --git a/lib/modules/artist/artist_card.dart b/lib/modules/artist/artist_card.dart deleted file mode 100644 index d9e01206..00000000 --- a/lib/modules/artist/artist_card.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:auto_size_text/auto_size_text.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -import 'package:spotube/provider/blacklist_provider.dart'; - -class ArtistCard extends HookConsumerWidget { - final SpotubeFullArtistObject artist; - const ArtistCard(this.artist, {super.key}); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final backgroundImage = UniversalImage.imageProvider( - artist.images.asUrlString( - placeholder: ImagePlaceholder.artist, - ), - ); - final isBlackListed = ref.watch( - blacklistProvider.select( - (blacklist) => blacklist.asData?.value.any( - (element) => element.elementId == artist.id, - ), - ), - ); - - return SizedBox( - width: 180, - child: Button.card( - onPressed: () { - context.navigateTo(ArtistRoute(artistId: artist.id)); - }, - child: Column( - children: [ - Avatar( - initials: artist.name.trim()[0].toUpperCase(), - provider: backgroundImage, - size: 130, - ), - const Gap(10), - AutoSizeText( - artist.name, - maxLines: 2, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: theme.typography.bold, - ), - const Spacer(), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (isBlackListed == true) ...[ - DestructiveBadge( - child: Text(context.l10n.blacklisted.toUpperCase()), - ), - const Gap(5), - ], - SecondaryBadge( - child: Text(context.l10n.artist.toUpperCase()), - ) - ], - ) - ], - ), - ), - ); - } -} diff --git a/lib/modules/connect/connect_device.dart b/lib/modules/connect/connect_device.dart deleted file mode 100644 index 2c8d612b..00000000 --- a/lib/modules/connect/connect_device.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/connect/clients.dart'; - -class ConnectDeviceButton extends HookConsumerWidget { - final bool _sidebar; - const ConnectDeviceButton({super.key}) : _sidebar = false; - const ConnectDeviceButton.sidebar({super.key}) : _sidebar = true; - - @override - Widget build(BuildContext context, ref) { - final connectClients = ref.watch(connectClientsProvider); - - final hasServices = - connectClients.asData?.value.services.isNotEmpty == true; - - if (_sidebar) { - final mediaQuery = MediaQuery.sizeOf(context); - - if (mediaQuery.mdAndDown) { - return IconButton.ghost( - icon: const Icon(SpotubeIcons.speaker), - onPressed: () { - context.navigateTo(const ConnectRoute()); - }, - ); - } - - return SizedBox( - width: double.infinity, - child: Button.primary( - onPressed: () { - context.navigateTo(const ConnectRoute()); - }, - trailing: const Icon(SpotubeIcons.speaker), - child: Text( - "${context.l10n.devices}" - "${hasServices ? " (${connectClients.asData?.value.services.length})" : ""}", - ), - ), - ); - } - - return Row( - children: [ - SecondaryBadge( - onPressed: () { - context.navigateTo(const ConnectRoute()); - }, - style: const ButtonStyle.secondary(size: ButtonSize(.8)), - leading: connectClients.asData?.value.resolvedService != null - ? const Center( - child: DotItem( - size: 6, - borderRadius: 10, - color: Colors.green, - ), - ) - : null, - child: Text( - "${context.l10n.devices}" - "${hasServices ? " (${connectClients.asData?.value.services.length})" : ""}", - ), - ), - IconButton.primary( - icon: const Icon(SpotubeIcons.speaker), - onPressed: () { - context.navigateTo(const ConnectRoute()); - }, - ) - ], - ); - } -} diff --git a/lib/modules/connect/local_devices.dart b/lib/modules/connect/local_devices.dart deleted file mode 100644 index dc192e44..00000000 --- a/lib/modules/connect/local_devices.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/ui/button_tile.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; - -class ConnectPageLocalDevices extends HookWidget { - const ConnectPageLocalDevices({super.key}); - - @override - Widget build(BuildContext context) { - final ThemeData(:typography) = Theme.of(context); - final devicesFuture = useFuture(audioPlayer.devices); - final devicesStream = useStream(audioPlayer.devicesStream); - final selectedDeviceFuture = useFuture(audioPlayer.selectedDevice); - final selectedDeviceStream = useStream(audioPlayer.selectedDeviceStream); - - final devices = devicesStream.data ?? devicesFuture.data; - final selectedDevice = - selectedDeviceStream.data ?? selectedDeviceFuture.data; - - if (devices == null) { - return const SliverToBoxAdapter(child: SizedBox.shrink()); - } - - return SliverMainAxisGroup( - slivers: [ - const SliverGap(10), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - sliver: SliverToBoxAdapter( - child: Text( - context.l10n.this_device, - style: typography.bold, - ), - ), - ), - const SliverGap(10), - SliverList.separated( - itemCount: devices.length, - separatorBuilder: (context, index) => const Gap(10), - itemBuilder: (context, index) { - final device = devices[index]; - - return ButtonTile( - selected: selectedDevice == device, - onPressed: () => audioPlayer.setAudioDevice(device), - leading: const Icon(SpotubeIcons.speaker), - title: Text(device.description), - subtitle: Text(device.name), - ); - }, - ), - const SliverGap(200) - ], - ); - } -} diff --git a/lib/modules/getting_started/blur_card.dart b/lib/modules/getting_started/blur_card.dart deleted file mode 100644 index 6434c0a3..00000000 --- a/lib/modules/getting_started/blur_card.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -class BlurCard extends HookConsumerWidget { - final Widget child; - const BlurCard({super.key, required this.child}); - - @override - Widget build(BuildContext context, ref) { - return Container( - margin: const EdgeInsets.all(16.0), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - ), - constraints: const BoxConstraints(maxWidth: 400), - clipBehavior: Clip.antiAlias, - child: SizedBox( - width: double.infinity, - child: SurfaceCard( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: child, - ), - ), - ), - ); - } -} diff --git a/lib/modules/home/sections/featured.dart b/lib/modules/home/sections/featured.dart deleted file mode 100644 index c65ebf89..00000000 --- a/lib/modules/home/sections/featured.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter_undraw/flutter_undraw.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/extensions/context.dart'; - -@Deprecated( - "Later a featured playlists API will be added for metadata plugins.") -class HomeFeaturedSection extends HookConsumerWidget { - const HomeFeaturedSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - return const SizedBox.shrink(); - // final featuredPlaylists = ref.watch(featuredPlaylistsProvider); - // final featuredPlaylistsNotifier = - // ref.watch(featuredPlaylistsProvider.notifier); - - // if (featuredPlaylists.hasError) { - // return Column( - // mainAxisSize: MainAxisSize.min, - // children: [ - // Undraw( - // illustration: UndrawIllustration.fixingBugs, - // height: 200 * context.theme.scaling, - // color: context.theme.colorScheme.primary, - // ), - // Text(context.l10n.something_went_wrong).small().muted(), - // const Gap(8), - // ], - // ); - // } - - // return Skeletonizer( - // enabled: featuredPlaylists.isLoading, - // child: HorizontalPlaybuttonCardView( - // items: featuredPlaylists.asData?.value.items ?? [], - // title: Text(context.l10n.featured), - // isLoadingNextPage: featuredPlaylists.isLoadingNextPage, - // hasNextPage: featuredPlaylists.asData?.value.hasMore ?? false, - // onFetchMore: featuredPlaylistsNotifier.fetchMore, - // ), - // ); - } -} diff --git a/lib/modules/home/sections/new_releases.dart b/lib/modules/home/sections/new_releases.dart deleted file mode 100644 index be6d335d..00000000 --- a/lib/modules/home/sections/new_releases.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/fallbacks/error_box.dart'; -import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/album/releases.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; - -class HomeNewReleasesSection extends HookConsumerWidget { - const HomeNewReleasesSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final authenticated = ref.watch(metadataPluginAuthenticatedProvider); - - final newReleases = ref.watch(metadataPluginAlbumReleasesProvider); - final newReleasesNotifier = - ref.read(metadataPluginAlbumReleasesProvider.notifier); - - if (authenticated.asData?.value != true || - newReleases.isLoading || - newReleases.asData?.value.items.isEmpty == true) { - return const SizedBox.shrink(); - } - - if (newReleases.error - case MetadataPluginException( - errorCode: MetadataPluginErrorCode.noDefaultMetadataPlugin, - message: _, - )) { - return const SizedBox.shrink(); - } - - return HorizontalPlaybuttonCardView( - items: newReleases.asData?.value.items ?? [], - title: Text(context.l10n.new_releases), - isLoadingNextPage: newReleases.isLoadingNextPage, - hasNextPage: newReleases.asData?.value.hasMore ?? false, - onFetchMore: newReleasesNotifier.fetchMore, - error: newReleases.hasError - ? Center( - child: ErrorBox( - error: newReleases.error!, - onRetry: () { - ref.invalidate(metadataPluginAlbumReleasesProvider); - }, - ), - ) - : null, - ); - } -} diff --git a/lib/modules/home/sections/recent.dart b/lib/modules/home/sections/recent.dart deleted file mode 100644 index 5420ad55..00000000 --- a/lib/modules/home/sections/recent.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/provider/history/recent.dart'; - -class HomeRecentlyPlayedSection extends HookConsumerWidget { - const HomeRecentlyPlayedSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final history = ref.watch(recentlyPlayedItems); - final historyData = - history.asData?.value ?? FakeData.historyRecentlyPlayedItems; - - if (history.asData?.value.isEmpty == true) { - return const SizedBox(); - } - - return Skeletonizer( - enabled: history.isLoading, - child: HorizontalPlaybuttonCardView( - title: Text(context.l10n.recently_played), - items: [ - for (final item in historyData) - if (item.playlist != null) - item.playlist - else if (item.album != null) - item.album - ], - hasNextPage: false, - isLoadingNextPage: false, - onFetchMore: () {}, - ), - ); - } -} diff --git a/lib/modules/home/sections/sections.dart b/lib/modules/home/sections/sections.dart deleted file mode 100644 index 93055b74..00000000 --- a/lib/modules/home/sections/sections.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/components/fallbacks/error_box.dart'; -import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart'; -import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/metadata_plugin/browse/sections.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; -import 'package:flutter_undraw/flutter_undraw.dart'; - -class HomePageBrowseSection extends HookConsumerWidget { - const HomePageBrowseSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final browseSections = ref.watch(metadataPluginBrowseSectionsProvider); - final sections = browseSections.asData?.value.items; - final ThemeData(:colorScheme) = Theme.of(context); - - if (browseSections.isLoading) { - return SliverToBoxAdapter( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 16, - children: [ - Undraw( - height: 200, - illustration: UndrawIllustration.process, - color: colorScheme.primary, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 8, - children: [ - const CircularProgressIndicator(), - Text(context.l10n.building_your_timeline).muted, - ], - ), - const Gap(16), - ], - ), - ); - } - - if (browseSections.error - case MetadataPluginException( - errorCode: MetadataPluginErrorCode.noDefaultMetadataPlugin, - message: _, - )) { - return const SliverFillRemaining( - child: Center(child: NoDefaultMetadataPlugin()), - ); - } - - if (browseSections.hasError) { - return SliverFillRemaining( - child: Center( - child: ErrorBox( - error: browseSections.error!, - onRetry: () { - ref.invalidate(metadataPluginBrowseSectionsProvider); - }, - ), - ), - ); - } - - return SliverInfiniteList( - hasReachedMax: browseSections.asData?.value.hasMore == false, - isLoading: !browseSections.isLoading && browseSections.isLoadingNextPage, - onFetchData: () { - ref.read(metadataPluginBrowseSectionsProvider.notifier).fetchMore(); - }, - itemCount: sections?.length ?? 0, - itemBuilder: (context, index) { - final section = sections![index]; - if (section.items.isEmpty) return const SizedBox.shrink(); - - return HorizontalPlaybuttonCardView( - items: section.items, - title: Text(section.title), - hasNextPage: false, - isLoadingNextPage: false, - onFetchMore: () {}, - titleTrailing: section.browseMore - ? Button.text( - child: Text(context.l10n.browse_all), - onPressed: () { - context.navigateTo( - HomeBrowseSectionItemsRoute( - sectionId: section.id, - section: section, - ), - ); - }, - ) - : null, - ); - }, - ); - } -} diff --git a/lib/modules/library/local_folder/cache_export_dialog.dart b/lib/modules/library/local_folder/cache_export_dialog.dart deleted file mode 100644 index 4c86a8d5..00000000 --- a/lib/modules/library/local_folder/cache_export_dialog.dart +++ /dev/null @@ -1,138 +0,0 @@ -import 'dart:io'; - -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:path/path.dart' as path; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/services/logger/logger.dart'; - -const containers = ["m4a", "mp3", "mp4", "ogg", "wav", "flac"]; - -class LocalFolderCacheExportDialog extends HookConsumerWidget { - final Directory exportDir; - final Directory cacheDir; - const LocalFolderCacheExportDialog({ - super.key, - required this.exportDir, - required this.cacheDir, - }); - - @override - Widget build(BuildContext context, ref) { - final ThemeData(:typography, :colorScheme) = Theme.of(context); - - final files = useState>([]); - final filesExported = useState(0); - - useEffect(() { - final stream = cacheDir.list().where( - (event) => - event is File && - containers - .contains(path.extension(event.path).replaceAll(".", "")), - ); - - stream.listen( - (event) { - files.value = [...files.value, event as File]; - }, - onError: (e, stack) { - AppLogger.reportError(e, stack); - }, - ); - return null; - }, []); - - useEffect(() { - if (filesExported.value == files.value.length && - filesExported.value > 0) { - Navigator.of(context).pop(); - } - return null; - }, [filesExported.value, files.value]); - - final isExportInProgress = - filesExported.value > 0 && filesExported.value != files.value.length; - - return AlertDialog( - title: Text(context.l10n.export_cache_files), - content: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: filesExported.value == 0 - ? Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.l10n.found_n_files(files.value.length.toString()), - ), - const Gap(10), - Text.rich( - TextSpan( - children: [ - TextSpan( - text: context.l10n.export_cache_confirmation, - ), - TextSpan( - text: "\n${exportDir.path}?", - style: typography.small.copyWith( - color: colorScheme.mutedForeground, - ), - ), - ], - ), - ), - ], - ) - : Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.l10n.exported_n_out_of_m_files( - files.value.length.toString(), - filesExported.value.toString(), - ), - ), - const Gap(10), - LinearProgressIndicator( - value: filesExported.value / files.value.length, - ), - ], - ), - ), - actions: [ - Button.outline( - onPressed: isExportInProgress - ? null - : () { - Navigator.of(context).pop(); - }, - child: Text(context.l10n.cancel), - ), - Button.primary( - onPressed: isExportInProgress - ? null - : () async { - for (final file in files.value) { - try { - final destinationFile = File( - path.join(exportDir.path, path.basename(file.path)), - ); - - if (await destinationFile.exists()) { - await destinationFile.delete(); - } - await file.copy(destinationFile.path); - filesExported.value++; - } catch (e, stack) { - AppLogger.reportError(e, stack); - continue; - } - } - }, - child: Text(context.l10n.export), - ), - ], - ); - } -} diff --git a/lib/modules/library/local_folder/local_folder_item.dart b/lib/modules/library/local_folder/local_folder_item.dart deleted file mode 100644 index 8bed5f7f..00000000 --- a/lib/modules/library/local_folder/local_folder_item.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'dart:math'; - -import 'package:auto_route/auto_route.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:path/path.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/string.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class LocalFolderItem extends HookConsumerWidget { - final String folder; - const LocalFolderItem({super.key, required this.folder}); - - @override - Widget build(BuildContext context, ref) { - final ThemeData(:colorScheme) = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - - final downloadFolder = - ref.watch(userPreferencesProvider.select((s) => s.downloadLocation)); - final cacheFolder = useFuture(UserPreferencesNotifier.getMusicCacheDir()); - - final isDownloadFolder = folder == downloadFolder; - final isCacheFolder = folder == cacheFolder.data; - - final trackSnapshot = ref.watch( - localTracksProvider.select( - (s) => s.whenData((tracks) => tracks[folder]?.take(4).toList()), - ), - ); - - final tracks = trackSnapshot.value ?? []; - - return Button( - onPressed: () { - context.navigateTo( - LocalLibraryRoute( - location: folder, - isCache: isCacheFolder, - isDownloads: isDownloadFolder, - ), - ); - }, - style: ButtonVariance.card.copyWith( - padding: (context, states, value) { - return const EdgeInsets.all(8); - }, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (tracks.isEmpty) - Padding( - padding: const EdgeInsets.all(8.0), - child: Icon( - SpotubeIcons.folder, - size: mediaQuery.smAndDown - ? 95 - : mediaQuery.mdAndDown - ? 100 - : 142, - ), - ) - else - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: max((tracks.length / 2).ceil(), 2), - ), - itemCount: tracks.length, - itemBuilder: (context, index) { - final track = tracks[index]; - return UniversalImage( - path: track.album.images.asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - fit: BoxFit.cover, - ); - }, - ), - ), - const Gap(8), - Stack( - children: [ - Center( - child: Text( - isDownloadFolder - ? context.l10n.downloads - : isCacheFolder - ? context.l10n.cache_folder.capitalize() - : basename(folder), - style: const TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (!isDownloadFolder && !isCacheFolder) - Align( - alignment: Alignment.topRight, - child: IconButton.ghost( - icon: const Icon(Icons.more_vert), - size: ButtonSize.small, - onPressed: () { - showDropdown( - context: context, - builder: (context) { - return DropdownMenu( - children: [ - MenuButton( - leading: Icon(SpotubeIcons.folderRemove, - color: colorScheme.destructive), - child: - Text(context.l10n.remove_library_location), - onPressed: (context) { - final libraryLocations = ref - .read(userPreferencesProvider) - .localLibraryLocation; - ref - .read(userPreferencesProvider.notifier) - .setLocalLibraryLocation( - libraryLocations - .where((e) => e != folder) - .toList(), - ); - }, - ) - ], - ); - }, - ); - }, - ), - ), - ], - ), - const Spacer(), - ], - ), - ); - } -} diff --git a/lib/modules/library/user_downloads/download_item.dart b/lib/modules/library/user_downloads/download_item.dart deleted file mode 100644 index b1cd9f62..00000000 --- a/lib/modules/library/user_downloads/download_item.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/links/artist_link.dart'; -import 'package:spotube/components/ui/button_tile.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; - -class DownloadItem extends HookConsumerWidget { - final DownloadTask task; - const DownloadItem({ - super.key, - required this.task, - }); - - @override - Widget build(BuildContext context, ref) { - final downloadManager = ref.watch(downloadManagerProvider.notifier); - - return ButtonTile( - style: ButtonVariance.ghost, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 5), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: UniversalImage( - height: 40, - width: 40, - path: task.track.album.images.asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - ), - ), - ), - title: Text(task.track.name), - subtitle: ArtistLink( - artists: task.track.artists, - mainAxisAlignment: WrapAlignment.start, - onOverflowArtistClick: () { - context.navigateTo(TrackRoute(trackId: task.track.id)); - }, - ), - trailing: switch (task.status) { - DownloadStatus.downloading => HookBuilder(builder: (context) { - return StreamBuilder( - stream: task.downloadedBytesStream, - builder: (context, asyncSnapshot) { - final progress = - task.totalSizeBytes == null || task.totalSizeBytes == 0 - ? 0 - : (asyncSnapshot.data ?? 0) / task.totalSizeBytes!; - - return Row( - children: [ - CircularProgressIndicator( - value: progress.toDouble(), - ), - const SizedBox(width: 10), - const SizedBox(width: 10), - IconButton.ghost( - icon: const Icon(SpotubeIcons.close), - onPressed: () { - downloadManager.cancel(task.track); - }), - ], - ); - }); - }), - DownloadStatus.failed || DownloadStatus.canceled => SizedBox( - width: 100, - child: Row( - children: [ - Icon( - SpotubeIcons.error, - color: Colors.red[400], - ), - const SizedBox(width: 10), - IconButton.ghost( - icon: const Icon(SpotubeIcons.refresh), - onPressed: () { - downloadManager.retry(task.track); - }, - ), - ], - ), - ), - DownloadStatus.completed => - Icon(SpotubeIcons.done, color: Colors.green[400]), - DownloadStatus.queued => IconButton.ghost( - icon: const Icon(SpotubeIcons.close), - onPressed: () { - downloadManager.cancel(task.track); - }), - }, - ); - } -} diff --git a/lib/modules/lyrics/use_synced_lyrics.dart b/lib/modules/lyrics/use_synced_lyrics.dart deleted file mode 100644 index cf929226..00000000 --- a/lib/modules/lyrics/use_synced_lyrics.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/logger/logger.dart'; - -int useSyncedLyrics( - WidgetRef ref, - Map lyricsMap, - int delay, -) { - final stream = audioPlayer.positionStream; - - final currentTime = useState(0); - - useEffect(() { - return stream.listen((pos) { - try { - if (lyricsMap.containsKey(pos.inSeconds + delay)) { - currentTime.value = pos.inSeconds + delay; - } - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }).cancel; - }, [lyricsMap, delay]); - - return (Duration(seconds: currentTime.value)).inSeconds; -} diff --git a/lib/modules/lyrics/zoom_controls.dart b/lib/modules/lyrics/zoom_controls.dart deleted file mode 100644 index b4eeb9d6..00000000 --- a/lib/modules/lyrics/zoom_controls.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; - -class ZoomControls extends HookWidget { - final int value; - final ValueChanged onChanged; - final int? min; - final int? max; - - final int interval; - final Icon increaseIcon; - final Icon decreaseIcon; - - final Axis direction; - final String unit; - - const ZoomControls({ - super.key, - required this.value, - required this.onChanged, - this.min, - this.max, - this.interval = 10, - this.increaseIcon = const Icon(SpotubeIcons.zoomIn), - this.decreaseIcon = const Icon(SpotubeIcons.zoomOut), - this.direction = Axis.horizontal, - this.unit = "%", - }); - - @override - Widget build(BuildContext context) { - final actions = [ - IconButton.ghost( - icon: decreaseIcon, - onPressed: () { - if (value == min) return; - onChanged(value - interval); - }, - ), - Text("$value$unit"), - IconButton.ghost( - icon: increaseIcon, - onPressed: () { - if (value == max) return; - onChanged(value + interval); - }, - ), - ]; - - return Container( - constraints: BoxConstraints( - maxHeight: direction == Axis.horizontal ? 50 : 200, - maxWidth: direction == Axis.vertical ? 50 : double.infinity, - ), - margin: const EdgeInsets.all(8), - child: SurfaceCard( - surfaceBlur: context.theme.surfaceBlur, - surfaceOpacity: context.theme.surfaceOpacity, - padding: EdgeInsets.zero, - child: direction == Axis.horizontal - ? Row( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: actions, - ) - : Column( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - verticalDirection: VerticalDirection.up, - children: actions, - ), - ), - ); - } -} diff --git a/lib/modules/metadata_plugins/installed_plugin.dart b/lib/modules/metadata_plugins/installed_plugin.dart deleted file mode 100644 index 7abda5ec..00000000 --- a/lib/modules/metadata_plugins/installed_plugin.dart +++ /dev/null @@ -1,405 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/markdown/markdown.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/modules/metadata_plugins/plugin_update_available_dialog.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/core/support.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/updater/update_checker.dart'; -import 'package:url_launcher/url_launcher.dart'; - -final validAbilities = { - PluginAbilities.metadata: ("Metadata", SpotubeIcons.album), - PluginAbilities.audioSource: ("Audio Source", SpotubeIcons.music), -}; - -class MetadataInstalledPluginItem extends HookConsumerWidget { - final PluginConfiguration plugin; - final bool isDefaultMetadata; - final bool isDefaultAudioSource; - const MetadataInstalledPluginItem({ - super.key, - required this.plugin, - required this.isDefaultMetadata, - required this.isDefaultAudioSource, - }); - - @override - Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.sizeOf(context); - - final metadataPlugin = ref.watch(metadataPluginProvider); - final audioSourcePlugin = ref.watch(audioSourcePluginProvider); - final pluginSnapshot = switch ((isDefaultMetadata, isDefaultAudioSource)) { - (true, _) => metadataPlugin, - (false, true) => audioSourcePlugin, - _ => null, - }; - - final pluginsNotifier = ref.watch(metadataPluginsProvider.notifier); - - final requiresAuth = (isDefaultMetadata || isDefaultAudioSource) && - plugin.abilities.contains(PluginAbilities.authentication); - final supportsScrobbling = isDefaultMetadata && - plugin.abilities.contains(PluginAbilities.scrobbling); - - final isMetadataAuthenticatedSnapshot = - ref.watch(metadataPluginAuthenticatedProvider); - final isAudioSourceAuthenticatedSnapshot = - ref.watch(audioSourcePluginAuthenticatedProvider); - final isAuthenticated = (isDefaultMetadata && - isMetadataAuthenticatedSnapshot.asData?.value == true) || - (isDefaultAudioSource && - isAudioSourceAuthenticatedSnapshot.asData?.value == true); - - final metadataUpdateAvailable = - ref.watch(metadataPluginUpdateCheckerProvider); - final audioSourceUpdateAvailable = - ref.watch(audioSourcePluginUpdateCheckerProvider); - final updateAvailable = switch ((isDefaultMetadata, isDefaultAudioSource)) { - (true, _) => metadataUpdateAvailable, - (false, true) => audioSourceUpdateAvailable, - _ => null, - }; - final hasUpdate = updateAvailable?.asData?.value != null; - - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - spacing: 12, - children: [ - FutureBuilder( - future: pluginsNotifier.getLogoPath(plugin), - builder: (context, snapshot) { - final repoUrl = plugin.repository != null - ? Uri.tryParse(plugin.repository!) - : null; - final repoOwner = repoUrl?.pathSegments.firstOrNull; - - final isOfficial = - repoUrl?.host == "github.com" && repoOwner == "KRTirtho"; - - return Basic( - leading: snapshot.hasData - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.file( - snapshot.data!, - width: 36, - height: 36, - ), - ) - : Container( - height: 36, - width: 36, - alignment: Alignment.center, - decoration: BoxDecoration( - color: context.theme.colorScheme.secondary, - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(SpotubeIcons.plugin), - ), - title: Text(plugin.name), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - Text(plugin.description), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final ability in plugin.abilities) - if (validAbilities.keys.contains(ability)) - SecondaryBadge( - leading: Icon(validAbilities[ability]!.$2), - child: Text(validAbilities[ability]!.$1), - ), - ], - ), - if (repoUrl != null) - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - if (isOfficial) - PrimaryBadge( - leading: const Icon(SpotubeIcons.done), - child: Text(context.l10n.official), - ) - else ...[ - Text(context.l10n.author_name(plugin.author)), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 4, - children: [ - const Icon(SpotubeIcons.warning, size: 14), - Text( - context.l10n.third_party, - style: const TextStyle(color: Colors.white), - ).xSmall - ], - ), - ), - ], - SecondaryBadge( - leading: const Icon(SpotubeIcons.connect), - child: Text(repoUrl.host), - onPressed: () { - launchUrl(repoUrl); - }, - ), - SecondaryBadge( - child: Padding( - padding: const EdgeInsets.all(1), - child: Text( - "${context.l10n.version}: ${plugin.version}", - ), - ), - ), - ], - ) - ], - ), - trailing: IconButton.ghost( - onPressed: () async { - await pluginsNotifier.removePlugin(plugin); - }, - icon: const Icon( - SpotubeIcons.trash, - color: Colors.red, - ), - ), - ); - }, - ), - if ((requiresAuth && !isAuthenticated) || - hasUpdate || - supportsScrobbling) - Container( - decoration: BoxDecoration( - color: context.theme.colorScheme.secondary, - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.all(12), - child: Column( - spacing: 12, - children: [ - if (requiresAuth && !isAuthenticated) - Row( - spacing: 8, - children: [ - const Icon(SpotubeIcons.warning, color: Colors.yellow), - Text(context.l10n.plugin_requires_authentication), - ], - ), - if (hasUpdate) - SizedBox( - width: double.infinity, - child: Basic( - leading: const Icon(SpotubeIcons.update), - title: Text(context.l10n.update_available), - subtitle: Text( - updateAvailable!.asData!.value!.version, - ), - trailing: Button.primary( - onPressed: () { - showDialog( - context: context, - builder: (context) => - MetadataPluginUpdateAvailableDialog( - plugin: plugin, - update: updateAvailable.asData!.value!, - ), - ); - }, - child: Text(context.l10n.update), - ), - ), - ), - if (supportsScrobbling) - SizedBox( - width: double.infinity, - child: Basic( - leading: const Icon(SpotubeIcons.info), - title: Text(context.l10n.supports_scrobbling), - subtitle: Text(context.l10n.plugin_scrobbling_info), - ), - ) - ], - ), - ), - Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.spaceBetween, - children: [ - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - if (plugin.abilities.contains(PluginAbilities.metadata)) - Button.secondary( - enabled: !isDefaultMetadata, - onPressed: () async { - await pluginsNotifier.setDefaultMetadataPlugin(plugin); - }, - child: Text( - isDefaultMetadata - ? context.l10n.default_metadata_source - : context.l10n.set_default_metadata_source, - ), - ), - if (plugin.abilities.contains(PluginAbilities.audioSource)) - Button.secondary( - enabled: !isDefaultAudioSource, - onPressed: () async { - await pluginsNotifier - .setDefaultAudioSourcePlugin(plugin); - }, - child: Text( - isDefaultAudioSource - ? context.l10n.default_audio_source - : context.l10n.set_default_audio_source, - ), - ), - ], - ), - Row( - mainAxisSize: - mediaQuery.smAndUp ? MainAxisSize.min : MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.end, - spacing: 8, - children: [ - if (isDefaultMetadata || isDefaultAudioSource) - Consumer(builder: (context, ref, _) { - final metadataSupportTextSnapshot = - ref.watch(metadataPluginSupportTextProvider); - final audioSourceSupportTextSnapshot = - ref.watch(audioSourcePluginSupportTextProvider); - - final supportTextSnapshot = - switch ((isDefaultMetadata, isDefaultAudioSource)) { - (true, _) => metadataSupportTextSnapshot, - (false, true) => audioSourceSupportTextSnapshot, - _ => null, - }; - - if ((supportTextSnapshot?.hasValue ?? false) && - supportTextSnapshot?.value == null) { - return const SizedBox.shrink(); - } - - final bgColor = - context.theme.brightness == Brightness.dark - ? const Color.fromARGB(255, 255, 145, 175) - : Colors.pink[600]; - final textColor = - context.theme.brightness == Brightness.dark - ? Colors.pink[700] - : Colors.pink[50]; - - final mediaQuery = MediaQuery.sizeOf(context); - - return Button( - style: ButtonVariance.secondary.copyWith( - decoration: (context, states, value) { - return value.copyWithIfBoxDecoration( - color: bgColor, - ); - }, - textStyle: (context, states, value) { - return value.copyWith( - color: textColor, - ); - }, - iconTheme: (context, states, value) { - return value.copyWith( - color: textColor, - ); - }, - ), - leading: const Icon(SpotubeIcons.heartFilled), - child: Text(context.l10n.support), - onPressed: () { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text( - context.l10n.support_plugin_development), - content: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: mediaQuery.height * 0.8, - maxWidth: 720, - ), - child: SizedBox( - width: double.infinity, - child: SingleChildScrollView( - child: AppMarkdown( - data: supportTextSnapshot - ?.asData?.value ?? - "", - ), - ), - ), - ), - actions: [ - Button.secondary( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(context.l10n.close), - ), - ], - ); - }, - ); - }, - ); - }), - if ((isDefaultMetadata || isDefaultAudioSource) && - requiresAuth && - !isAuthenticated) - Button.primary( - onPressed: () async { - await pluginSnapshot?.asData?.value?.auth - .authenticate(); - }, - leading: const Icon(SpotubeIcons.login), - child: Text(context.l10n.login), - ) - else if ((isDefaultMetadata || isDefaultAudioSource) && - requiresAuth && - isAuthenticated) - Button.destructive( - onPressed: () async { - await pluginSnapshot?.asData?.value?.auth.logout(); - }, - leading: const Icon(SpotubeIcons.logout), - child: Text(context.l10n.logout), - ), - ], - ) - ], - ) - ], - ), - ); - } -} diff --git a/lib/modules/metadata_plugins/plugin_repository.dart b/lib/modules/metadata_plugins/plugin_repository.dart deleted file mode 100644 index 9bd71f0a..00000000 --- a/lib/modules/metadata_plugins/plugin_repository.dart +++ /dev/null @@ -1,237 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/markdown/markdown.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:url_launcher/url_launcher_string.dart'; -import 'package:change_case/change_case.dart'; - -final validTopics = { - "spotube-metadata-plugin": ("Metadata", SpotubeIcons.album), - "spotube-audio-source-plugin": ("Audio Source", SpotubeIcons.music), -}; - -class MetadataPluginRepositoryItem extends HookConsumerWidget { - final MetadataPluginRepository pluginRepo; - const MetadataPluginRepositoryItem({ - super.key, - required this.pluginRepo, - }); - - @override - Widget build(BuildContext context, ref) { - final pluginsNotifier = ref.watch(metadataPluginsProvider.notifier); - final host = useMemoized( - () => Uri.parse(pluginRepo.repoUrl).host, - [pluginRepo.repoUrl], - ); - final isInstalling = useState(false); - - return Card( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 8, - children: [ - Basic( - title: Text( - pluginRepo.name.startsWith("spotube-plugin") - ? pluginRepo.name - .replaceFirst("spotube-plugin-", "") - .trim() - .toCapitalCase() - : pluginRepo.name.toCapitalCase(), - ), - subtitle: Text(pluginRepo.description), - trailing: Button.primary( - enabled: !isInstalling.value, - onPressed: () async { - try { - isInstalling.value = true; - final pluginConfig = await pluginsNotifier - .downloadAndCachePlugin(pluginRepo.repoUrl); - - if (!context.mounted) return; - final isOfficialPlugin = pluginRepo.owner == "KRTirtho"; - - final isAllowed = isOfficialPlugin - ? true - : await showDialog( - context: context, - builder: (context) { - final pluginAbilities = pluginConfig.apis - .map((e) => - context.l10n.can_access_name_api(e.name)) - .join("\n\n"); - - return AlertDialog( - title: Text( - context.l10n.do_you_want_to_install_this_plugin, - ), - content: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.third_party_plugin_warning), - const Gap(8), - FutureBuilder( - future: pluginsNotifier - .getLogoPath(pluginConfig), - builder: (context, snapshot) { - return Basic( - leading: snapshot.hasData - ? Image.file( - snapshot.data!, - width: 36, - height: 36, - ) - : Container( - height: 36, - width: 36, - alignment: Alignment.center, - decoration: BoxDecoration( - color: context.theme - .colorScheme.secondary, - borderRadius: - BorderRadius.circular(8), - ), - child: const Icon( - SpotubeIcons.plugin), - ), - title: Text(pluginConfig.name), - subtitle: - Text(pluginConfig.description), - ); - }, - ), - const Gap(8), - AppMarkdown( - data: - "**${context.l10n.author}**: ${pluginConfig.author}\n\n" - "**${context.l10n.repository}**: [${pluginConfig.repository ?? 'N/A'}](${pluginConfig.repository})\n\n\n\n" - "${context.l10n.this_plugin_can_do_following}:\n\n" - "$pluginAbilities", - ), - ], - ), - actions: [ - Button.secondary( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text(context.l10n.decline), - ), - Button.primary( - onPressed: () { - Navigator.of(context).pop(true); - }, - child: Text(context.l10n.accept), - ), - ], - ); - }, - ); - - if (isAllowed != true) return; - await pluginsNotifier.addPlugin(pluginConfig); - } finally { - if (context.mounted) { - isInstalling.value = false; - } - } - }, - leading: isInstalling.value - ? SizedBox.square( - dimension: 20, - child: CircularProgressIndicator( - color: context.theme.colorScheme.primaryForeground, - ), - ) - : const Icon(SpotubeIcons.add), - child: Text(context.l10n.install), - ), - ), - if (pluginRepo.owner != "KRTirtho") - Text.rich( - TextSpan( - children: [ - TextSpan(text: context.l10n.source), - TextSpan( - text: pluginRepo.repoUrl.replaceAll("https://", ""), - style: const TextStyle( - color: Colors.blue, - decoration: TextDecoration.underline, - ), - recognizer: TapGestureRecognizer() - ..onTap = () async { - launchUrlString(pluginRepo.repoUrl); - }, - ), - ], - ), - style: context.theme.typography.xSmall, - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - if (pluginRepo.owner == "KRTirtho") - PrimaryBadge( - leading: const Icon(SpotubeIcons.done), - child: Text(context.l10n.official), - ) - else ...[ - Text( - context.l10n.author_name(pluginRepo.owner), - style: context.theme.typography.xSmall, - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 4, - children: [ - const Icon(SpotubeIcons.warning, size: 14), - Text( - context.l10n.third_party, - style: const TextStyle(color: Colors.white), - ).xSmall - ], - ), - ), - ], - for (final topic in pluginRepo.topics) - if (validTopics.keys.contains(topic)) - SecondaryBadge( - leading: Icon(validTopics[topic]!.$2), - child: Text(validTopics[topic]!.$1), - ), - SecondaryBadge( - leading: host == "github.com" - ? const Icon(SpotubeIcons.github) - : null, - child: Text(host), - onPressed: () { - launchUrlString(pluginRepo.repoUrl); - }, - ), - ], - ), - ], - ), - ); - } -} diff --git a/lib/modules/metadata_plugins/plugin_update_available_dialog.dart b/lib/modules/metadata_plugins/plugin_update_available_dialog.dart deleted file mode 100644 index d16a0a35..00000000 --- a/lib/modules/metadata_plugins/plugin_update_available_dialog.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/markdown/markdown.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; - -class MetadataPluginUpdateAvailableDialog extends HookConsumerWidget { - final PluginConfiguration plugin; - final PluginUpdateAvailable update; - const MetadataPluginUpdateAvailableDialog({ - super.key, - required this.plugin, - required this.update, - }); - - @override - Widget build(BuildContext context, ref) { - final isUpdating = useState(false); - - final showErrorSnackbar = useCallback( - (BuildContext context, String message) { - showToast( - context: context, - builder: (context, overlay) { - return SurfaceCard( - child: Basic( - leading: const Icon(SpotubeIcons.error, color: Colors.red), - title: Text(message), - leadingAlignment: Alignment.center, - trailing: IconButton.ghost( - size: ButtonSize.small, - icon: const Icon(SpotubeIcons.close), - onPressed: () { - overlay.close(); - }, - ), - ), - ); - }); - }, - [], - ); - - return AlertDialog( - title: const Text('Plugin update available'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - Text('${plugin.name} (${update.version}) available.'), - if (update.changelog != null && update.changelog!.isNotEmpty) - AppMarkdown( - data: '### Changelog: \n\n${update.changelog}', - ), - ], - ), - actions: [ - SecondaryButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('Dismiss'), - ), - PrimaryButton( - enabled: !isUpdating.value, - onPressed: () async { - isUpdating.value = true; - try { - await ref - .read(metadataPluginsProvider.notifier) - .updatePlugin(plugin, update); - if (context.mounted) { - Navigator.of(context).pop(); - } - } catch (e) { - if (context.mounted) { - showErrorSnackbar(context, e.toString()); - } - } finally { - if (context.mounted) { - isUpdating.value = false; - } - } - }, - child: const Text('Update'), - ), - ], - ); - } -} diff --git a/lib/modules/player/player.dart b/lib/modules/player/player.dart deleted file mode 100644 index 5ea690e0..00000000 --- a/lib/modules/player/player.dart +++ /dev/null @@ -1,275 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:sliding_up_panel/sliding_up_panel.dart'; - -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/framework/app_pop_scope.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/modules/player/player_actions.dart'; -import 'package:spotube/modules/player/player_controls.dart'; -import 'package:spotube/modules/player/volume_slider.dart'; -import 'package:spotube/components/dialogs/track_details_dialog.dart'; -import 'package:spotube/components/links/artist_link.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/modules/root/spotube_navigation_bar.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/metadata_plugin/audio_source/quality_label.dart'; -import 'package:spotube/provider/server/active_track_sources.dart'; -import 'package:spotube/provider/volume_provider.dart'; - -class PlayerView extends HookConsumerWidget { - final PanelController panelController; - final ScrollController scrollController; - const PlayerView({ - super.key, - required this.panelController, - required this.scrollController, - }); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final sourcedCurrentTrack = ref.watch(activeTrackSourcesProvider); - final currentActiveTrack = - ref.watch(audioPlayerProvider.select((s) => s.activeTrack)); - final currentActiveTrackSource = sourcedCurrentTrack.asData?.value?.source; - final isLocalTrack = currentActiveTrack is SpotubeLocalTrackObject; - final mediaQuery = MediaQuery.sizeOf(context); - final qualityLabel = ref.watch(audioSourceQualityLabelProvider); - - final shouldHide = useState(true); - - ref.listen(navigationPanelHeight, (_, height) { - shouldHide.value = height.ceil() == 50; - }); - - if (shouldHide.value) { - return const SizedBox(); - } - - useEffect(() { - if (mediaQuery.lgAndUp) { - WidgetsBinding.instance.addPostFrameCallback((_) { - panelController.close(); - }); - } - return null; - }, [mediaQuery.lgAndUp]); - - String albumArt = useMemoized( - () => (currentActiveTrack?.album.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - [currentActiveTrack?.album.images], - ); - - useEffect(() { - for (final renderView in WidgetsBinding.instance.renderViews) { - renderView.automaticSystemUiAdjustment = false; - } - - return () { - for (final renderView in WidgetsBinding.instance.renderViews) { - renderView.automaticSystemUiAdjustment = true; - } - }; - }, [panelController.isAttached && panelController.isPanelOpen]); - - return AppPopScope( - canPop: false, - onPopInvoked: (didPop) async { - await panelController.close(); - }, - child: SurfaceCard( - borderWidth: 0, - surfaceOpacity: 0.9, - padding: EdgeInsets.zero, - child: Scaffold( - backgroundColor: Colors.transparent, - headers: [ - SafeArea( - bottom: false, - child: TitleBar( - surfaceOpacity: 0, - surfaceBlur: 0, - leading: [ - IconButton.ghost( - size: const ButtonSize(1.2), - icon: const Icon(SpotubeIcons.angleDown), - onPressed: panelController.close, - ) - ], - trailing: [ - if (!isLocalTrack) - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.details), - ).call, - child: IconButton.ghost( - size: const ButtonSize(1.2), - icon: const Icon(SpotubeIcons.info), - onPressed: currentActiveTrackSource == null - ? null - : () { - showDialog( - context: context, - builder: (context) { - return TrackDetailsDialog( - track: currentActiveTrack - as SpotubeFullTrackObject, - ); - }); - }, - ), - ) - ], - ), - ), - ], - child: SingleChildScrollView( - controller: scrollController, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - Container( - margin: const EdgeInsets.all(8), - constraints: - const BoxConstraints(maxHeight: 300, maxWidth: 300), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withAlpha(100), - spreadRadius: 2, - blurRadius: 10, - offset: Offset.zero, - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: UniversalImage( - path: albumArt, - placeholder: Assets.images.albumPlaceholder.path, - fit: BoxFit.cover, - ), - ), - ), - const SizedBox(height: 60), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - alignment: Alignment.centerLeft, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AutoSizeText( - currentActiveTrack?.name ?? context.l10n.not_playing, - style: const TextStyle(fontSize: 22), - maxFontSize: 22, - maxLines: 1, - textAlign: TextAlign.start, - ), - if (isLocalTrack) - Text( - currentActiveTrack.artists.asString(), - style: theme.typography.normal - .copyWith(fontWeight: FontWeight.bold), - ) - else - ArtistLink( - artists: currentActiveTrack?.artists ?? [], - textStyle: theme.typography.normal - .copyWith(fontWeight: FontWeight.bold), - onRouteChange: (route) { - panelController.close(); - context.router.navigateNamed(route); - }, - onOverflowArtistClick: () => context.navigateTo( - TrackRoute( - trackId: currentActiveTrack!.id, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 10), - const PlayerControls(), - const SizedBox(height: 25), - const PlayerActions( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - showQueue: false, - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const SizedBox(width: 10), - Expanded( - child: OutlineButton( - leading: const Icon(SpotubeIcons.queue), - child: Text(context.l10n.queue), - onPressed: () { - context.pushRoute(const PlayerQueueRoute()); - }, - ), - ), - const SizedBox(width: 10), - Expanded( - child: OutlineButton( - leading: const Icon(SpotubeIcons.music), - child: Text(context.l10n.lyrics), - onPressed: () { - context.pushRoute(const PlayerLyricsRoute()); - }, - ), - ), - const SizedBox(width: 10), - ], - ), - const SizedBox(height: 25), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Consumer(builder: (context, ref, _) { - final volume = ref.watch(volumeProvider); - return VolumeSlider( - fullWidth: true, - value: volume, - onChanged: (value) { - ref.read(volumeProvider.notifier).setVolume(value); - }, - ); - }), - ), - const Gap(25), - OutlineBadge( - style: const ButtonStyle.outline( - size: ButtonSize.normal, - density: ButtonDensity.dense, - shape: ButtonShape.rectangle, - ).copyWith( - textStyle: (context, states, value) { - return value.copyWith(fontWeight: FontWeight.w500); - }, - ), - leading: const Icon(SpotubeIcons.lightningOutlined), - child: Text(qualityLabel), - ) - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/modules/player/player_actions.dart b/lib/modules/player/player_actions.dart deleted file mode 100644 index 9f8639ec..00000000 --- a/lib/modules/player/player_actions.dart +++ /dev/null @@ -1,276 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/routes.gr.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/modules/player/player_queue.dart'; -import 'package:spotube/modules/player/sibling_tracks_sheet.dart'; -import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; -import 'package:spotube/components/heart_button/heart_button.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/duration.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/sleep_timer_provider.dart'; - -class PlayerActions extends HookConsumerWidget { - final MainAxisAlignment mainAxisAlignment; - final bool floatingQueue; - final bool showQueue; - final List? extraActions; - - const PlayerActions({ - this.mainAxisAlignment = MainAxisAlignment.center, - this.floatingQueue = true, - this.showQueue = true, - this.extraActions, - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch(audioPlayerProvider); - final isLocalTrack = playlist.activeTrack is SpotubeLocalTrackObject; - ref.watch(downloadManagerProvider); - final downloader = ref.watch(downloadManagerProvider.notifier); - final isInQueue = useMemoized(() { - if (playlist.activeTrack is! SpotubeFullTrackObject) return false; - final downloadTask = - downloader.getTaskByTrackId(playlist.activeTrack!.id); - return const [ - DownloadStatus.queued, - DownloadStatus.downloading, - ].contains(downloadTask?.status); - }, [ - playlist.activeTrack, - downloader, - ]); - - final localTracks = ref.watch(localTracksProvider).value; - final authenticated = ref.watch(metadataPluginAuthenticatedProvider); - final sleepTimer = ref.watch(sleepTimerProvider); - final sleepTimerNotifier = ref.watch(sleepTimerProvider.notifier); - - final isDownloaded = useMemoized(() { - return localTracks?.values.expand((e) => e).any( - (element) => - element.name == playlist.activeTrack?.name && - element.album.name == playlist.activeTrack?.album.name && - element.artists.asString() == - playlist.activeTrack?.artists.asString(), - ) == - true; - }, [localTracks, playlist.activeTrack]); - - final sleepTimerEntries = useMemoized( - () => { - context.l10n.mins(15): const Duration(minutes: 15), - context.l10n.mins(30): const Duration(minutes: 30), - context.l10n.hour(1): const Duration(hours: 1), - context.l10n.hour(2): const Duration(hours: 2), - }, - [context.l10n], - ); - - var customHoursEnabled = - sleepTimer == null || sleepTimerEntries.values.contains(sleepTimer); - return Row( - mainAxisAlignment: mainAxisAlignment, - children: [ - if (showQueue) - Tooltip( - tooltip: TooltipContainer(child: Text(context.l10n.queue)).call, - child: IconButton.ghost( - icon: const Icon(SpotubeIcons.queue), - enabled: playlist.activeTrack != null, - onPressed: () { - openDrawer( - context: context, - position: OverlayPosition.right, - transformBackdrop: false, - draggable: false, - surfaceBlur: context.theme.surfaceBlur, - surfaceOpacity: 0.7, - builder: (context) { - return Container( - constraints: const BoxConstraints(maxWidth: 800), - child: Consumer( - builder: (context, ref, _) { - final playlist = ref.watch(audioPlayerProvider); - final playlistNotifier = - ref.read(audioPlayerProvider.notifier); - - return PlayerQueue.fromAudioPlayerNotifier( - floating: true, - playlist: playlist, - notifier: playlistNotifier, - ); - }, - ), - ); - }, - ); - }, - ), - ), - if (!isLocalTrack) - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.alternative_track_sources), - ).call, - child: IconButton.ghost( - enabled: playlist.activeTrack != null, - icon: const Icon(SpotubeIcons.alternativeRoute), - onPressed: () { - final screenSize = MediaQuery.sizeOf(context); - if (screenSize.mdAndUp) { - showPopover( - alignment: Alignment.bottomCenter, - context: context, - builder: (context) { - return SurfaceCard( - padding: EdgeInsets.zero, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: 600, - maxWidth: 500, - ), - child: SiblingTracksSheet(floating: floatingQueue), - ), - ); - }, - ); - } else { - context.pushRoute(const PlayerTrackSourcesRoute()); - } - }, - ), - ), - if (!kIsWeb && !isLocalTrack) - if (isInQueue) - const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - size: 2, - ), - ) - else - Tooltip( - tooltip: - TooltipContainer(child: Text(context.l10n.download_track)) - .call, - child: IconButton.ghost( - icon: Icon( - isDownloaded ? SpotubeIcons.done : SpotubeIcons.download, - ), - onPressed: playlist.activeTrack != null - ? () => downloader.addToQueue( - playlist.activeTrack! as SpotubeFullTrackObject) - : null, - ), - ), - if (playlist.activeTrack != null && - !isLocalTrack && - authenticated.asData?.value == true) - TrackHeartButton(track: playlist.activeTrack!), - AdaptivePopSheetList( - tooltip: context.l10n.sleep_timer, - offset: Offset(0, -50 * (sleepTimerEntries.values.length + 2)), - headings: [ - Text(context.l10n.sleep_timer), - ], - icon: Icon( - SpotubeIcons.timer, - color: sleepTimer != null ? Colors.red : null, - ), - onSelected: (value) { - if (value == Duration.zero) { - sleepTimerNotifier.cancelSleepTimer(); - } else { - sleepTimerNotifier.setSleepTimer(value); - } - }, - items: (context) => [ - for (final entry in sleepTimerEntries.entries) - AdaptiveMenuButton( - value: entry.value, - enabled: sleepTimer != entry.value, - child: Text(entry.key), - ), - AdaptiveMenuButton( - enabled: customHoursEnabled, - onPressed: (context) async { - final currentTime = TimeOfDay.now(); - final time = await showDialog( - context: context, - builder: (context) => HookBuilder(builder: (context) { - final timeRef = useRef(null); - return AlertDialog( - trailing: IconButton.ghost( - size: ButtonSize.xSmall, - icon: const Icon(SpotubeIcons.close), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - title: Text( - ShadcnLocalizations.of(context).placeholderTimePicker, - ), - content: TimePickerDialog( - use24HourFormat: false, - initialValue: TimeOfDay.fromDateTime( - DateTime.now().add(sleepTimer ?? Duration.zero), - ), - onChanged: (value) => timeRef.value = value, - ), - actions: [ - Button.primary( - onPressed: () { - Navigator.of(context).pop(timeRef.value); - }, - child: Text(context.l10n.save), - ), - ], - ); - }), - ); - - if (time != null) { - sleepTimerNotifier.setSleepTimer( - Duration( - hours: (time.hour - currentTime.hour).abs(), - minutes: (time.minute - currentTime.minute).abs(), - ), - ); - } - }, - child: Text( - customHoursEnabled - ? context.l10n.custom_hours - : sleepTimer.format(abbreviated: true), - ), - ), - AdaptiveMenuButton( - value: Duration.zero, - enabled: sleepTimer != Duration.zero && sleepTimer != null, - child: Text( - context.l10n.cancel, - style: const TextStyle(color: Colors.green), - ), - ), - ], - ), - ...(extraActions ?? []) - ], - ); - } -} diff --git a/lib/modules/player/player_controls.dart b/lib/modules/player/player_controls.dart deleted file mode 100644 index 3da36bf8..00000000 --- a/lib/modules/player/player_controls.dart +++ /dev/null @@ -1,278 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:palette_generator/palette_generator.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/collections/intents.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/duration.dart'; -import 'package:spotube/modules/player/use_progress.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/audio_player/querying_track_info.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/utils/platform.dart'; - -class PlayerControls extends HookConsumerWidget { - final PaletteGenerator? palette; - final bool compact; - - const PlayerControls({ - this.palette, - this.compact = false, - super.key, - }); - - static FocusNode focusNode = FocusNode(); - - @override - Widget build(BuildContext context, ref) { - final shortcuts = useMemoized( - () => { - const SingleActivator(LogicalKeyboardKey.arrowRight): - SeekIntent(ref, true), - const SingleActivator(LogicalKeyboardKey.arrowLeft): - SeekIntent(ref, false), - }, - [ref]); - final actions = useMemoized( - () => { - SeekIntent: SeekAction(), - }, - []); - final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); - - final playing = - useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - final theme = Theme.of(context); - - final buttonSize = - kIsMobile ? const ButtonSize(1.5) : const ButtonSize(1.2); - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - if (focusNode.canRequestFocus) { - focusNode.requestFocus(); - } - }, - child: FocusableActionDetector( - focusNode: focusNode, - shortcuts: shortcuts, - actions: actions, - child: Container( - constraints: const BoxConstraints(maxWidth: 600), - child: Column( - children: [ - if (!compact) - HookBuilder( - builder: (context) { - final mediaQuery = MediaQuery.sizeOf(context); - - final ( - :bufferProgress, - :duration, - :position, - :progressStatic - ) = useProgress(ref); - - final progress = useState( - useMemoized(() => progressStatic, []), - ); - - useEffect(() { - progress.value = progressStatic; - return null; - }, [progressStatic]); - - return Column( - children: [ - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.slide_to_seek), - ).call, - child: SizedBox( - width: mediaQuery.xlAndUp ? 600 : 500, - child: Slider( - hintValue: SliderValue.single(bufferProgress), - value: - SliderValue.single(progress.value.toDouble()), - onChanged: isFetchingActiveTrack - ? null - : (v) { - progress.value = v.value; - }, - onChangeEnd: (value) async { - await audioPlayer.seek( - Duration( - seconds: (value.value * duration.inSeconds) - .toInt(), - ), - ); - }, - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - position.toHumanReadableString(), - style: theme.typography.xSmall, - ), - Text( - duration.toHumanReadableString(), - style: theme.typography.xSmall, - ), - ], - ), - ), - ], - ); - }, - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Consumer(builder: (context, ref, _) { - final shuffled = ref - .watch(audioPlayerProvider.select((s) => s.shuffled)); - return Tooltip( - tooltip: TooltipContainer( - child: Text( - shuffled - ? context.l10n.unshuffle_playlist - : context.l10n.shuffle_playlist, - ), - ).call, - child: IconButton( - size: buttonSize, - icon: Icon( - SpotubeIcons.shuffle, - color: shuffled ? theme.colorScheme.primary : null, - size: 22, - ), - variance: shuffled - ? ButtonVariance.secondary - : ButtonVariance.ghost, - onPressed: isFetchingActiveTrack - ? null - : () { - if (shuffled) { - audioPlayer.setShuffle(false); - } else { - audioPlayer.setShuffle(true); - } - }, - ), - ); - }), - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.previous_track), - ).call, - child: IconButton.ghost( - size: buttonSize, - enabled: !isFetchingActiveTrack, - icon: const Icon(SpotubeIcons.skipBack), - onPressed: audioPlayer.skipToPrevious, - ), - ), - Tooltip( - tooltip: TooltipContainer( - child: Text( - playing - ? context.l10n.pause_playback - : context.l10n.resume_playback, - ), - ).call, - child: IconButton.primary( - size: buttonSize, - shape: ButtonShape.circle, - icon: isFetchingActiveTrack - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(), - ) - : Icon( - playing ? SpotubeIcons.pause : SpotubeIcons.play, - ), - onPressed: isFetchingActiveTrack - ? null - : Actions.handler( - context, - PlayPauseIntent(ref), - ), - ), - ), - Tooltip( - tooltip: - TooltipContainer(child: Text(context.l10n.next_track)) - .call, - child: IconButton.ghost( - size: buttonSize, - icon: const Icon(SpotubeIcons.skipForward), - onPressed: - isFetchingActiveTrack ? null : audioPlayer.skipToNext, - ), - ), - Consumer(builder: (context, ref, _) { - final loopMode = ref - .watch(audioPlayerProvider.select((s) => s.loopMode)); - - return Tooltip( - tooltip: TooltipContainer( - child: Text( - loopMode == PlaylistMode.single - ? context.l10n.loop_track - : loopMode == PlaylistMode.loop - ? context.l10n.repeat_playlist - : "", - ), - ).call, - child: IconButton( - size: buttonSize, - icon: Icon( - loopMode == PlaylistMode.single - ? SpotubeIcons.repeatOne - : SpotubeIcons.repeat, - color: loopMode != PlaylistMode.none - ? theme.colorScheme.primary - : null, - ), - variance: loopMode == PlaylistMode.single || - loopMode == PlaylistMode.loop - ? ButtonVariance.secondary - : ButtonVariance.ghost, - onPressed: isFetchingActiveTrack - ? null - : () async { - await audioPlayer.setLoopMode( - switch (loopMode) { - PlaylistMode.loop => PlaylistMode.single, - PlaylistMode.single => PlaylistMode.none, - PlaylistMode.none => PlaylistMode.loop, - }, - ); - }, - ), - ); - }), - ], - ), - const SizedBox(height: 5) - ], - ), - ), - ), - ); - } -} diff --git a/lib/modules/player/player_overlay.dart b/lib/modules/player/player_overlay.dart deleted file mode 100644 index 3c3ff373..00000000 --- a/lib/modules/player/player_overlay.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:sliding_up_panel/sliding_up_panel.dart'; -import 'package:spotube/modules/player/player_overlay_collapsed.dart'; - -import 'package:spotube/modules/root/spotube_navigation_bar.dart'; -import 'package:spotube/modules/player/player.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; - -final playerOverlayControllerProvider = StateProvider((ref) { - return PanelController(); -}); - -class PlayerOverlay extends HookConsumerWidget { - final String albumArt; - - const PlayerOverlay({ - required this.albumArt, - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch(audioPlayerProvider); - final canShow = playlist.activeTrack != null; - - final screenSize = MediaQuery.sizeOf(context); - - final panelController = ref.watch(playerOverlayControllerProvider); - - return SlidingUpPanel( - maxHeight: screenSize.height, - backdropEnabled: false, - minHeight: canShow ? 63 : 0, - onPanelSlide: (position) { - final invertedPosition = 1 - position; - ref.read(navigationPanelHeight.notifier).state = 50 * invertedPosition; - }, - controller: panelController, - color: Colors.transparent, - parallaxEnabled: true, - renderPanelSheet: false, - header: SizedBox( - height: 63, - width: screenSize.width, - child: PlayerOverlayCollapsedSection(panelController: panelController), - ), - panelBuilder: (scrollController) => PlayerView( - panelController: panelController, - scrollController: scrollController, - ), - ); - } -} diff --git a/lib/modules/player/player_overlay_collapsed.dart b/lib/modules/player/player_overlay_collapsed.dart deleted file mode 100644 index d0961ade..00000000 --- a/lib/modules/player/player_overlay_collapsed.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:sliding_up_panel/sliding_up_panel.dart'; -import 'package:spotube/collections/intents.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/player/player_track_details.dart'; -import 'package:spotube/modules/root/spotube_navigation_bar.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/audio_player/querying_track_info.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; - -class PlayerOverlayCollapsedSection extends HookConsumerWidget { - final PanelController panelController; - const PlayerOverlayCollapsedSection({ - super.key, - required this.panelController, - }); - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch(audioPlayerProvider); - final canShow = playlist.activeTrack != null; - - final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); - final playing = - useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - - final theme = Theme.of(context); - - final shouldShow = useState(true); - - ref.listen(navigationPanelHeight, (_, height) { - shouldShow.value = height.ceil() == 50; - }); - - return AnimatedSwitcher( - duration: const Duration(milliseconds: 250), - child: canShow && shouldShow.value - ? Padding( - padding: const EdgeInsets.all(5), - child: SurfaceCard( - surfaceBlur: theme.surfaceBlur, - surfaceOpacity: theme.surfaceOpacity, - padding: EdgeInsets.zero, - borderRadius: theme.borderRadiusLg, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: GestureDetector( - onTap: () { - panelController.open(); - }, - child: Container( - width: double.infinity, - color: Colors.transparent, - child: PlayerTrackDetails( - track: playlist.activeTrack, - color: theme.colorScheme.foreground, - ), - ), - ), - ), - Row( - children: [ - IconButton.ghost( - icon: const Icon(SpotubeIcons.skipBack), - onPressed: isFetchingActiveTrack - ? null - : audioPlayer.skipToPrevious, - ), - Consumer( - builder: (context, ref, _) { - return IconButton.ghost( - icon: isFetchingActiveTrack - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(), - ) - : Icon( - playing - ? SpotubeIcons.pause - : SpotubeIcons.play, - ), - onPressed: Actions.handler( - context, - PlayPauseIntent(ref), - ), - ); - }, - ), - IconButton.ghost( - icon: const Icon(SpotubeIcons.skipForward), - onPressed: isFetchingActiveTrack - ? null - : audioPlayer.skipToNext, - ), - const Gap(5), - ], - ), - ], - ), - ), - ], - ), - ), - ) - : const SizedBox.shrink(), - ); - } -} diff --git a/lib/modules/player/player_queue.dart b/lib/modules/player/player_queue.dart deleted file mode 100644 index bfb7a2e3..00000000 --- a/lib/modules/player/player_queue.dart +++ /dev/null @@ -1,380 +0,0 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:scroll_to_index/scroll_to_index.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/button/back_button.dart'; -import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; -import 'package:spotube/components/fallbacks/not_found.dart'; -import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/track_tile/track_tile.dart'; -import 'package:spotube/components/ui/button_tile.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/modules/player/player_queue_actions.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/audio_player/state.dart'; - -class PlayerQueue extends HookConsumerWidget { - final bool floating; - final AudioPlayerState playlist; - - final Future Function(SpotubeTrackObject track) onJump; - final Future Function(String trackId) onRemove; - final Future Function(int oldIndex, int newIndex) onReorder; - final Future Function() onStop; - - const PlayerQueue({ - this.floating = true, - required this.playlist, - required this.onJump, - required this.onRemove, - required this.onReorder, - required this.onStop, - super.key, - }); - - PlayerQueue.fromAudioPlayerNotifier({ - this.floating = true, - required this.playlist, - required AudioPlayerNotifier notifier, - super.key, - }) : onJump = notifier.jumpToTrack, - onRemove = notifier.removeTrack, - onReorder = notifier.moveTrack, - onStop = notifier.stop; - - @override - Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.sizeOf(context); - - final controller = useAutoScrollController(); - final searchText = useState(''); - - final selectionMode = useState(false); - final selectedTrackIds = useState({}); - - final isSearching = useState(false); - - final tracks = playlist.tracks; - - final filteredTracks = useMemoized( - () { - if (searchText.value.isEmpty) { - return tracks; - } - return tracks - .map((e) => ( - weightedRatio( - '${e.name} - ${e.artists.asString()}', - searchText.value, - ), - e - )) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(); - }, - [tracks, searchText.value], - ); - - if (tracks.isEmpty) { - return const NotFound(); - } - - return Stack( - children: [ - LayoutBuilder( - builder: (context, constrains) { - final searchBar = ConstrainedBox( - constraints: BoxConstraints( - maxHeight: 40, - maxWidth: mediaQuery.smAndDown ? mediaQuery.width - 40 : 300, - ), - child: TextField( - onChanged: (value) { - searchText.value = value; - }, - placeholder: Text(context.l10n.search), - ), - ); - return CallbackShortcuts( - bindings: { - LogicalKeySet(LogicalKeyboardKey.escape): () { - if (!isSearching.value) { - Navigator.of(context).pop(); - } - isSearching.value = false; - searchText.value = ''; - } - }, - child: Column( - children: [ - if (isSearching.value && mediaQuery.smAndDown) - AppBar( - backgroundColor: Colors.transparent, - leading: [ - if (mediaQuery.smAndDown) - IconButton.ghost( - icon: const Icon( - Icons.arrow_back_ios_new_outlined, - ), - onPressed: () { - isSearching.value = false; - searchText.value = ''; - }, - ) - ], - surfaceBlur: 0, - surfaceOpacity: 0, - child: searchBar, - ) - else if (selectionMode.value) - AppBar( - backgroundColor: Colors.transparent, - surfaceBlur: 0, - surfaceOpacity: 0, - leading: [ - IconButton.ghost( - icon: const Icon(SpotubeIcons.close), - onPressed: () { - selectedTrackIds.value = {}; - selectionMode.value = false; - }, - ) - ], - title: SizedBox( - height: 30, - child: AutoSizeText( - '${selectedTrackIds.value.length} selected', - maxLines: 1, - ), - ), - trailing: [ - PlayerQueueActionButton( - builder: (context, close) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Gap(12), - ButtonTile( - style: const ButtonStyle.ghost(), - leading: - const Icon(SpotubeIcons.selectionCheck), - title: Text(context.l10n.select_all), - onPressed: () { - selectedTrackIds.value = - filteredTracks.map((t) => t.id).toSet(); - Navigator.pop(context); - }, - ), - ButtonTile( - style: const ButtonStyle.ghost(), - leading: const Icon(SpotubeIcons.playlistAdd), - title: Text(context.l10n.add_to_playlist), - onPressed: () async { - final selected = filteredTracks - .where((t) => - selectedTrackIds.value.contains(t.id)) - .toList(); - close(); - if (selected.isEmpty) return; - final res = await showDialog( - context: context, - builder: (context) => - PlaylistAddTrackDialog( - tracks: selected, - openFromPlaylist: null, - ), - ); - if (res == true) { - selectedTrackIds.value = {}; - selectionMode.value = false; - } - }, - ), - ButtonTile( - style: const ButtonStyle.ghost(), - leading: const Icon(SpotubeIcons.trash), - title: Text(context.l10n.remove_from_queue), - onPressed: () async { - final ids = selectedTrackIds.value.toList(); - close(); - if (ids.isEmpty) return; - await Future.wait( - ids.map((id) => onRemove(id))); - if (context.mounted) { - selectedTrackIds.value = {}; - selectionMode.value = false; - } - }, - ), - const Gap(12), - ], - ), - ), - ], - ) - else - AppBar( - trailingGap: 0, - backgroundColor: Colors.transparent, - surfaceBlur: 0, - surfaceOpacity: 0, - title: mediaQuery.mdAndUp || !isSearching.value - ? SizedBox( - height: 30, - child: AutoSizeText( - context.l10n.tracks_in_queue(tracks.length), - maxLines: 1, - ), - ) - : null, - trailing: [ - if (mediaQuery.mdAndUp) - searchBar - else - IconButton.ghost( - icon: const Icon(SpotubeIcons.filter), - onPressed: () { - isSearching.value = !isSearching.value; - }, - ), - if (!isSearching.value) ...[ - const SizedBox(width: 10), - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.clear_all)) - .call, - child: IconButton.outline( - icon: const Icon(SpotubeIcons.playlistRemove), - onPressed: () { - onStop(); - closeDrawer(context); - }, - ), - ), - const Gap(5), - if (mediaQuery.smAndDown) - const BackButton(icon: SpotubeIcons.angleDown), - ], - ], - ), - const Divider(), - Expanded( - child: InterScrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - slivers: [ - const SliverGap(10), - SliverReorderableList( - onReorder: onReorder, - itemCount: filteredTracks.length, - onReorderStart: (index) { - HapticFeedback.selectionClick(); - }, - onReorderEnd: (index) { - HapticFeedback.selectionClick(); - }, - itemBuilder: (context, i) { - final track = filteredTracks.elementAt(i); - - void toggleSelection(String id) { - final s = {...selectedTrackIds.value}; - if (s.contains(id)) { - s.remove(id); - } else { - s.add(id); - } - selectedTrackIds.value = s; - if (selectedTrackIds.value.isEmpty) { - selectionMode.value = false; - } - } - - return AutoScrollTag( - key: ValueKey(i), - controller: controller, - index: i, - child: TrackTile( - playlist: playlist, - index: i, - track: track, - selectionMode: selectionMode.value, - selected: - selectedTrackIds.value.contains(track.id), - onChanged: selectionMode.value - ? (_) => toggleSelection(track.id) - : null, - onTap: () async { - if (selectionMode.value) { - toggleSelection(track.id); - return; - } - if (playlist.activeTrack?.id == track.id) { - return; - } - await onJump(track); - }, - onLongPress: () { - if (!selectionMode.value) { - selectionMode.value = true; - selectedTrackIds.value = {track.id}; - } else { - toggleSelection(track.id); - } - }, - leadingActions: [ - if (!isSearching.value && - searchText.value.isEmpty && - !selectionMode.value) - Padding( - padding: - const EdgeInsets.only(left: 8.0), - child: ReorderableDragStartListener( - index: i, - child: const Icon( - SpotubeIcons.dragHandle, - ), - ), - ), - ], - ), - ); - }, - ), - const SliverSafeArea(sliver: SliverGap(100)), - ], - ), - ), - ), - ], - ), - ); - }, - ), - Positioned( - right: 20, - bottom: 20, - child: IconButton.secondary( - icon: const Icon(SpotubeIcons.angleDown), - onPressed: () { - controller.scrollToIndex( - playlist.currentIndex, - preferPosition: AutoScrollPosition.middle, - ); - }, - ), - ) - ], - ); - } -} diff --git a/lib/modules/player/player_queue_actions.dart b/lib/modules/player/player_queue_actions.dart deleted file mode 100644 index 3d1666c2..00000000 --- a/lib/modules/player/player_queue_actions.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/constrains.dart'; - -class PlayerQueueActionButton extends StatelessWidget { - final Widget Function(BuildContext context, VoidCallback close) builder; - - const PlayerQueueActionButton({ - super.key, - required this.builder, - }); - - @override - Widget build(BuildContext context) { - return IconButton.ghost( - onPressed: () { - final mediaQuery = MediaQuery.sizeOf(context); - - if (mediaQuery.lgAndUp) { - showDropdown( - context: context, - builder: (context) { - return SizedBox( - width: 220 * context.theme.scaling, - child: Card( - padding: EdgeInsets.zero, - child: builder(context, () => closeOverlay(context)), - ), - ); - }, - ); - } else { - openSheet( - context: context, - builder: (context) => builder(context, () => closeSheet(context)), - position: OverlayPosition.bottom, - ); - } - }, - icon: const Icon(SpotubeIcons.moreHorizontal), - ); - } -} diff --git a/lib/modules/player/player_track_details.dart b/lib/modules/player/player_track_details.dart deleted file mode 100644 index c158aed3..00000000 --- a/lib/modules/player/player_track_details.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:auto_route/auto_route.dart'; - -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/links/artist_link.dart'; -import 'package:spotube/components/links/link_text.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; - -class PlayerTrackDetails extends HookConsumerWidget { - final Color? color; - final SpotubeTrackObject? track; - const PlayerTrackDetails({super.key, this.color, this.track}); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - final playback = ref.watch(audioPlayerProvider); - - return Row( - children: [ - if (playback.activeTrack != null) - Container( - padding: const EdgeInsets.all(6), - constraints: const BoxConstraints( - maxWidth: 80, - maxHeight: 80, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: UniversalImage( - path: (track?.album.images) - .asUrlString(placeholder: ImagePlaceholder.albumArt), - placeholder: Assets.images.albumPlaceholder.path, - ), - ), - ), - if (mediaQuery.mdAndDown) - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - Text( - playback.activeTrack?.name ?? "", - overflow: TextOverflow.ellipsis, - style: theme.typography.normal.copyWith( - color: color, - ), - ), - Text( - playback.activeTrack?.artists.asString() ?? "", - overflow: TextOverflow.ellipsis, - style: theme.typography.small.copyWith(color: color), - ) - ], - ), - ), - if (mediaQuery.lgAndUp) - Flexible( - flex: 1, - child: Column( - children: [ - LinkText( - playback.activeTrack?.name ?? "", - TrackRoute(trackId: playback.activeTrack?.id ?? ""), - push: true, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontWeight: FontWeight.bold, color: color), - ), - ArtistLink( - artists: playback.activeTrack?.artists ?? [], - onRouteChange: (route) { - context.router.navigateNamed(route); - }, - onOverflowArtistClick: () => - context.navigateTo(TrackRoute(trackId: track!.id)), - ) - ], - ), - ), - ], - ); - } -} diff --git a/lib/modules/player/sibling_tracks_sheet.dart b/lib/modules/player/sibling_tracks_sheet.dart deleted file mode 100644 index b9bd7631..00000000 --- a/lib/modules/player/sibling_tracks_sheet.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/fallbacks/not_found.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/ui/button_tile.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/duration.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/server/sourced_track_provider.dart'; - -class SiblingTracksSheet extends HookConsumerWidget { - final bool floating; - const SiblingTracksSheet({ - super.key, - this.floating = true, - }); - - @override - Widget build(BuildContext context, ref) { - final controller = useScrollController(); - - final activeTrack = - ref.watch(audioPlayerProvider.select((e) => e.activeTrack)); - - if (activeTrack == null || activeTrack is! SpotubeFullTrackObject) { - return const SafeArea(child: NotFound()); - } - - return HookBuilder(builder: (context) { - final sourcedTrack = ref.watch(sourcedTrackProvider(activeTrack)); - final sourcedTrackNotifier = - ref.watch(sourcedTrackProvider(activeTrack).notifier); - - final siblings = useMemoized>( - () => !sourcedTrack.isLoading - ? [ - if (sourcedTrack.asData?.value != null) - sourcedTrack.asData!.value.info, - ...?sourcedTrack.asData?.value.siblings, - ] - : [], - [sourcedTrack], - ); - - useEffect(() { - /// Populate sibling when active track changes - if (sourcedTrack.asData?.value != null && - sourcedTrack.asData?.value.siblings.isEmpty == true) { - sourcedTrackNotifier.copyWithSibling(); - } - return null; - }, [sourcedTrack]); - - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16), - child: Row( - spacing: 5, - children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: Text( - context.l10n.alternative_track_sources, - ).bold()), - ], - ), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: sourcedTrack.isLoading - ? const SizedBox( - width: double.infinity, - child: LinearProgressIndicator(), - ) - : const SizedBox.shrink(), - ), - Expanded( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) => - FadeTransition(opacity: animation, child: child), - child: InterScrollbar( - controller: controller, - child: ListView.separated( - padding: const EdgeInsets.all(8.0), - controller: controller, - itemCount: siblings.length, - separatorBuilder: (context, index) => const Gap(8), - itemBuilder: (context, index) { - final sourceInfo = siblings[index]; - - return ButtonTile( - style: ButtonVariance.ghost, - padding: const EdgeInsets.symmetric(horizontal: 8), - title: Text( - sourceInfo.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - leading: sourceInfo.thumbnail != null - ? UniversalImage( - path: sourceInfo.thumbnail!, - height: 60, - width: 60, - ) - : null, - trailing: - Text(sourceInfo.duration.toHumanReadableString()), - subtitle: Text( - sourceInfo.artists.join(", "), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - enabled: !sourcedTrack.isLoading, - selected: !sourcedTrack.isLoading && - sourceInfo.id == sourcedTrack.asData?.value.info.id, - onPressed: () async { - if (!sourcedTrack.isLoading && - sourceInfo.id != - sourcedTrack.asData?.value.info.id) { - await sourcedTrackNotifier - .swapWithSibling(sourceInfo); - await ref - .read(audioPlayerProvider.notifier) - .swapActiveSource(); - - if (context.mounted) { - if (MediaQuery.sizeOf(context).mdAndUp) { - closeOverlay(context); - } else { - closeDrawer(context); - } - } - } - }, - ); - }, - ), - ), - ), - ), - ], - ), - ); - }); - } -} diff --git a/lib/modules/player/use_progress.dart b/lib/modules/player/use_progress.dart deleted file mode 100644 index eaea638e..00000000 --- a/lib/modules/player/use_progress.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; - -({ - double progressStatic, - Duration position, - Duration duration, - double bufferProgress -}) useProgress(WidgetRef ref) { - final bufferProgress = - useStream(audioPlayer.bufferedPositionStream).data?.inSeconds ?? 0; - - final duration = useState(Duration.zero); - final position = useState(Duration.zero); - - final sliderMax = duration.value.inSeconds; - final sliderValue = position.value.inSeconds; - - useEffect(() { - duration.value = audioPlayer.duration; - - final durationSubscription = audioPlayer.durationStream.listen((event) { - duration.value = event; - }); - - position.value = audioPlayer.position; - - var lastPosition = position.value; - - // audioPlayer.positionStream is fired every 200ms and only 1s delay is - // enough. Thus only update the position if the difference is more than 1s - // Reduces CPU usage - final positionSubscription = audioPlayer.positionStream.listen((event) { - final diff = event.inMilliseconds - lastPosition.inMilliseconds; - if (event.inMilliseconds > 1000 && diff < 1000 && diff > 0) return; - - lastPosition = event; - position.value = event; - }); - - return () { - positionSubscription.cancel(); - durationSubscription.cancel(); - }; - }, []); - - return ( - progressStatic: - sliderMax == 0 || sliderValue > sliderMax ? 0 : sliderValue / sliderMax, - position: position.value, - duration: duration.value, - bufferProgress: sliderMax == 0 || bufferProgress > sliderMax - ? 0 - : bufferProgress / sliderMax, - ); -} diff --git a/lib/modules/player/volume_slider.dart b/lib/modules/player/volume_slider.dart deleted file mode 100644 index ee4ac9c5..00000000 --- a/lib/modules/player/volume_slider.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/gestures.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; - -class VolumeSlider extends HookConsumerWidget { - final bool fullWidth; - - final double value; - final ValueChanged onChanged; - - const VolumeSlider({ - super.key, - this.fullWidth = false, - required this.value, - required this.onChanged, - }); - - @override - Widget build(BuildContext context, ref) { - var slider = Listener( - onPointerSignal: (event) async { - if (event is PointerScrollEvent) { - if (event.scrollDelta.dy > 0) { - final newValue = value - .2; - onChanged(newValue < 0 ? 0 : newValue); - } else { - final newValue = value + .2; - onChanged(newValue > 1 ? 1 : newValue); - } - } - }, - child: SizedBox( - height: 20, - width: 100, - child: Slider( - min: 0, - max: 1, - value: SliderValue.single(value), - onChanged: (v) => onChanged(v.value), - ), - ), - ); - - return Row( - mainAxisAlignment: - !fullWidth ? MainAxisAlignment.center : MainAxisAlignment.start, - children: [ - IconButton( - variance: ButtonVariance.ghost, - icon: Icon( - value == 0 - ? SpotubeIcons.volumeMute - : value <= 0.2 - ? SpotubeIcons.volumeLow - : value <= 0.6 - ? SpotubeIcons.volumeMedium - : SpotubeIcons.volumeHigh, - size: 16, - ), - onPressed: () { - if (value == 0) { - onChanged(1); - } else { - onChanged(0); - } - }, - ), - if (fullWidth) Expanded(child: slider) else slider, - ], - ); - } -} diff --git a/lib/modules/playlist/playlist_card.dart b/lib/modules/playlist/playlist_card.dart deleted file mode 100644 index 1d221a33..00000000 --- a/lib/modules/playlist/playlist_card.dart +++ /dev/null @@ -1,224 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/playbutton_view/playbutton_card.dart'; -import 'package:spotube/components/playbutton_view/playbutton_tile.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/querying_track_info.dart'; -import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/metadata_plugin/library/tracks.dart'; -import 'package:spotube/provider/metadata_plugin/tracks/playlist.dart'; -import 'package:spotube/provider/metadata_plugin/core/user.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; - -class PlaylistCard extends HookConsumerWidget { - final SpotubeSimplePlaylistObject playlist; - final bool _isTile; - - const PlaylistCard( - this.playlist, { - super.key, - }) : _isTile = false; - - const PlaylistCard.tile( - this.playlist, { - super.key, - }) : _isTile = true; - - @override - Widget build(BuildContext context, ref) { - final playlistQueue = ref.watch(audioPlayerProvider); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); - final historyNotifier = ref.read(playbackHistoryActionsProvider); - - final playing = - useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - - final isPlaylistPlaying = useMemoized( - () => playlistQueue.containsCollection(playlist.id), - [playlistQueue, playlist.id], - ); - - final updating = useState(false); - final me = ref.watch(metadataPluginUserProvider); - - final fetchInitialTracks = useCallback(() async { - if (playlist.id == 'user-liked-tracks') { - final tracks = await ref.read(metadataPluginSavedTracksProvider.future); - return tracks.items; - } - - final result = await ref - .read(metadataPluginPlaylistTracksProvider(playlist.id).future); - - return result.items; - }, [playlist.id, ref]); - - final fetchAllTracks = useCallback(() async { - await fetchInitialTracks(); - - if (playlist.id == 'user-liked-tracks') { - return ref.read(metadataPluginSavedTracksProvider.notifier).fetchAll(); - } - - return ref - .read(metadataPluginPlaylistTracksProvider(playlist.id).notifier) - .fetchAll(); - }, [playlist.id, ref, fetchInitialTracks]); - - final onTap = useCallback(() { - context.navigateTo(PlaylistRoute(id: playlist.id, playlist: playlist)); - }, [context, playlist]); - - final onPlaybuttonPressed = useCallback(() async { - try { - updating.value = true; - if (isPlaylistPlaying && playing) { - return audioPlayer.pause(); - } else if (isPlaylistPlaying && !playing) { - return audioPlayer.resume(); - } - - final fetchedInitialTracks = await fetchInitialTracks(); - - if (fetchedInitialTracks.isEmpty || !context.mounted) return; - - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - if (isRemoteDevice == null) return; - if (isRemoteDevice) { - final remotePlayback = ref.read(connectProvider.notifier); - final allTracks = await fetchAllTracks(); - await remotePlayback.load( - WebSocketLoadEventData.playlist( - tracks: allTracks, - collection: playlist, - ), - ); - } else { - await playlistNotifier.load(fetchedInitialTracks, autoPlay: true); - playlistNotifier.addCollection(playlist.id); - historyNotifier.addPlaylists([playlist]); - - final allTracks = await fetchAllTracks(); - - await playlistNotifier - .addTracks(allTracks.sublist(fetchedInitialTracks.length)); - } - } finally { - if (context.mounted) { - updating.value = false; - } - } - }, [ - isPlaylistPlaying, - playing, - fetchInitialTracks, - context, - showSelectDeviceDialog, - ref, - connectProvider, - fetchAllTracks, - playlistNotifier, - playlist.id, - historyNotifier, - playlist, - updating - ]); - - final onAddToQueuePressed = useCallback(() async { - updating.value = true; - try { - if (isPlaylistPlaying) return; - - final fetchedInitialTracks = await fetchAllTracks(); - - if (fetchedInitialTracks.isEmpty) return; - - playlistNotifier.addTracks(fetchedInitialTracks); - playlistNotifier.addCollection(playlist.id); - historyNotifier.addPlaylists([playlist]); - if (context.mounted) { - showToast( - context: context, - builder: (context, overlay) { - return SurfaceCard( - child: Basic( - content: Text( - context.l10n - .added_num_tracks_to_queue(fetchedInitialTracks.length), - ), - trailing: Button.outline( - child: Text(context.l10n.undo), - onPressed: () { - playlistNotifier - .removeTracks(fetchedInitialTracks.map((e) => e.id)); - }, - ), - ), - ); - }, - ); - } - } finally { - updating.value = false; - } - }, [ - isPlaylistPlaying, - fetchAllTracks, - playlistNotifier, - playlist.id, - historyNotifier, - playlist, - context, - updating - ]); - - final imageUrl = useMemoized( - () => playlist.images.from200PxTo300PxOrSmallestImage( - ImagePlaceholder.collection, - ), - [playlist.images], - ); - - final isLoading = - (isPlaylistPlaying && isFetchingActiveTrack) || updating.value; - final isOwner = playlist.owner.id == me.asData?.value?.id && - me.asData?.value?.id != null; - - if (_isTile) { - return PlaybuttonTile( - title: playlist.name, - description: playlist.description, - image: null, - imageUrl: imageUrl, - isPlaying: isPlaylistPlaying, - isLoading: isLoading, - isOwner: isOwner, - onTap: onTap, - onPlaybuttonPressed: onPlaybuttonPressed, - onAddToQueuePressed: onAddToQueuePressed, - ); - } - - return PlaybuttonCard( - title: playlist.name, - description: playlist.description, - image: null, - imageUrl: imageUrl, - isPlaying: isPlaylistPlaying, - isLoading: isLoading, - isOwner: isOwner, - onTap: onTap, - onPlaybuttonPressed: onPlaybuttonPressed, - onAddToQueuePressed: onAddToQueuePressed, - ); - } -} diff --git a/lib/modules/playlist/playlist_create_dialog.dart b/lib/modules/playlist/playlist_create_dialog.dart deleted file mode 100644 index 0fdcf081..00000000 --- a/lib/modules/playlist/playlist_create_dialog.dart +++ /dev/null @@ -1,307 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:auto_route/auto_route.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:form_builder_validators/form_builder_validators.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:path/path.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/form/checkbox_form_field.dart'; -import 'package:spotube/components/form/text_form_field.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/library/playlists.dart'; -import 'package:spotube/provider/metadata_plugin/playlist/playlist.dart'; - -class PlaylistCreateDialog extends HookConsumerWidget { - /// Track ids to add to the playlist - final List trackIds; - final String? playlistId; - const PlaylistCreateDialog({ - super.key, - this.trackIds = const [], - this.playlistId, - }); - - @override - Widget build(BuildContext context, ref) { - final userPlaylists = ref.watch(metadataPluginSavedPlaylistsProvider); - final playlist = - ref.watch(metadataPluginPlaylistProvider(playlistId ?? "")); - final playlistNotifier = - ref.watch(metadataPluginPlaylistProvider(playlistId ?? "").notifier); - - final isSubmitting = useState(false); - - final formKey = useMemoized(() => GlobalKey(), []); - - final updatingPlaylist = useMemoized( - () => userPlaylists.asData?.value.items - .firstWhereOrNull((playlist) => playlist.id == playlistId), - [ - userPlaylists.asData?.value.items, - playlistId, - ], - ); - - final isUpdatingPlaylist = playlistId != null; - - final l10n = context.l10n; - final theme = Theme.of(context); - - useEffect(() { - if (playlist.asData?.value != null) { - formKey.currentState?.patchValue({ - 'playlistName': playlist.asData!.value.name, - 'description': playlist.asData!.value.description, - 'public': playlist.asData!.value.public, - 'collaborative': playlist.asData!.value.collaborative, - }); - } - - return; - }, [playlist]); - - final onError = useCallback((error) { - showToast( - context: context, - location: ToastLocation.topRight, - builder: (context, overlay) { - return SurfaceCard( - child: Basic( - title: Text( - l10n.error(l10n.epic_failure), - style: theme.typography.normal.copyWith( - color: theme.colorScheme.destructive, - ), - ), - ), - ); - }, - ); - }, [l10n, theme]); - - Future onCreate() async { - if (!formKey.currentState!.saveAndValidate()) return; - - try { - isSubmitting.value = true; - final values = formKey.currentState!.value; - - final payload = ( - playlistName: values['playlistName'], - collaborative: values['collaborative'], - public: values['public'], - description: values['description'], - base64Image: (values['image'] as XFile?)?.path != null - ? await (values['image'] as XFile) - .readAsBytes() - .then((bytes) => base64Encode(bytes)) - : null, - ); - - if (isUpdatingPlaylist) { - await playlistNotifier.modify( - name: payload.playlistName, - description: payload.description, - public: payload.public, - collaborative: payload.collaborative, - onError: onError, - ); - } else { - await playlistNotifier.create( - name: payload.playlistName, - description: payload.description, - public: payload.public, - collaborative: payload.collaborative, - onError: onError, - ); - } - - if (trackIds.isNotEmpty) { - await playlistNotifier.addTracks(trackIds, onError); - } - } finally { - isSubmitting.value = false; - if (context.mounted && - !ref - .read(metadataPluginPlaylistProvider(playlistId ?? "")) - .hasError) { - context.router.maybePop( - await ref - .read(metadataPluginPlaylistProvider(playlistId ?? "").future), - ); - } - } - } - - return AlertDialog( - title: Text( - isUpdatingPlaylist - ? context.l10n.update_playlist - : context.l10n.create_a_playlist, - ), - actions: [ - Button.outline( - child: Text(context.l10n.cancel), - onPressed: () { - Navigator.pop(context); - }, - ), - Button.primary( - onPressed: onCreate, - enabled: !playlist.isLoading & !isSubmitting.value, - child: Text( - isUpdatingPlaylist ? context.l10n.update : context.l10n.create, - ), - ), - ], - content: Container( - width: MediaQuery.of(context).size.width, - constraints: const BoxConstraints(maxWidth: 500), - child: FormBuilder( - key: formKey, - initialValue: { - 'playlistName': updatingPlaylist?.name, - 'description': updatingPlaylist?.description, - 'public': playlist.asData?.value.public ?? false, - 'collaborative': playlist.asData?.value.collaborative ?? false, - }, - child: ListView( - shrinkWrap: true, - children: [ - FormBuilderField( - name: 'image', - validator: (value) { - if (value == null) return null; - final file = File(value.path); - - if (file.lengthSync() > 256000) { - return "Image size should be less than 256kb"; - } - - if (extension(file.path) != ".png") { - return "Image should be in PNG format"; - } - return null; - }, - builder: (field) { - return Column( - spacing: 10, - children: [ - UniversalImage( - path: field.value?.path ?? - (updatingPlaylist?.images).asUrlString( - placeholder: ImagePlaceholder.collection, - ), - height: 200, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Button.secondary( - leading: const Icon(SpotubeIcons.edit), - child: Text( - field.value?.path != null || - updatingPlaylist?.images != null - ? context.l10n.change_cover - : context.l10n.add_cover, - ), - onPressed: () async { - final imageFile = await ImagePicker().pickImage( - source: ImageSource.gallery, - ); - - if (imageFile != null) { - field.didChange(imageFile); - field.validate(); - field.save(); - } - }, - ), - const SizedBox(width: 10), - IconButton.destructive( - icon: const Icon(SpotubeIcons.trash), - enabled: field.value != null, - onPressed: () { - field.didChange(null); - field.validate(); - field.save(); - }, - ), - ], - ), - if (field.hasError) - Text( - field.errorText ?? "", - style: theme.typography.normal.copyWith( - color: theme.colorScheme.destructive, - ), - ) - ], - ); - }, - ), - const Gap(20), - TextFormBuilderField( - name: 'playlistName', - label: Text(context.l10n.playlist_name), - placeholder: Text(context.l10n.name_of_playlist), - validator: FormBuilderValidators.required(), - ), - const Gap(20), - TextFormBuilderField( - name: 'description', - label: Text(context.l10n.description), - validator: FormBuilderValidators.required(), - placeholder: Text(context.l10n.description), - keyboardType: TextInputType.multiline, - maxLines: 5, - ), - const Gap(20), - CheckboxFormBuilderField( - name: 'public', - trailing: Text(context.l10n.public), - ), - const Gap(10), - CheckboxFormBuilderField( - name: 'collaborative', - trailing: Text(context.l10n.collaborative), - ), - ], - ), - ), - ), - ); - } -} - -class PlaylistCreateDialogButton extends HookConsumerWidget { - const PlaylistCreateDialogButton({super.key}); - - showPlaylistDialog(BuildContext context) { - showDialog( - context: context, - alignment: Alignment.center, - builder: (context) => const ToastLayer( - child: PlaylistCreateDialog(), - ), - ); - } - - @override - Widget build(BuildContext context, ref) { - return Button.secondary( - leading: const Icon(SpotubeIcons.addFilled), - child: Text(context.l10n.playlist), - onPressed: () => showPlaylistDialog(context), - ); - } -} diff --git a/lib/modules/root/bottom_player.dart b/lib/modules/root/bottom_player.dart deleted file mode 100644 index 33497d8d..00000000 --- a/lib/modules/root/bottom_player.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; - -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/modules/player/player_actions.dart'; -import 'package:spotube/modules/player/player_overlay.dart'; -import 'package:spotube/modules/player/player_track_details.dart'; -import 'package:spotube/modules/player/player_controls.dart'; -import 'package:spotube/modules/player/volume_slider.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -import 'package:spotube/provider/volume_provider.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:window_manager/window_manager.dart'; - -class BottomPlayer extends HookConsumerWidget { - const BottomPlayer({super.key}); - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch(audioPlayerProvider); - final layoutMode = - ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - - final mediaQuery = MediaQuery.of(context); - - String albumArt = useMemoized( - () => playlist.activeTrack?.album.images.isNotEmpty == true - ? (playlist.activeTrack?.album.images).asUrlString( - index: (playlist.activeTrack?.album.images.length ?? 1) - 1, - placeholder: ImagePlaceholder.albumArt, - ) - : Assets.images.albumPlaceholder.path, - [playlist.activeTrack?.album.images], - ); - - // returning an empty non spacious Container as the overlay will take - // place in the global overlay stack aka [_entries] - if (layoutMode == LayoutMode.compact || - ((mediaQuery.mdAndDown) && layoutMode == LayoutMode.adaptive)) { - return PlayerOverlay(albumArt: albumArt); - } - - return SurfaceCard( - borderRadius: BorderRadius.zero, - surfaceBlur: context.theme.surfaceBlur, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: PlayerTrackDetails(track: playlist.activeTrack), - ), - // controls - const Flexible( - flex: 3, - child: Padding( - padding: EdgeInsets.only(top: 5), - child: PlayerControls(), - ), - ), - // add to saved tracks - Column( - mainAxisSize: MainAxisSize.min, - children: [ - PlayerActions( - extraActions: [ - Tooltip( - tooltip: - TooltipContainer(child: Text(context.l10n.mini_player)) - .call, - child: IconButton( - variance: ButtonVariance.ghost, - icon: const Icon(SpotubeIcons.miniPlayer), - onPressed: () async { - if (!kIsDesktop) return; - - final prevSize = await windowManager.getSize(); - await windowManager.setMinimumSize( - const Size(300, 300), - ); - await windowManager.setAlwaysOnTop(true); - if (!kIsLinux) { - await windowManager.setHasShadow(false); - } - await windowManager.setAlignment(Alignment.topRight); - await windowManager.setSize(const Size(400, 500)); - await Future.delayed( - const Duration(milliseconds: 100), - () async { - if (context.mounted) { - context.navigateTo( - MiniLyricsRoute(prevSize: prevSize), - ); - } - }, - ); - }, - ), - ), - ], - ), - Container( - height: 40, - constraints: const BoxConstraints(maxWidth: 250), - padding: const EdgeInsets.only(right: 10), - child: Consumer(builder: (context, ref, _) { - final volume = ref.watch(volumeProvider); - return VolumeSlider( - fullWidth: true, - value: volume, - onChanged: (value) { - ref.read(volumeProvider.notifier).setVolume(value); - }, - ); - }), - ) - ], - ), - ], - ), - ); - } -} diff --git a/lib/modules/root/sidebar/sidebar.dart b/lib/modules/root/sidebar/sidebar.dart deleted file mode 100644 index 1538d624..00000000 --- a/lib/modules/root/sidebar/sidebar.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -import 'package:spotube/collections/side_bar_tiles.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/modules/root/sidebar/sidebar_footer.dart'; - -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class Sidebar extends HookConsumerWidget { - final Widget child; - - const Sidebar({ - required this.child, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final ThemeData(:colorScheme) = Theme.of(context); - final mediaQuery = MediaQuery.sizeOf(context); - - final layoutMode = - ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - - final sidebarTileList = useMemoized( - () => getSidebarTileList(context.l10n), - [context.l10n], - ); - - final sidebarLibraryTileList = useMemoized( - () => getSidebarLibraryTileList(context.l10n), - [context.l10n], - ); - - final tileList = [...sidebarTileList, ...sidebarLibraryTileList]; - - final router = context.watchRouter; - - final selectedIndex = tileList.indexWhere( - (e) => router.currentPath.startsWith(e.pathPrefix), - ); - - if (layoutMode == LayoutMode.compact || - (mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) { - return child; - } - - final navigationButtons = [ - NavigationLabel( - child: mediaQuery.lgAndUp - ? DefaultTextStyle( - style: TextStyle( - fontFamily: "Cookie", - fontSize: 30, - letterSpacing: 1.8, - color: colorScheme.foreground, - ), - child: const Text("Spotube"), - ) - : const Text(""), - ), - for (final tile in sidebarTileList) - NavigationButton( - style: router.currentPath.startsWith(tile.pathPrefix) - ? const ButtonStyle.secondary() - : null, - label: mediaQuery.lgAndUp ? Text(tile.title) : null, - child: Tooltip( - tooltip: TooltipContainer(child: Text(tile.title)).call, - child: Icon(tile.icon), - ), - onPressed: () { - context.navigateTo(tile.route); - }, - ), - const NavigationDivider(), - if (mediaQuery.lgAndUp) - NavigationLabel(child: Text(context.l10n.library)), - for (final tile in sidebarLibraryTileList) - NavigationButton( - style: router.currentPath.startsWith(tile.pathPrefix) - ? const ButtonStyle.secondary() - : null, - label: mediaQuery.lgAndUp ? Text(tile.title) : null, - onPressed: () { - context.navigateTo(tile.route); - }, - child: Tooltip( - tooltip: TooltipContainer(child: Text(tile.title)).call, - child: Icon(tile.icon), - ), - ), - ]; - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - children: [ - Expanded( - child: mediaQuery.lgAndUp - ? NavigationSidebar( - index: selectedIndex, - onSelected: (index) { - final tile = tileList[index]; - context.navigateTo(tile.route); - }, - children: navigationButtons, - ) - : NavigationRail( - alignment: NavigationRailAlignment.start, - index: selectedIndex, - onSelected: (index) { - final tile = tileList[index]; - context.navigateTo(tile.route); - }, - children: navigationButtons, - ), - ), - const SidebarFooter(), - if (mediaQuery.lgAndUp) const Gap(130) else const Gap(65), - ], - ), - const VerticalDivider(), - Expanded(child: child), - ], - ); - } -} diff --git a/lib/modules/root/sidebar/sidebar_footer.dart b/lib/modules/root/sidebar/sidebar_footer.dart deleted file mode 100644 index 0f8ac9d8..00000000 --- a/lib/modules/root/sidebar/sidebar_footer.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart' show Badge; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/modules/connect/connect_device.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/core/user.dart'; - -class SidebarFooter extends HookConsumerWidget implements NavigationBarItem { - const SidebarFooter({ - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final router = AutoRouter.of(context, watch: true); - final mediaQuery = MediaQuery.of(context); - final downloadCount = ref - .watch(downloadManagerProvider) - .where((e) => - e.status == DownloadStatus.downloading || - e.status == DownloadStatus.queued) - .length; - final userSnapshot = ref.watch(metadataPluginUserProvider); - final data = userSnapshot.asData?.value; - - final avatarImg = (data?.images).asUrlString( - index: (data?.images.length ?? 1) - 1, - placeholder: ImagePlaceholder.artist, - ); - - final authenticated = ref.watch(metadataPluginAuthenticatedProvider); - - if (mediaQuery.mdAndDown) { - return Column( - mainAxisSize: MainAxisSize.min, - spacing: 10, - children: [ - Badge( - isLabelVisible: downloadCount > 0, - label: Text(downloadCount.toString()), - child: IconButton( - variance: router.topRoute.name == UserDownloadsRoute.name - ? ButtonVariance.secondary - : ButtonVariance.ghost, - icon: const Icon(SpotubeIcons.download), - onPressed: () => context.navigateTo(const UserDownloadsRoute()), - ), - ), - const ConnectDeviceButton.sidebar(), - IconButton( - variance: ButtonVariance.ghost, - icon: const Icon(SpotubeIcons.settings), - onPressed: () => context.navigateTo(const SettingsRoute()), - ), - ], - ); - } - - return Container( - padding: const EdgeInsets.only(left: 12), - width: 180, - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 10, - children: [ - SizedBox( - width: double.infinity, - child: Button( - style: router.topRoute.name == UserDownloadsRoute.name - ? ButtonVariance.secondary - : ButtonVariance.outline, - onPressed: () { - context.navigateTo(const UserDownloadsRoute()); - }, - leading: const Icon(SpotubeIcons.download), - trailing: downloadCount > 0 - ? PrimaryBadge( - child: Text(downloadCount.toString()), - ) - : null, - child: Text(context.l10n.downloads), - ), - ), - const ConnectDeviceButton.sidebar(), - Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (authenticated.asData?.value == true && data == null) - const CircularProgressIndicator() - else if (data != null) - Flexible( - child: GestureDetector( - onTap: () { - context.navigateTo(const ProfileRoute()); - }, - child: Row( - children: [ - Avatar( - initials: Avatar.getInitials(data.name), - provider: UniversalImage.imageProvider(avatarImg), - ), - const SizedBox(width: 10), - Flexible( - child: Text( - data.name, - maxLines: 1, - softWrap: false, - overflow: TextOverflow.fade, - style: theme.typography.normal - .copyWith(fontWeight: FontWeight.bold), - ), - ), - ], - ), - ), - ), - IconButton( - variance: ButtonVariance.ghost, - icon: const Icon(SpotubeIcons.settings), - onPressed: () { - context.navigateTo(const SettingsRoute()); - }, - ), - ], - ), - ], - ), - ); - } - - @override - bool get selectable => false; -} diff --git a/lib/modules/root/spotube_navigation_bar.dart b/lib/modules/root/spotube_navigation_bar.dart deleted file mode 100644 index 47ea3ca3..00000000 --- a/lib/modules/root/spotube_navigation_bar.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'dart:math'; - -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart' show Badge; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; - -import 'package:spotube/collections/side_bar_tiles.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -final navigationPanelHeight = StateProvider((ref) => 50); - -class SpotubeNavigationBar extends HookConsumerWidget { - const SpotubeNavigationBar({ - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.of(context); - - final downloadCount = ref - .watch(downloadManagerProvider) - .where((e) => - e.status == DownloadStatus.downloading || - e.status == DownloadStatus.queued) - .length; - final layoutMode = - ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - - final navbarTileList = useMemoized( - () => getNavbarTileList(context.l10n), - [context.l10n], - ); - - final panelHeight = ref.watch(navigationPanelHeight); - - final router = context.watchRouter; - final selectedIndex = max( - 0, - navbarTileList.indexWhere( - (e) => router.currentPath.startsWith(e.pathPrefix), - ), - ); - - if (layoutMode == LayoutMode.extended || - (mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) || - panelHeight < 10) { - return const SizedBox(); - } - - return AnimatedContainer( - duration: const Duration(milliseconds: 100), - height: panelHeight, - child: SingleChildScrollView( - child: Column( - children: [ - const Divider(), - NavigationBar( - index: selectedIndex, - surfaceBlur: context.theme.surfaceBlur, - surfaceOpacity: context.theme.surfaceOpacity, - children: [ - for (final tile in navbarTileList) - NavigationButton( - style: navbarTileList[selectedIndex] == tile - ? const ButtonStyle.fixed(density: ButtonDensity.icon) - : const ButtonStyle.muted(density: ButtonDensity.icon), - child: Badge( - isLabelVisible: tile.id == "library" && downloadCount > 0, - label: Text(downloadCount.toString()), - child: Icon(tile.icon), - ), - onPressed: () { - context.navigateTo(tile.route); - }, - ) - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/modules/root/update_dialog.dart b/lib/modules/root/update_dialog.dart deleted file mode 100644 index 4aa2fd13..00000000 --- a/lib/modules/root/update_dialog.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/links/anchor_button.dart'; -import 'package:url_launcher/url_launcher_string.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:version/version.dart'; - -class RootAppUpdateDialog extends StatelessWidget { - final Version? version; - final int? nightlyBuildNum; - - const RootAppUpdateDialog({super.key, this.version}) : nightlyBuildNum = null; - const RootAppUpdateDialog.nightly({super.key, required this.nightlyBuildNum}) - : version = null; - - @override - Widget build(BuildContext context) { - const url = "https://spotube.krtirtho.dev/downloads"; - const nightlyUrl = "https://spotube.krtirtho.dev/downloads/nightly"; - return AlertDialog( - title: Text(context.l10n.spotube_has_an_update), - actions: [ - Button.primary( - child: Text(context.l10n.download_now), - onPressed: () => launchUrlString( - nightlyBuildNum != null ? nightlyUrl : url, - mode: LaunchMode.externalApplication, - ), - ), - ], - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - nightlyBuildNum != null - ? context.l10n.nightly_version(nightlyBuildNum!) - : context.l10n.release_version(version!), - ), - if (nightlyBuildNum == null) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(context.l10n.read_the_latest), - AnchorButton( - context.l10n.release_notes, - style: const TextStyle(color: Colors.blue), - onTap: () => launchUrlString( - url, - mode: LaunchMode.externalApplication, - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/lib/modules/root/use_global_subscriptions.dart b/lib/modules/root/use_global_subscriptions.dart deleted file mode 100644 index 9a492d31..00000000 --- a/lib/modules/root/use_global_subscriptions.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/modules/metadata_plugins/plugin_update_available_dialog.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/updater/update_checker.dart'; -import 'package:spotube/provider/server/routes/connect.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/connectivity_adapter.dart'; -import 'package:spotube/utils/service_utils.dart'; - -void useGlobalSubscriptions(WidgetRef ref) { - final context = useContext(); - final theme = Theme.of(context); - final connectRoutes = ref.watch(serverConnectRoutesProvider); - - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) async { - ServiceUtils.checkForUpdates(context, ref); - - final pluginUpdate = - await ref.read(metadataPluginUpdateCheckerProvider.future); - - if (pluginUpdate != null) { - final pluginConfig = await ref.read(metadataPluginsProvider.future); - if (context.mounted) { - showDialog( - context: context, - builder: (context) => MetadataPluginUpdateAvailableDialog( - plugin: pluginConfig.defaultMetadataPluginConfig!, - update: pluginUpdate, - ), - ); - } - } - }); - - StreamSubscription? audioPlayerSubscription; - bool pausedByStream = false; - - final subscriptions = [ - ConnectionCheckerService.instance.onConnectivityChanged - .listen((connected) async { - audioPlayerSubscription?.cancel(); - - /// Pausing or resuming based on connectivity to avoid MPV skipping - /// audio while retrying to connect - if (audioPlayer.currentIndex >= 0) { - if (connected && audioPlayer.isPaused && pausedByStream) { - await audioPlayer.resume(); - pausedByStream = false; - } else if (!connected && audioPlayer.isPlaying) { - if ((audioPlayer.bufferedPosition - const Duration(seconds: 1)) <= - audioPlayer.position) { - await audioPlayer.pause(); - pausedByStream = true; - } else { - audioPlayerSubscription = - audioPlayer.positionStream.listen((position) async { - if (ConnectionCheckerService.instance.isConnectedSync) return; - - final bufferedPosition = - audioPlayer.bufferedPosition - const Duration(seconds: 1); - final duration = - audioPlayer.duration - const Duration(seconds: 1); - - if (bufferedPosition <= position || position >= duration) { - audioPlayer.pause(); - pausedByStream = true; - } - }); - } - } - } - - // Show notification for connection related issues - if (!context.mounted) return; - - showToast( - context: context, - location: ToastLocation.bottomCenter, - builder: (context, overlay) { - if (connected) { - return SurfaceCard( - child: Basic( - leading: const Icon(SpotubeIcons.wifi), - title: Text(context.l10n.connection_restored), - ), - ); - } - - return SurfaceCard( - fillColor: theme.colorScheme.destructive, - filled: true, - child: Basic( - leading: Icon( - SpotubeIcons.noWifi, - color: theme.colorScheme.destructiveForeground, - ), - trailing: Text( - context.l10n.you_are_offline, - style: TextStyle( - color: theme.colorScheme.destructiveForeground, - ), - ), - ), - ); - }, - ); - }), - connectRoutes.connectClientStream.listen((clientOrigin) { - if (!context.mounted) return; - showToast( - context: context, - location: ToastLocation.topRight, - builder: (context, overlay) { - return SurfaceCard( - fillColor: Colors.yellow[600], - filled: true, - child: Basic( - leading: const Icon( - SpotubeIcons.error, - color: Colors.black, - ), - title: Text( - context.l10n.connect_client_alert(clientOrigin), - style: const TextStyle(color: Colors.black), - ), - ), - ); - }, - ); - }) - ]; - - return () { - for (final subscription in subscriptions) { - subscription.cancel(); - } - }; - }, []); -} diff --git a/lib/modules/search/loading.dart b/lib/modules/search/loading.dart deleted file mode 100644 index 8ca2820f..00000000 --- a/lib/modules/search/loading.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter_undraw/flutter_undraw.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/search/search.dart'; - -class SearchPlaceholder extends HookConsumerWidget { - final AsyncValue snapshot; - final Widget child; - const SearchPlaceholder({ - super.key, - required this.child, - required this.snapshot, - }); - - @override - Widget build(BuildContext context, ref) { - final theme = context.theme; - final mediaQuery = MediaQuery.sizeOf(context); - - final searchTerm = ref.watch(searchTermStateProvider); - - return switch ((searchTerm.isEmpty, snapshot.isLoading)) { - (true, false) => Column( - children: [ - SizedBox( - height: mediaQuery.height * 0.2, - ), - Undraw( - illustration: UndrawIllustration.explore, - color: theme.colorScheme.primary, - height: 200 * theme.scaling, - ), - const SizedBox(height: 20), - Text(context.l10n.search_to_get_results).large(), - ], - ), - (false, true) => Container( - constraints: BoxConstraints( - maxWidth: - mediaQuery.lgAndUp ? mediaQuery.width * 0.5 : mediaQuery.width, - ), - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - context.l10n.crunching_results, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w900, - color: theme.colorScheme.foreground.withValues(alpha: 0.7), - ), - ), - const SizedBox(height: 20), - const LinearProgressIndicator(), - ], - ), - ), - _ => child, - }; - } -} diff --git a/lib/modules/search/sections/albums.dart b/lib/modules/search/sections/albums.dart deleted file mode 100644 index e8bc71fc..00000000 --- a/lib/modules/search/sections/albums.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/provider/metadata_plugin/search/all.dart'; - -class SearchAlbumsSection extends HookConsumerWidget { - const SearchAlbumsSection({ - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final searchTerm = ref.watch(searchTermStateProvider); - final search = ref.watch(metadataPluginSearchAllProvider(searchTerm)); - final albums = search.asData?.value.albums ?? []; - - return HorizontalPlaybuttonCardView( - isLoadingNextPage: false, - hasNextPage: false, - items: albums, - onFetchMore: () {}, - title: Text(context.l10n.albums), - ); - } -} diff --git a/lib/modules/search/sections/artists.dart b/lib/modules/search/sections/artists.dart deleted file mode 100644 index 9da3702c..00000000 --- a/lib/modules/search/sections/artists.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/provider/metadata_plugin/search/all.dart'; - -class SearchArtistsSection extends HookConsumerWidget { - const SearchArtistsSection({ - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final searchTerm = ref.watch(searchTermStateProvider); - final search = ref.watch(metadataPluginSearchAllProvider(searchTerm)); - - final artists = search.asData?.value.artists ?? []; - - return HorizontalPlaybuttonCardView( - isLoadingNextPage: false, - hasNextPage: false, - items: artists, - onFetchMore: () {}, - title: Text(context.l10n.artists), - ); - } -} diff --git a/lib/modules/search/sections/playlists.dart b/lib/modules/search/sections/playlists.dart deleted file mode 100644 index 7e03bdeb..00000000 --- a/lib/modules/search/sections/playlists.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/provider/metadata_plugin/search/all.dart'; - -class SearchPlaylistsSection extends HookConsumerWidget { - const SearchPlaylistsSection({ - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final searchTerm = ref.watch(searchTermStateProvider); - final playlistsQuery = - ref.watch(metadataPluginSearchAllProvider(searchTerm)); - final playlists = playlistsQuery.asData?.value.playlists ?? []; - - return HorizontalPlaybuttonCardView( - isLoadingNextPage: false, - hasNextPage: false, - items: playlists, - onFetchMore: () {}, - title: Text(context.l10n.playlists), - ); - } -} diff --git a/lib/modules/search/sections/tracks.dart b/lib/modules/search/sections/tracks.dart deleted file mode 100644 index 6bc60045..00000000 --- a/lib/modules/search/sections/tracks.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:collection/collection.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/track_tile/track_tile.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/metadata_plugin/search/all.dart'; - -class SearchTracksSection extends HookConsumerWidget { - const SearchTracksSection({ - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final searchTerm = ref.watch(searchTermStateProvider); - final search = ref.watch(metadataPluginSearchAllProvider(searchTerm)); - final tracks = search.asData?.value.tracks ?? []; - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final playlist = ref.watch(audioPlayerProvider); - final theme = Theme.of(context); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (tracks.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - context.l10n.songs, - style: theme.typography.h4, - ), - ), - if (search.isLoading) - const CircularProgressIndicator() - else - ...tracks.mapIndexed((i, track) { - return TrackTile( - index: i, - track: track, - playlist: playlist, - onTap: () async { - final isRemoteDevice = - await showSelectDeviceDialog(context, ref); - - if (isRemoteDevice == null) return; - - if (isRemoteDevice) { - final remotePlayback = ref.read(connectProvider.notifier); - final remotePlaylist = ref.read(queueProvider); - - final isTrackPlaying = - remotePlaylist.activeTrack?.id == track.id; - - if (!isTrackPlaying && context.mounted) { - final shouldPlay = (playlist.tracks.length) > 20 - ? await showPromptDialog( - context: context, - title: context.l10n.playing_track( - track.name, - ), - message: context.l10n.queue_clear_alert( - playlist.tracks.length, - ), - ) - : true; - - if (shouldPlay) { - await remotePlayback.load( - WebSocketLoadEventData.playlist( - tracks: [track], - ), - ); - } - } - } else { - final isTrackPlaying = playlist.activeTrack?.id == track.id; - if (!isTrackPlaying && context.mounted) { - final shouldPlay = (playlist.tracks.length) > 20 - ? await showPromptDialog( - context: context, - title: context.l10n.playing_track( - track.name, - ), - message: context.l10n.queue_clear_alert( - playlist.tracks.length, - ), - ) - : true; - - if (shouldPlay) { - await playlistNotifier.load( - [track], - autoPlay: true, - ); - } - } - } - }, - ); - }), - ], - ); - } -} diff --git a/lib/modules/settings/color_scheme_picker_dialog.dart b/lib/modules/settings/color_scheme_picker_dialog.dart deleted file mode 100644 index 9469ff00..00000000 --- a/lib/modules/settings/color_scheme_picker_dialog.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class SpotubeColor extends Color { - final String name; - - const SpotubeColor(super.color, {required this.name}); - - const SpotubeColor.from(super.value, {required this.name}); - - factory SpotubeColor.fromString(String string) { - final slices = string.split(":"); - return SpotubeColor(int.parse(slices.last), name: slices.first); - } - - @override - String toString() { - return "$name:${toARGB32()}"; - } -} - -final Set colorsMap = { - SpotubeColor(Colors.slate.value, name: "slate"), - SpotubeColor(Colors.gray.value, name: "gray"), - SpotubeColor(Colors.zinc.value, name: "zinc"), - SpotubeColor(Colors.neutral.value, name: "neutral"), - SpotubeColor(Colors.stone.value, name: "stone"), - SpotubeColor(Colors.red.value, name: "red"), - SpotubeColor(Colors.orange.value, name: "orange"), - SpotubeColor(Colors.yellow.value, name: "yellow"), - SpotubeColor(Colors.green.value, name: "green"), - SpotubeColor(Colors.blue.value, name: "blue"), - SpotubeColor(Colors.violet.value, name: "violet"), - SpotubeColor(Colors.rose.value, name: "rose"), -}; - -final colorSchemeMap = { - "slate": LegacyColorSchemes.slate, - "gray": LegacyColorSchemes.gray, - "zinc": LegacyColorSchemes.zinc, - "neutral": LegacyColorSchemes.neutral, - "stone": LegacyColorSchemes.stone, - "red": LegacyColorSchemes.red, - "orange": LegacyColorSchemes.orange, - "yellow": LegacyColorSchemes.yellow, - "green": LegacyColorSchemes.green, - "blue": LegacyColorSchemes.blue, - "violet": LegacyColorSchemes.violet, - "rose": LegacyColorSchemes.rose, -}; - -class ColorSchemePickerDialog extends HookConsumerWidget { - const ColorSchemePickerDialog({super.key}); - - @override - Widget build(BuildContext context, ref) { - final preferences = ref.watch(userPreferencesProvider); - final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); - - final scheme = preferences.accentColorScheme; - final active = useState( - colorsMap.firstWhereOrNull( - (element) { - return scheme.name == element.name; - }, - )?.name, - ); - - return AlertDialog( - title: Text( - context.l10n.pick_color_scheme, - style: TextStyle(color: context.theme.colorScheme.foreground), - ).large(), - actions: [ - Button.outline( - child: Text(context.l10n.cancel), - onPressed: () { - Navigator.pop(context); - }, - ), - Button.primary( - onPressed: () { - Navigator.pop(context); - }, - child: Text(context.l10n.save), - ), - ], - content: SizedBox( - height: 200, - width: 400, - child: Wrap( - spacing: 8, - runSpacing: 8, - children: colorsMap.map( - (color) { - return ColorChip( - name: color.name, - color: color, - isActive: color.name == active.value, - onPressed: () { - active.value = color.name; - preferencesNotifier.setAccentColorScheme( - colorsMap.firstWhere( - (element) { - return element.name == color.name; - }, - ), - ); - }, - ); - }, - ).toList(), - ), - ), - ); - } -} - -class ColorChip extends StatelessWidget { - final String name; - final Color color; - final bool isActive; - final VoidCallback onPressed; - const ColorChip({ - super.key, - required this.name, - required this.color, - required this.isActive, - required this.onPressed, - }); - - @override - Widget build(BuildContext context) { - return Chip( - leading: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(10), - ), - ), - onPressed: onPressed, - style: isActive ? ButtonVariance.primary : ButtonVariance.outline, - child: Text(name), - ); - } -} diff --git a/lib/modules/settings/playback/edit_connect_port_dialog.dart b/lib/modules/settings/playback/edit_connect_port_dialog.dart deleted file mode 100644 index 587f4388..00000000 --- a/lib/modules/settings/playback/edit_connect_port_dialog.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:form_builder_validators/form_builder_validators.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/form/text_form_field.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class SettingsPlaybackEditConnectPortDialog extends HookConsumerWidget { - const SettingsPlaybackEditConnectPortDialog({super.key}); - - @override - Widget build(BuildContext context, ref) { - final connectPort = ref.watch( - userPreferencesProvider.select((s) => s.connectPort), - ); - final controller = useShadcnTextEditingController( - text: connectPort.toString(), - ); - final formKey = useMemoized(() => GlobalKey(), []); - - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Alert( - title: Text(context.l10n.edit_port).h4(), - content: FormBuilder( - key: formKey, - child: Column( - children: [ - const Gap(10), - TextFormBuilderField( - name: "port", - controller: controller, - placeholder: const Text("3000"), - validator: FormBuilderValidators.integer(radix: 10), - keyboardType: TextInputType.number, - inputFormatters: [ - // Allow only signed integers - TextInputFormatter.withFunction( - (oldValue, newValue) { - if (newValue.text.isEmpty) { - return const TextEditingValue(); - } - if (newValue.text.length == 1 && newValue.text == "-") { - return newValue; - } - - final intValue = int.tryParse(newValue.text); - if (intValue == null) { - return oldValue; - } - return newValue; - }, - ), - ], - ), - const Gap(5), - Text(context.l10n.port_helper_msg).small.muted, - const Gap(20), - Row( - children: [ - Expanded( - child: Button.secondary( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(context.l10n.cancel), - ), - ), - const Gap(10), - Expanded( - child: Button.primary( - onPressed: () { - if (!formKey.currentState!.saveAndValidate()) { - return; - } - final port = int.parse(controller.text); - ref - .read(userPreferencesProvider.notifier) - .setConnectPort(port); - Navigator.of(context).pop(); - }, - child: Text(context.l10n.save), - ), - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/modules/settings/playback/edit_instance_url_dialog.dart b/lib/modules/settings/playback/edit_instance_url_dialog.dart deleted file mode 100644 index b2dda411..00000000 --- a/lib/modules/settings/playback/edit_instance_url_dialog.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:form_builder_validators/form_builder_validators.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/form/text_form_field.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; - -class SettingsPlaybackEditInstanceUrlDialog extends HookConsumerWidget { - final String title; - final String? initialValue; - final ValueChanged onSave; - - const SettingsPlaybackEditInstanceUrlDialog({ - super.key, - required this.title, - required this.onSave, - this.initialValue, - }); - - @override - Widget build(BuildContext context, ref) { - final controller = useShadcnTextEditingController( - text: initialValue, - ); - final formKey = useMemoized(() => GlobalKey(), []); - - return Alert( - title: Text(title).h4(), - content: FormBuilder( - key: formKey, - child: Column( - children: [ - const Gap(10), - TextFormBuilderField( - name: "url", - controller: controller, - placeholder: Text(title), - validator: FormBuilderValidators.url(), - ), - const Gap(10), - Row( - children: [ - Expanded( - child: Button.secondary( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(context.l10n.cancel), - ), - ), - const Gap(10), - Expanded( - child: Button.primary( - onPressed: () { - if (!formKey.currentState!.saveAndValidate()) { - return; - } - onSave( - controller.text, - ); - Navigator.of(context).pop(); - }, - child: Text(context.l10n.save), - ), - ), - ], - ) - ], - ), - ), - ); - } -} diff --git a/lib/modules/settings/section_card_with_heading.dart b/lib/modules/settings/section_card_with_heading.dart deleted file mode 100644 index c7bc1f26..00000000 --- a/lib/modules/settings/section_card_with_heading.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flutter/material.dart' show ListTileTheme, ListTileThemeData; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Theme, ThemeData; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; - -class SectionCardWithHeading extends StatelessWidget { - final String heading; - final List children; - const SectionCardWithHeading({ - super.key, - required this.heading, - required this.children, - }); - - @override - Widget build(BuildContext context) { - return ListTileTheme( - data: ListTileThemeData( - shape: RoundedRectangleBorder( - borderRadius: context.theme.borderRadiusLg, - side: BorderSide( - color: context.theme.colorScheme.border, - width: .5, - ), - ), - textColor: context.theme.colorScheme.foreground, - iconColor: context.theme.colorScheme.foreground, - selectedColor: context.theme.colorScheme.accent, - subtitleTextStyle: context.theme.typography.xSmall, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Text( - heading, - style: context.theme.typography.large.copyWith( - color: context.theme.colorScheme.foreground, - ), - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, - ).gap(8.0), - ), - ], - ), - ); - } -} diff --git a/lib/modules/settings/youtube_engine_not_installed_dialog.dart b/lib/modules/settings/youtube_engine_not_installed_dialog.dart deleted file mode 100644 index b993dd1b..00000000 --- a/lib/modules/settings/youtube_engine_not_installed_dialog.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/form/text_form_field.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:yt_dlp_dart/yt_dlp_dart.dart'; - -const engineDownloadUrls = { - YoutubeClientEngine.ytDlp: - "https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#installation", -}; - -class YouTubeEngineNotInstalledDialog extends HookConsumerWidget { - final YoutubeClientEngine engine; - const YouTubeEngineNotInstalledDialog({ - super.key, - required this.engine, - }); - - @override - Widget build(BuildContext context, ref) { - final controller = useShadcnTextEditingController(); - final formKey = useMemoized(() => GlobalKey(), []); - - return AlertDialog( - title: Row( - spacing: 8, - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.error, color: Colors.red), - Text( - context.l10n.youtube_engine_not_installed_title(engine.label), - style: const TextStyle(color: Colors.red), - ), - ], - ), - content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - Text( - context.l10n.youtube_engine_not_installed_message(engine.label), - ), - if (engineDownloadUrls[engine] != null) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text("${context.l10n.download}:"), - Button.link( - child: Text(engineDownloadUrls[engine]!.split("?").first), - onPressed: () async { - launchUrl(Uri.parse(engineDownloadUrls[engine]!)); - }, - ), - ], - ), - Text(context.l10n.youtube_engine_set_path(engine.label)), - const Gap(8), - FormBuilder( - key: formKey, - child: TextFormBuilderField( - name: "path", - controller: controller, - placeholder: Text(switch (context.theme.platform) { - TargetPlatform.macOS => "e.g. /opt/homebrew/bin/yt-dlp", - TargetPlatform.windows => - r"e.g. C:\Program Files\yt-dlp\yt-dlp.exe", - _ => "e.g. /home/user/.local/bin/yt-dlp", - }), - ), - ), - if (kIsMacOS || kIsLinux) - Text(context.l10n.youtube_engine_unix_issue_message), - ], - ), - ), - actions: [ - Button.text( - onPressed: () { - if (!context.mounted) return; - Navigator.of(context).pop(false); - }, - child: Text(context.l10n.cancel), - ), - Button.secondary( - onPressed: () async { - if (controller.text.isNotEmpty) { - if (!await File(controller.text).exists() && context.mounted) { - formKey.currentState?.fields["path"] - ?.invalidate(context.l10n.file_not_found); - return; - } - await KVStoreService.setYoutubeEnginePath( - engine, - controller.text, - ); - if (engine == YoutubeClientEngine.ytDlp) { - await YtDlp.instance.setBinaryLocation(controller.text); - } - } - if (!context.mounted) return; - Navigator.of(context).pop(true); - }, - child: Text(context.l10n.save), - ), - ], - ); - } -} diff --git a/lib/modules/stats/common/album_item.dart b/lib/modules/stats/common/album_item.dart deleted file mode 100644 index 2ac73b91..00000000 --- a/lib/modules/stats/common/album_item.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/components/links/artist_link.dart'; -import 'package:spotube/components/ui/button_tile.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/modules/album/album_card.dart'; -import 'package:spotube/components/image/universal_image.dart'; - -class StatsAlbumItem extends StatelessWidget { - final SpotubeSimpleAlbumObject album; - final Widget info; - const StatsAlbumItem({super.key, required this.album, required this.info}); - - @override - Widget build(BuildContext context) { - return ButtonTile( - style: ButtonVariance.ghost, - leading: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: UniversalImage( - path: (album.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - width: 40, - height: 40, - ), - ), - title: Text(album.name), - subtitle: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text("${album.albumType.formatted} • "), - Flexible( - child: ArtistLink( - artists: album.artists, - mainAxisAlignment: WrapAlignment.start, - onOverflowArtistClick: () => - context.navigateTo(AlbumRoute(id: album.id, album: album)), - ), - ), - ], - ), - trailing: info, - onPressed: () { - context.navigateTo(AlbumRoute(id: album.id, album: album)); - }, - ); - } -} diff --git a/lib/modules/stats/common/artist_item.dart b/lib/modules/stats/common/artist_item.dart deleted file mode 100644 index 92d3b915..00000000 --- a/lib/modules/stats/common/artist_item.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/ui/button_tile.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -class StatsArtistItem extends StatelessWidget { - final SpotubeSimpleArtistObject artist; - final Widget info; - const StatsArtistItem({ - super.key, - required this.artist, - required this.info, - }); - - @override - Widget build(BuildContext context) { - return ButtonTile( - style: ButtonVariance.ghost, - title: Text(artist.name), - leading: Avatar( - initials: artist.name.substring(0, 1), - provider: UniversalImage.imageProvider( - (artist.images).asUrlString( - placeholder: ImagePlaceholder.artist, - ), - ), - ), - trailing: info, - onPressed: () { - context.navigateTo(ArtistRoute(artistId: artist.id)); - }, - ); - } -} diff --git a/lib/modules/stats/common/playlist_item.dart b/lib/modules/stats/common/playlist_item.dart deleted file mode 100644 index 64abe7d5..00000000 --- a/lib/modules/stats/common/playlist_item.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/ui/button_tile.dart'; -import 'package:spotube/extensions/string.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -class StatsPlaylistItem extends StatelessWidget { - final SpotubeSimplePlaylistObject playlist; - final Widget info; - const StatsPlaylistItem( - {super.key, required this.playlist, required this.info}); - - @override - Widget build(BuildContext context) { - return ButtonTile( - style: ButtonVariance.ghost, - leading: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: UniversalImage( - path: (playlist.images).asUrlString( - placeholder: ImagePlaceholder.collection, - ), - width: 40, - height: 40, - ), - ), - title: Text(playlist.name), - subtitle: Text( - playlist.description.unescapeHtml(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: info, - onPressed: () { - context.navigateTo(PlaylistRoute(id: playlist.id, playlist: playlist)); - }, - ); - } -} diff --git a/lib/modules/stats/common/track_item.dart b/lib/modules/stats/common/track_item.dart deleted file mode 100644 index eea3dd4b..00000000 --- a/lib/modules/stats/common/track_item.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/links/artist_link.dart'; -import 'package:spotube/components/ui/button_tile.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -class StatsTrackItem extends StatelessWidget { - final SpotubeTrackObject track; - final Widget info; - const StatsTrackItem({ - super.key, - required this.track, - required this.info, - }); - - @override - Widget build(BuildContext context) { - return ButtonTile( - style: ButtonVariance.ghost, - leading: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: UniversalImage( - path: (track.album.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - width: 40, - height: 40, - ), - ), - title: Text(track.name), - subtitle: ArtistLink( - artists: track.artists, - mainAxisAlignment: WrapAlignment.start, - onOverflowArtistClick: () { - context.navigateTo(TrackRoute(trackId: track.id)); - }, - ), - trailing: info, - onPressed: () { - context.navigateTo(TrackRoute(trackId: track.id)); - }, - ); - } -} diff --git a/lib/modules/stats/summary/summary.dart b/lib/modules/stats/summary/summary.dart deleted file mode 100644 index 30e68b1f..00000000 --- a/lib/modules/stats/summary/summary.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/modules/stats/summary/summary_card.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/history/summary.dart'; - -class StatsPageSummarySection extends HookConsumerWidget { - const StatsPageSummarySection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final summary = ref.watch(playbackHistorySummaryProvider); - final summaryData = summary.asData?.value ?? FakeData.historySummary; - - return Skeletonizer.sliver( - enabled: summary.isLoading, - child: SliverPadding( - padding: const EdgeInsets.all(10), - sliver: SliverLayoutBuilder(builder: (context, constrains) { - return SliverGrid( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: constrains.isXs - ? 2 - : constrains.smAndDown - ? 3 - : constrains.mdAndDown - ? 4 - : constrains.lgAndDown - ? 5 - : 6, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childAspectRatio: constrains.isXs ? 1.3 : 1.5, - ), - delegate: SliverChildListDelegate([ - SummaryCard( - title: summaryData.duration.inMinutes.toDouble(), - unit: context.l10n.summary_minutes, - description: context.l10n.summary_listened_to_music, - color: Colors.indigo, - onTap: () { - context.navigateTo(const StatsMinutesRoute()); - }, - ), - SummaryCard( - title: summaryData.tracks.toDouble(), - unit: context.l10n.summary_songs, - description: context.l10n.summary_streamed_overall, - color: Colors.blue, - onTap: () { - context.navigateTo(const StatsStreamsRoute()); - }, - ), - SummaryCard.unformatted( - title: usdFormatter.format(summaryData.fees.toDouble()), - unit: "", - description: context.l10n.summary_owed_to_artists, - color: Colors.green, - onTap: () { - context.navigateTo(const StatsStreamFeesRoute()); - }, - ), - SummaryCard( - title: summaryData.artists.toDouble(), - unit: context.l10n.summary_artists, - description: context.l10n.summary_music_reached_you, - color: Colors.yellow, - onTap: () { - context.navigateTo(const StatsArtistsRoute()); - }, - ), - SummaryCard( - title: summaryData.albums.toDouble(), - unit: context.l10n.summary_full_albums, - description: context.l10n.summary_got_your_love, - color: Colors.pink, - onTap: () { - context.navigateTo(const StatsAlbumsRoute()); - }, - ), - SummaryCard( - title: summaryData.playlists.toDouble(), - unit: context.l10n.summary_playlists, - description: context.l10n.summary_were_on_repeat, - color: Colors.teal, - onTap: () { - context.navigateTo(const StatsPlaylistsRoute()); - }, - ), - ]), - ); - }), - ), - ); - } -} diff --git a/lib/modules/stats/summary/summary_card.dart b/lib/modules/stats/summary/summary_card.dart deleted file mode 100644 index e78dd080..00000000 --- a/lib/modules/stats/summary/summary_card.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/foundation.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/formatters.dart'; - -class SummaryCard extends StatelessWidget { - final String title; - final String unit; - final String description; - final VoidCallback? onTap; - - final ColorShades color; - - SummaryCard({ - super.key, - required double title, - required this.unit, - required this.description, - required this.color, - this.onTap, - }) : title = compactNumberFormatter.format(title); - - const SummaryCard.unformatted({ - super.key, - required this.title, - required this.unit, - required this.description, - required this.color, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final ThemeData(:typography, :brightness) = Theme.of(context); - - final descriptionNewLines = description.split("").where((s) => s == "\n"); - - return Card( - fillColor: brightness == Brightness.dark ? color.shade100 : color.shade50, - filled: true, - borderColor: color, - padding: EdgeInsets.zero, - borderRadius: context.theme.borderRadiusLg, - child: Button.ghost( - onPressed: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 15), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AutoSizeText.rich( - TextSpan( - children: [ - TextSpan( - text: title, - style: typography.h2.copyWith( - color: color.shade900, - ), - ), - TextSpan( - text: " $unit", - style: typography.semiBold.copyWith( - color: color.shade900, - ), - ), - ], - ), - maxLines: 1, - ), - const Gap(5), - AutoSizeText( - description, - maxLines: description.contains("\n") - ? descriptionNewLines.length + 1 - : 1, - minFontSize: 9, - style: typography.small.copyWith( - color: color.shade900, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/modules/stats/top/albums.dart b/lib/modules/stats/top/albums.dart deleted file mode 100644 index e2a9042a..00000000 --- a/lib/modules/stats/top/albums.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter_undraw/flutter_undraw.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/modules/stats/common/album_item.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/provider/history/top/albums.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; - -class TopAlbums extends HookConsumerWidget { - const TopAlbums({super.key}); - - @override - Widget build(BuildContext context, ref) { - final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final topAlbums = ref.watch(historyTopAlbumsProvider(historyDuration)); - final topAlbumsNotifier = - ref.watch(historyTopAlbumsProvider(historyDuration).notifier); - - final albumsData = topAlbums.asData?.value.items ?? []; - - return Skeletonizer.sliver( - enabled: topAlbums.isLoading && !topAlbums.isLoadingNextPage, - child: SliverInfiniteList( - onFetchData: () async { - await topAlbumsNotifier.fetchMore(); - }, - hasError: topAlbums.hasError, - isLoading: topAlbums.isLoading && !topAlbums.isLoadingNextPage, - hasReachedMax: topAlbums.asData?.value.hasMore ?? true, - itemCount: albumsData.length, - emptyBuilder: (context) => Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Gap(50), - Undraw( - illustration: UndrawIllustration.happyMusic, - color: context.theme.colorScheme.primary, - height: 200 * context.theme.scaling, - ), - Text( - context.l10n.no_tracks_listened_yet, - textAlign: TextAlign.center, - ).muted().small(), - ], - ), - ), - itemBuilder: (context, index) { - final album = albumsData[index]; - return StatsAlbumItem( - album: album.album, - info: Text( - context.l10n - .count_plays(compactNumberFormatter.format(album.count)), - ), - ); - }, - ), - ); - } -} diff --git a/lib/modules/stats/top/artists.dart b/lib/modules/stats/top/artists.dart deleted file mode 100644 index 5a8dc441..00000000 --- a/lib/modules/stats/top/artists.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_undraw/flutter_undraw.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/modules/stats/common/artist_item.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/provider/history/top/tracks.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; - -class TopArtists extends HookConsumerWidget { - const TopArtists({super.key}); - - @override - Widget build(BuildContext context, ref) { - final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final topTracks = ref.watch( - historyTopTracksProvider(historyDuration), - ); - final topTracksNotifier = - ref.watch(historyTopTracksProvider(historyDuration).notifier); - - final artistsData = - useMemoized(() => topTracksNotifier.artists, [topTracks.asData?.value]); - - return Skeletonizer.sliver( - enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, - child: SliverInfiniteList( - onFetchData: () async { - await topTracksNotifier.fetchMore(); - }, - hasError: topTracks.hasError, - isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, - hasReachedMax: topTracks.asData?.value.hasMore ?? true, - itemCount: artistsData.length, - emptyBuilder: (context) => Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Gap(50), - Undraw( - illustration: UndrawIllustration.happyMusic, - color: context.theme.colorScheme.primary, - height: 200 * context.theme.scaling, - ), - Text( - context.l10n.no_tracks_listened_yet, - textAlign: TextAlign.center, - ).muted().small(), - ], - ), - ), - itemBuilder: (context, index) { - final artist = artistsData[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text( - context.l10n - .count_plays(compactNumberFormatter.format(artist.count)), - ), - ); - }, - ), - ); - } -} diff --git a/lib/modules/stats/top/top.dart b/lib/modules/stats/top/top.dart deleted file mode 100644 index 38f04ccb..00000000 --- a/lib/modules/stats/top/top.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/modules/stats/top/albums.dart'; -import 'package:spotube/modules/stats/top/artists.dart'; -import 'package:spotube/modules/stats/top/tracks.dart'; -import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/history/top.dart'; - -class StatsPageTopSection extends HookConsumerWidget { - const StatsPageTopSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final selectedIndex = useState(0); - final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final historyDurationNotifier = - ref.watch(playbackHistoryTopDurationProvider.notifier); - - final translations = { - HistoryDuration.days7: context.l10n.this_week, - HistoryDuration.days30: context.l10n.this_month, - HistoryDuration.months6: context.l10n.last_6_months, - HistoryDuration.year: context.l10n.this_year, - HistoryDuration.years2: context.l10n.last_2_years, - HistoryDuration.allTime: context.l10n.all_time, - }; - - final dropdown = Select( - popupConstraints: const BoxConstraints(maxWidth: 150), - popupWidthConstraint: PopoverConstraint.flexible, - padding: const EdgeInsets.all(4), - borderRadius: BorderRadius.circular(4), - value: historyDuration, - onChanged: (value) { - if (value == null) return; - historyDurationNotifier.update((_) => value); - }, - itemBuilder: (context, item) => Text(translations[item]!), - popup: (context) { - return SelectPopup( - items: SelectItemBuilder( - childCount: HistoryDuration.values.length, - builder: (context, index) { - final item = HistoryDuration.values[index]; - return SelectItemButton( - value: item, - child: Text(translations[item]!), - ); - }, - ), - ); - }); - - return SliverLayoutBuilder(builder: (context, constraints) { - return SliverMainAxisGroup( - slivers: [ - SliverAppBar( - floating: true, - elevation: 0, - backgroundColor: context.theme.colorScheme.background, - automaticallyImplyLeading: false, - flexibleSpace: Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - TabList( - index: selectedIndex.value, - onChanged: (value) { - selectedIndex.value = value; - }, - children: [ - TabItem( - child: Text(context.l10n.top_tracks), - ), - TabItem( - child: Text(context.l10n.top_artists), - ), - TabItem( - child: Text(context.l10n.top_albums), - ), - ], - ), - if (constraints.mdAndUp) ...[ - const Spacer(), - dropdown, - ] - ], - ), - ), - ), - if (constraints.smAndDown) - SliverToBoxAdapter( - child: Align( - alignment: Alignment.centerRight, - child: dropdown, - ), - ), - switch (selectedIndex.value) { - 1 => const TopArtists(), - 2 => const TopAlbums(), - _ => const TopTracks(), - }, - ], - ); - }); - } -} diff --git a/lib/modules/stats/top/tracks.dart b/lib/modules/stats/top/tracks.dart deleted file mode 100644 index 08c742c4..00000000 --- a/lib/modules/stats/top/tracks.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter_undraw/flutter_undraw.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/modules/stats/common/track_item.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/provider/history/top/tracks.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; - -class TopTracks extends HookConsumerWidget { - const TopTracks({super.key}); - - @override - Widget build(BuildContext context, ref) { - final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final topTracks = ref.watch( - historyTopTracksProvider(historyDuration), - ); - final topTracksNotifier = - ref.watch(historyTopTracksProvider(historyDuration).notifier); - - final tracksData = topTracks.asData?.value.items ?? []; - - return Skeletonizer.sliver( - enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, - child: SliverInfiniteList( - onFetchData: () async { - await topTracksNotifier.fetchMore(); - }, - hasError: topTracks.hasError, - isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, - hasReachedMax: topTracks.asData?.value.hasMore ?? true, - itemCount: tracksData.length, - emptyBuilder: (context) => Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Gap(50), - Undraw( - illustration: UndrawIllustration.happyMusic, - color: context.theme.colorScheme.primary, - height: 200 * context.theme.scaling, - ), - Text( - context.l10n.no_tracks_listened_yet, - textAlign: TextAlign.center, - ).muted().small(), - ], - ), - ), - itemBuilder: (context, index) { - final track = tracksData[index]; - return StatsTrackItem( - track: track.track, - info: Text( - context.l10n - .count_plays(compactNumberFormatter.format(track.count)), - ), - ); - }, - ), - ); - } -} diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart deleted file mode 100644 index 049d8023..00000000 --- a/lib/pages/album/album.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:flutter/material.dart' as material; -import 'package:auto_route/auto_route.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/track_presentation/presentation_props.dart'; -import 'package:spotube/components/track_presentation/track_presentation.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/library/albums.dart'; -import 'package:spotube/provider/metadata_plugin/tracks/album.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; - -@RoutePage() -class AlbumPage extends HookConsumerWidget { - static const name = "album"; - - final SpotubeSimpleAlbumObject album; - final String id; - const AlbumPage({ - super.key, - @PathParam("id") required this.id, - required this.album, - }); - - @override - Widget build(BuildContext context, ref) { - final tracks = ref.watch(metadataPluginAlbumTracksProvider(album.id)); - final tracksNotifier = - ref.watch(metadataPluginAlbumTracksProvider(album.id).notifier); - final favoriteAlbumsNotifier = - ref.watch(metadataPluginSavedAlbumsProvider.notifier); - final isSavedAlbum = - ref.watch(metadataPluginIsSavedAlbumProvider(album.id)); - - return material.RefreshIndicator.adaptive( - onRefresh: () async { - ref.invalidate(metadataPluginAlbumTracksProvider(album.id)); - ref.invalidate(metadataPluginIsSavedAlbumProvider(album.id)); - ref.invalidate(metadataPluginSavedAlbumsProvider); - }, - child: TrackPresentation( - options: TrackPresentationOptions( - collection: album, - image: album.images.asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - title: album.name, - description: - "${context.l10n.released} • ${album.releaseDate} • ${album.artists.first.name}", - tracks: tracks.asData?.value.items ?? [], - error: tracks.error, - pagination: PaginationProps( - hasNextPage: tracks.asData?.value.hasMore ?? false, - isLoading: tracks.isLoading || tracks.isLoadingNextPage, - onFetchMore: () async { - await tracksNotifier.fetchMore(); - }, - onFetchAll: () async { - return tracksNotifier.fetchAll(); - }, - onRefresh: () async { - ref.invalidate(metadataPluginAlbumTracksProvider(album.id)); - }, - ), - routePath: "/album/${album.id}", - shareUrl: album.externalUri, - isLiked: isSavedAlbum.asData?.value ?? false, - owner: album.artists.first.name, - onHeart: isSavedAlbum.asData?.value == null - ? null - : () async { - if (isSavedAlbum.asData!.value) { - await favoriteAlbumsNotifier.removeFavorite([album]); - } else { - await favoriteAlbumsNotifier.addFavorite([album]); - } - return null; - }, - ), - ), - ); - } -} diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart deleted file mode 100644 index 64bed283..00000000 --- a/lib/pages/artist/artist.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:flutter/material.dart' as material; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/components/button/back_button.dart'; - -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/modules/artist/artist_album_list.dart'; - -import 'package:spotube/pages/artist/section/footer.dart'; -import 'package:spotube/pages/artist/section/header.dart'; -import 'package:spotube/pages/artist/section/related_artists.dart'; -import 'package:spotube/pages/artist/section/top_tracks.dart'; -import 'package:spotube/provider/metadata_plugin/artist/albums.dart'; -import 'package:spotube/provider/metadata_plugin/artist/artist.dart'; -import 'package:auto_route/auto_route.dart'; -import 'package:spotube/provider/metadata_plugin/artist/related.dart'; -import 'package:spotube/provider/metadata_plugin/artist/top_tracks.dart'; -import 'package:spotube/provider/metadata_plugin/artist/wikipedia.dart'; -import 'package:spotube/provider/metadata_plugin/library/artists.dart'; - -@RoutePage() -class ArtistPage extends HookConsumerWidget { - static const name = "artist"; - - final String artistId; - const ArtistPage( - @PathParam("id") this.artistId, { - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final scrollController = useScrollController(); - - final artistQuery = ref.watch(metadataPluginArtistProvider(artistId)); - - return SafeArea( - bottom: false, - child: Scaffold( - headers: const [ - TitleBar( - leading: [BackButton()], - backgroundColor: Colors.transparent, - ) - ], - floatingHeader: true, - child: material.RefreshIndicator.adaptive( - onRefresh: () async { - ref.invalidate(metadataPluginArtistProvider(artistId)); - ref.invalidate( - metadataPluginArtistRelatedArtistsProvider(artistId), - ); - ref.invalidate(metadataPluginArtistAlbumsProvider(artistId)); - ref.invalidate(metadataPluginIsSavedArtistProvider(artistId)); - ref.invalidate(metadataPluginArtistTopTracksProvider(artistId)); - if (artistQuery.hasValue) { - ref.invalidate( - artistWikipediaSummaryProvider(artistQuery.asData!.value), - ); - } - }, - child: Builder(builder: (context) { - if (artistQuery.hasError && artistQuery.asData?.value == null) { - return Center(child: Text(artistQuery.error.toString())); - } - return Skeletonizer( - enabled: artistQuery.isLoading, - child: CustomScrollView( - controller: scrollController, - slivers: [ - const SliverGap(material.kToolbarHeight), - SliverToBoxAdapter( - child: SafeArea( - bottom: false, - child: ArtistPageHeader(artistId: artistId), - ), - ), - const SliverGap(20), - ArtistPageTopTracks(artistId: artistId), - const SliverGap(20), - SliverToBoxAdapter(child: ArtistAlbumList(artistId)), - SliverPadding( - padding: const EdgeInsets.all(8.0), - sliver: SliverToBoxAdapter( - child: Text( - context.l10n.fans_also_like, - style: context.theme.typography.h4, - ), - ), - ), - ArtistPageRelatedArtists(artistId: artistId), - const SliverGap(20), - if (artistQuery.asData?.value != null) - SliverToBoxAdapter( - child: - ArtistPageFooter(artist: artistQuery.asData!.value), - ), - const SliverSafeArea(sliver: SliverGap(10)), - ], - ), - ); - }), - ), - ), - ); - } -} diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart deleted file mode 100644 index 938fb6fc..00000000 --- a/lib/pages/artist/section/footer.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/artist/wikipedia.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -class ArtistPageFooter extends ConsumerWidget { - final SpotubeFullArtistObject artist; - const ArtistPageFooter({super.key, required this.artist}); - - @override - Widget build(BuildContext context, ref) { - final ThemeData(:typography) = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - - final artistImage = artist.images.asUrlString( - placeholder: ImagePlaceholder.artist, - ); - final summary = ref.watch(artistWikipediaSummaryProvider(artist)); - if (summary.asData?.value == null) return const SizedBox.shrink(); - - return Container( - margin: const EdgeInsets.all(8), - padding: mediaQuery.smAndDown - ? const EdgeInsets.all(20) - : const EdgeInsets.all(30), - constraints: const BoxConstraints(minHeight: 300), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - image: DecorationImage( - colorFilter: ColorFilter.mode( - Colors.black.withValues(alpha: 0.5), - BlendMode.darken, - ), - image: UniversalImage.imageProvider( - summary.asData?.value!.thumbnail?.source_ ?? artistImage, - height: summary.asData?.value!.thumbnail?.height.toDouble(), - width: summary.asData?.value!.thumbnail?.width.toDouble(), - ), - fit: BoxFit.cover, - alignment: Alignment.center, - ), - ), - alignment: Alignment.center, - child: RichText( - text: TextSpan( - style: typography.semiBold.copyWith( - color: Colors.white, - ), - children: [ - // icon - const WidgetSpan( - child: Icon( - SpotubeIcons.wikipedia, - color: Colors.white, - size: 30, - ), - ), - TextSpan( - text: " Wikipedia", - style: typography.large.copyWith( - color: Colors.white, - ), - ), - const TextSpan(text: '\n\n'), - TextSpan( - text: summary.asData?.value!.extract, - ), - TextSpan( - text: '\n...read more at wikipedia', - style: typography.semiBold.copyWith( - color: Colors.sky[300], - decoration: TextDecoration.underline, - decorationColor: Colors.sky[300], - ), - recognizer: TapGestureRecognizer() - ..onTap = () async { - await launchUrlString( - "http://en.wikipedia.org/wiki?curid=${summary.asData?.value?.pageid}", - ); - }, - ), - ], - ), - ), - ); - } -} diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart deleted file mode 100644 index b8e7e5dc..00000000 --- a/lib/pages/artist/section/header.dart +++ /dev/null @@ -1,229 +0,0 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/services.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Consumer; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/metadata_plugin/artist/artist.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/library/artists.dart'; -import 'package:spotube/utils/primitive_utils.dart'; - -class ArtistPageHeader extends HookConsumerWidget { - final String artistId; - const ArtistPageHeader({super.key, required this.artistId}); - - @override - Widget build(BuildContext context, ref) { - final artistQuery = ref.watch(metadataPluginArtistProvider(artistId)); - final artist = artistQuery.asData?.value ?? FakeData.artist; - - final theme = Theme.of(context); - final ThemeData(:typography) = theme; - - final authenticated = ref.watch(metadataPluginAuthenticatedProvider); - ref.watch(blacklistProvider); - final blacklistNotifier = ref.watch(blacklistProvider.notifier); - final isBlackListed = blacklistNotifier.containsArtist(artist.id); - - final image = artist.images.asUrlString( - placeholder: ImagePlaceholder.artist, - ); - - final actions = Skeleton.keep( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (authenticated.asData?.value == true) - Consumer( - builder: (context, ref, _) { - final isFollowingQuery = ref.watch( - metadataPluginIsSavedArtistProvider(artist.id), - ); - final followingArtistNotifier = - ref.watch(metadataPluginSavedArtistsProvider.notifier); - - return switch (isFollowingQuery) { - AsyncData(value: final following) => Builder( - builder: (context) { - if (following) { - return Button.outline( - onPressed: () async { - await followingArtistNotifier - .removeFavorite([artist]); - }, - child: Text(context.l10n.following), - ); - } - - return Button.primary( - onPressed: () async { - await followingArtistNotifier.addFavorite([artist]); - }, - child: Text(context.l10n.follow), - ); - }, - ), - AsyncError() => const SizedBox(), - _ => const SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(), - ) - }; - }, - ), - const SizedBox(width: 5), - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.add_artist_to_blacklist), - ).call, - child: IconButton( - icon: Icon( - SpotubeIcons.userRemove, - color: !isBlackListed ? Colors.red[400] : null, - ), - variance: isBlackListed - ? ButtonVariance.destructive - : ButtonVariance.ghost, - onPressed: () async { - if (isBlackListed) { - await ref.read(blacklistProvider.notifier).remove(artist.id); - } else { - await ref.read(blacklistProvider.notifier).add( - BlacklistTableCompanion.insert( - name: artist.name, - elementId: artist.id, - elementType: BlacklistedType.artist, - ), - ); - } - }, - ), - ), - IconButton.ghost( - icon: const Icon(SpotubeIcons.share), - onPressed: () async { - await Clipboard.setData( - ClipboardData( - text: artist.externalUri, - ), - ); - - if (!context.mounted) return; - - showToast( - context: context, - location: ToastLocation.topRight, - dismissible: true, - builder: (context, overlay) { - return SurfaceCard( - child: Text( - context.l10n.artist_url_copied, - textAlign: TextAlign.center, - ), - ); - }, - ); - }, - ) - ], - ), - ); - - return LayoutBuilder( - builder: (context, constrains) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: theme.borderRadiusXl, - child: UniversalImage( - path: image, - width: constrains.mdAndUp ? 200 : 120, - height: constrains.mdAndUp ? 200 : 120, - fit: BoxFit.cover, - ), - ), - const Gap(20), - Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - OutlineBadge( - child: - Text(context.l10n.artist).small().muted(), - ), - if (isBlackListed) ...[ - const Gap(5), - DestructiveBadge( - child: Text(context.l10n.blacklisted).small(), - ), - ] - ], - ), - const Gap(10), - Flexible( - child: AutoSizeText( - artist.name, - style: constrains.smAndDown - ? typography.h4 - : typography.h3, - maxLines: 2, - overflow: TextOverflow.ellipsis, - minFontSize: 14, - ), - ), - const Gap(5), - Flexible( - child: AutoSizeText( - context.l10n.followers( - artist.followers == null - ? double.infinity - : PrimitiveUtils.toReadableNumber( - artist.followers!.toDouble(), - ), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - minFontSize: 12, - ).muted(), - ), - if (constrains.mdAndUp) ...[ - const Gap(20), - actions, - ] - ], - ), - ), - ], - ), - if (constrains.smAndDown) ...[ - const Gap(20), - actions, - ] - ], - ), - ), - ); - }, - ); - } -} diff --git a/lib/pages/artist/section/related_artists.dart b/lib/pages/artist/section/related_artists.dart deleted file mode 100644 index ec17e240..00000000 --- a/lib/pages/artist/section/related_artists.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/modules/artist/artist_card.dart'; -import 'package:spotube/provider/metadata_plugin/artist/related.dart'; - -class ArtistPageRelatedArtists extends ConsumerWidget { - final String artistId; - const ArtistPageRelatedArtists({ - super.key, - required this.artistId, - }); - - @override - Widget build(BuildContext context, ref) { - final relatedArtists = - ref.watch(metadataPluginArtistRelatedArtistsProvider(artistId)); - - return switch (relatedArtists) { - AsyncData(value: final artists) => SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - sliver: SliverGrid.builder( - itemCount: artists.items.length, - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: 250, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childAspectRatio: 0.8, - ), - itemBuilder: (context, index) { - final artist = artists.items.elementAt(index); - return SizedBox( - width: 180, - child: ArtistCard(artist), - ); - }, - ), - ), - AsyncError(:final error) => SliverToBoxAdapter( - child: Center( - child: Text(error.toString()), - ), - ), - _ => const SliverToBoxAdapter( - child: Center(child: CircularProgressIndicator()), - ), - }; - } -} diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart deleted file mode 100644 index 30745a01..00000000 --- a/lib/pages/artist/section/top_tracks.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/track_tile/track_tile.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/metadata_plugin/artist/top_tracks.dart'; - -class ArtistPageTopTracks extends HookConsumerWidget { - final String artistId; - const ArtistPageTopTracks({super.key, required this.artistId}); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final isLoading = useState(false); - - final playlist = ref.watch(audioPlayerProvider); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final topTracksQuery = - ref.watch(metadataPluginArtistTopTracksProvider(artistId)); - - final isPlaylistPlaying = playlist.containsTracks( - topTracksQuery.asData?.value.items ?? [], - ); - - if (topTracksQuery.hasError) { - return SliverToBoxAdapter( - child: Center( - child: Text(topTracksQuery.error.toString()), - ), - ); - } - - final topTracks = topTracksQuery.asData?.value.items ?? - List.generate(10, (index) => FakeData.track); - - void playPlaylist( - List tracks, { - SpotubeTrackObject? currentTrack, - }) async { - isLoading.value = true; - - currentTrack ??= tracks.first; - try { - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - - if (isRemoteDevice == null) return; - - if (isRemoteDevice) { - final remotePlayback = ref.read(connectProvider.notifier); - final remotePlaylist = ref.read(queueProvider); - - final isPlaylistPlaying = remotePlaylist.containsTracks(tracks); - - if (!isPlaylistPlaying) { - await remotePlayback.load( - WebSocketLoadEventData.playlist( - tracks: tracks, - collection: null, - initialIndex: - tracks.indexWhere((s) => s.id == currentTrack?.id), - ), - ); - } else if (isPlaylistPlaying && - currentTrack.id != remotePlaylist.activeTrack?.id) { - final index = playlist.tracks - .toList() - .indexWhere((s) => s.id == currentTrack!.id); - await remotePlayback.jumpTo(index); - } - } else { - if (!isPlaylistPlaying) { - playlistNotifier.load( - tracks, - initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != playlist.activeTrack?.id) { - await playlistNotifier.jumpToTrack(currentTrack); - } - } - } finally { - isLoading.value = false; - } - } - - return SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter( - child: Row( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.l10n.top_tracks, - style: theme.typography.h4, - ), - ), - if (!isPlaylistPlaying) - IconButton.outline( - icon: const Icon( - SpotubeIcons.queueAdd, - ), - onPressed: () { - playlistNotifier.addTracks(topTracks.toList()); - showToast( - context: context, - location: ToastLocation.topRight, - builder: (context, overlay) { - return SurfaceCard( - child: Text( - context.l10n.added_to_queue( - topTracks.length, - ), - ), - ); - }, - ); - }, - ), - const SizedBox(width: 5), - IconButton.primary( - shape: ButtonShape.circle, - enabled: !isPlaylistPlaying && !isLoading.value, - icon: isLoading.value - ? CircularProgressIndicator( - size: 20 * context.theme.scaling, - color: theme.colorScheme.primaryForeground, - ) - : Skeleton.keep( - child: Icon( - isPlaylistPlaying - ? SpotubeIcons.pause - : SpotubeIcons.play, - ), - ), - onPressed: () => playPlaylist(topTracks.toList()), - ) - ], - ), - ), - const SliverGap(10), - SliverList.builder( - itemCount: topTracks.length, - itemBuilder: (context, index) { - final track = topTracks.elementAt(index); - return TrackTile( - index: index, - playlist: playlist, - track: track, - onTap: () async { - playPlaylist( - topTracks.toList(), - currentTrack: track, - ); - }, - ); - }, - ), - ], - ); - } -} diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart deleted file mode 100644 index bb8bbfae..00000000 --- a/lib/pages/connect/connect.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/ui/button_tile.dart'; -import 'package:spotube/modules/connect/local_devices.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/connect/clients.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class ConnectPage extends HookConsumerWidget { - static const name = "connect"; - - const ConnectPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final ThemeData(:colorScheme, :typography) = Theme.of(context); - - final connectClients = ref.watch(connectClientsProvider); - final connectClientsNotifier = ref.read(connectClientsProvider.notifier); - final discoveredDevices = connectClients.asData?.value.services; - - return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - TitleBar(title: Text(context.l10n.devices)), - ], - child: Padding( - padding: const EdgeInsets.all(10.0), - child: CustomScrollView( - slivers: [ - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - sliver: SliverToBoxAdapter( - child: Text( - context.l10n.remote, - style: typography.bold, - ), - ), - ), - const SliverGap(10), - SliverList.separated( - itemCount: discoveredDevices?.length ?? 0, - separatorBuilder: (context, index) => const Gap(10), - itemBuilder: (context, index) { - final device = discoveredDevices![index]; - final selected = - connectClients.asData?.value.resolvedService?.name == - device.name; - return ButtonTile( - selected: selected, - leading: const Icon(SpotubeIcons.monitor), - title: Text(device.name), - subtitle: selected - ? Text( - "${connectClients.asData?.value.resolvedService?.host}" - ":${connectClients.asData?.value.resolvedService?.port}", - ) - : null, - trailing: selected - ? IconButton.outline( - icon: const Icon(SpotubeIcons.power), - size: ButtonSize.small, - onPressed: () => - connectClientsNotifier.clearResolvedService(), - ) - : null, - onPressed: () { - if (selected) { - context.navigateTo(const ConnectControlRoute()); - } else { - connectClientsNotifier.resolveService(device); - } - }, - ); - }, - ), - const ConnectPageLocalDevices(), - ], - ), - ), - ), - ); - } -} diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart deleted file mode 100644 index 164e5d43..00000000 --- a/lib/pages/connect/control/control.dart +++ /dev/null @@ -1,390 +0,0 @@ -import 'dart:convert'; - -import 'package:auto_route/auto_route.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/modules/player/player_queue.dart'; -import 'package:spotube/modules/player/volume_slider.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/links/anchor_button.dart'; -import 'package:spotube/components/links/artist_link.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/duration.dart'; -import 'package:spotube/provider/connect/clients.dart'; -import 'package:spotube/provider/connect/connect.dart'; -import 'package:media_kit/media_kit.dart' hide Track; - -class RemotePlayerQueue extends ConsumerWidget { - const RemotePlayerQueue({super.key}); - - @override - Widget build(BuildContext context, ref) { - final connectNotifier = ref.watch(connectProvider.notifier); - final playlist = ref.watch(queueProvider); - return PlayerQueue( - playlist: playlist, - floating: true, - onJump: (track) async { - final index = playlist.tracks.toList().indexOf(track); - connectNotifier.jumpTo(index); - }, - onRemove: (track) async { - await connectNotifier.removeTrack(track); - }, - onStop: () async => connectNotifier.stop(), - onReorder: (oldIndex, newIndex) async { - await connectNotifier.reorder( - (oldIndex: oldIndex, newIndex: newIndex), - ); - }, - ); - } -} - -@RoutePage() -class ConnectControlPage extends HookConsumerWidget { - static const name = "connect_control"; - - const ConnectControlPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final ThemeData(:typography, :colorScheme) = Theme.of(context); - - final resolvedService = - ref.watch(connectClientsProvider).asData?.value.resolvedService; - final connect = ref.watch(connectProvider); - final connectNotifier = ref.read(connectProvider.notifier); - final playlist = ref.watch(queueProvider); - final playing = ref.watch(playingProvider); - final shuffled = ref.watch(shuffleProvider); - final loopMode = ref.watch(loopModeProvider); - - ref.listen(connectClientsProvider, (prev, next) { - if (next.asData?.value.resolvedService == null) { - context.back(); - } - }); - - useEffect(() { - if (connect.asData?.value == null) return null; - - final subscription = connect.asData?.value?.stream.listen((message) { - final event = WebSocketEvent.fromJson( - jsonDecode(message), - (data) => data, - ); - event.onError((event) { - if (event.data != "Connection denied") return; - if (!context.mounted) return; - context.back(); - }); - }); - - return () { - subscription?.cancel(); - }; - }, [connect.asData?.value]); - - return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - TitleBar( - title: Text(resolvedService?.name ?? ""), - ) - ], - child: LayoutBuilder(builder: (context, constrains) { - return Row( - children: [ - Expanded( - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Container( - alignment: Alignment.center, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 10, - ).copyWith(top: 0), - constraints: - const BoxConstraints(maxHeight: 350, maxWidth: 350), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: UniversalImage( - path: (playlist.activeTrack?.album.images) - .asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - fit: BoxFit.cover, - ), - ), - ), - ), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20), - sliver: SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter( - child: AnchorButton( - playlist.activeTrack?.name ?? "", - style: typography.h4, - onTap: () { - if (playlist.activeTrack == null) return; - context.navigateTo( - TrackRoute(trackId: playlist.activeTrack!.id), - ); - }, - ), - ), - SliverToBoxAdapter( - child: ArtistLink( - artists: playlist.activeTrack?.artists ?? [], - textStyle: typography.normal, - mainAxisAlignment: WrapAlignment.start, - onOverflowArtistClick: () => context.navigateTo( - TrackRoute(trackId: playlist.activeTrack!.id), - ), - ), - ), - ], - ), - ), - const SliverGap(30), - SliverToBoxAdapter( - child: Consumer( - builder: (context, ref, _) { - final position = ref.watch(positionProvider); - final duration = ref.watch(durationProvider); - - final progress = duration.inSeconds == 0 - ? 0 - : position.inSeconds / duration.inSeconds; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Column( - children: [ - Slider( - value: - SliderValue.single(progress.toDouble()), - onChanged: (value) { - connectNotifier.seek( - Duration( - seconds: - (value.value * duration.inSeconds) - .toInt(), - ), - ); - }, - ), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text(position.toHumanReadableString()), - Text(duration.toHumanReadableString()), - ], - ), - ], - ), - ); - }, - ), - ), - SliverToBoxAdapter( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 20, - children: [ - Tooltip( - tooltip: TooltipContainer( - child: Text( - shuffled - ? context.l10n.unshuffle_playlist - : context.l10n.shuffle_playlist, - ), - ).call, - child: IconButton( - icon: const Icon(SpotubeIcons.shuffle), - variance: shuffled - ? ButtonVariance.secondary - : ButtonVariance.ghost, - onPressed: playlist.activeTrack == null - ? null - : () { - connectNotifier.setShuffle(!shuffled); - }, - ), - ), - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.previous_track), - ).call, - child: IconButton.ghost( - icon: const Icon(SpotubeIcons.skipBack), - onPressed: playlist.activeTrack == null - ? null - : connectNotifier.previous, - ), - ), - Tooltip( - tooltip: TooltipContainer( - child: Text( - playing - ? context.l10n.pause_playback - : context.l10n.resume_playback, - ), - ).call, - child: IconButton.primary( - shape: ButtonShape.circle, - icon: playlist.activeTrack == null - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - onSurface: false), - ) - : Icon( - playing - ? SpotubeIcons.pause - : SpotubeIcons.play, - ), - onPressed: playlist.activeTrack == null - ? null - : () { - if (playing) { - connectNotifier.pause(); - } else { - connectNotifier.resume(); - } - }, - ), - ), - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.next_track)) - .call, - child: IconButton.ghost( - icon: const Icon(SpotubeIcons.skipForward), - onPressed: playlist.activeTrack == null - ? null - : connectNotifier.next, - ), - ), - Tooltip( - tooltip: TooltipContainer( - child: Text( - loopMode == PlaylistMode.single - ? context.l10n.loop_track - : loopMode == PlaylistMode.loop - ? context.l10n.repeat_playlist - : context.l10n.no_loop, - ), - ).call, - child: IconButton( - icon: Icon( - loopMode == PlaylistMode.single - ? SpotubeIcons.repeatOne - : SpotubeIcons.repeat, - ), - variance: loopMode == PlaylistMode.single || - loopMode == PlaylistMode.loop - ? ButtonVariance.secondary - : ButtonVariance.ghost, - onPressed: playlist.activeTrack == null - ? null - : () async { - connectNotifier.setLoopMode( - switch (loopMode) { - PlaylistMode.loop => - PlaylistMode.single, - PlaylistMode.single => - PlaylistMode.none, - PlaylistMode.none => - PlaylistMode.loop, - }, - ); - }, - ), - ) - ], - ), - ), - const SliverGap(30), - if (constrains.mdAndDown) - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20), - sliver: SliverToBoxAdapter( - child: Button.outline( - leading: const Icon(SpotubeIcons.queue), - child: Text(context.l10n.queue), - onPressed: () { - openDrawer( - context: context, - barrierDismissible: true, - draggable: true, - barrierColor: Colors.black.withAlpha(100), - borderRadius: BorderRadius.circular(10), - transformBackdrop: false, - position: OverlayPosition.bottom, - surfaceBlur: context.theme.surfaceBlur, - surfaceOpacity: 0.7, - expands: true, - builder: (context) { - return ConstrainedBox( - constraints: BoxConstraints( - maxHeight: - MediaQuery.sizeOf(context).height * - 0.8, - ), - child: const RemotePlayerQueue(), - ); - }, - ); - }, - ), - ), - ), - const SliverGap(30), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20), - sliver: SliverToBoxAdapter( - child: Consumer(builder: (context, ref, _) { - final volume = ref.watch(volumeProvider); - return VolumeSlider( - fullWidth: true, - value: volume, - onChanged: (value) { - ref.read(volumeProvider.notifier).state = value; - connectNotifier.setVolume(value); - }, - ); - }), - ), - ), - const SliverSafeArea(sliver: SliverGap(10)), - ], - ), - ), - if (constrains.lgAndUp) ...[ - const VerticalDivider(thickness: 1), - const Expanded( - child: RemotePlayerQueue(), - ), - ] - ], - ); - }), - ), - ); - } -} diff --git a/lib/pages/getting_started/getting_started.dart b/lib/pages/getting_started/getting_started.dart deleted file mode 100644 index 1662624c..00000000 --- a/lib/pages/getting_started/getting_started.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/getting_started/sections/greeting.dart'; -import 'package:spotube/pages/getting_started/sections/playback.dart'; -import 'package:spotube/pages/getting_started/sections/region.dart'; -import 'package:spotube/pages/getting_started/sections/support.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class GettingStartedPage extends HookConsumerWidget { - static const name = "getting_started"; - - const GettingStartedPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final pageController = usePageController(); - - final onNext = useCallback(() { - pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, [pageController]); - - final onPrevious = useCallback(() { - pageController.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, [pageController]); - - return Scaffold( - headers: [ - SafeArea( - child: TitleBar( - backgroundColor: Colors.transparent, - surfaceBlur: 0, - trailing: [ - ListenableBuilder( - listenable: pageController, - builder: (context, _) { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: pageController.hasClients && - (pageController.page == 0 || - pageController.page == 3) - ? const SizedBox() - : Button.secondary( - onPressed: () { - pageController.animateToPage( - 3, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - child: Text(context.l10n.skip_this_nonsense), - ), - ); - }, - ), - ], - ), - ), - ], - floatingHeader: true, - child: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: Assets.images.bengaliPatternsBg.provider(), - fit: BoxFit.cover, - ), - ), - child: PageView( - controller: pageController, - children: [ - GettingStartedPageGreetingSection(onNext: onNext), - GettingStartedPageLanguageRegionSection(onNext: onNext), - GettingStartedPagePlaybackSection( - onNext: onNext, - onPrevious: onPrevious, - ), - const GettingStartedScreenSupportSection(), - ], - ), - ), - ); - } -} diff --git a/lib/pages/getting_started/sections/greeting.dart b/lib/pages/getting_started/sections/greeting.dart deleted file mode 100644 index 68903e07..00000000 --- a/lib/pages/getting_started/sections/greeting.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/modules/getting_started/blur_card.dart'; -import 'package:spotube/utils/platform.dart'; - -class GettingStartedPageGreetingSection extends HookConsumerWidget { - final VoidCallback onNext; - const GettingStartedPageGreetingSection({super.key, required this.onNext}); - - @override - Widget build(BuildContext context, ref) { - return Center( - child: BlurCard( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Assets.branding.spotubeLogoPng.image(height: 200), - const Gap(24), - const Text("Spotube").semiBold().h4(), - const Gap(4), - Text( - kIsMobile - ? context.l10n.freedom_of_music_palm - : context.l10n.freedom_of_music, - textAlign: TextAlign.center, - ).light().large().italic(), - const Gap(84), - Button.primary( - onPressed: onNext, - trailing: const Icon(SpotubeIcons.angleRight), - child: Text(context.l10n.get_started), - ), - ], - ), - ), - ); - } -} diff --git a/lib/pages/getting_started/sections/playback.dart b/lib/pages/getting_started/sections/playback.dart deleted file mode 100644 index 699024b1..00000000 --- a/lib/pages/getting_started/sections/playback.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/ui/button_tile.dart'; -import 'package:spotube/modules/getting_started/blur_card.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class GettingStartedPagePlaybackSection extends HookConsumerWidget { - final VoidCallback onNext; - final VoidCallback onPrevious; - - const GettingStartedPagePlaybackSection({ - super.key, - required this.onNext, - required this.onPrevious, - }); - - @override - Widget build(BuildContext context, ref) { - final preferences = ref.watch(userPreferencesProvider); - final preferencesNotifier = ref.read(userPreferencesProvider.notifier); - - // final audioSourceToDescription = useMemoized( - // () => { - // AudioSource.youtube: "${context.l10n.youtube_source_description}\n" - // "${context.l10n.highest_quality("148kbps mp4, 128kbps opus")}", - // AudioSource.piped: context.l10n.piped_source_description, - // AudioSource.jiosaavn: - // "${context.l10n.jiosaavn_source_description}\n" - // "${context.l10n.highest_quality("320kbps mp4")}", - // AudioSource.invidious: context.l10n.invidious_source_description, - // AudioSource.dabMusic: "${context.l10n.dab_music_source_description}\n" - // "${context.l10n.highest_quality("320kbps mp3, HI-RES 24bit 44.1kHz-96kHz flac")}", - // }, - // []); - - return Center( - child: BlurCard( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - const Icon(SpotubeIcons.album, size: 16), - const Gap(8), - Text(context.l10n.playback).semiBold().large(), - ], - ), - const Gap(16), - // Align( - // alignment: Alignment.centerLeft, - // child: Text(context.l10n.select_audio_source).semiBold().large(), - // ), - // const Gap(16), - // RadioGroup( - // value: preferences.audioSource, - // onChanged: (value) { - // preferencesNotifier.setAudioSource(value); - // }, - // child: Wrap( - // spacing: 6, - // runSpacing: 6, - // children: [ - // for (final source in AudioSource.values) - // Badge( - // isLabelVisible: source == AudioSource.dabMusic, - // label: const Text("NEW"), - // backgroundColor: Colors.lime[300], - // textColor: Colors.black, - // child: RadioCard( - // value: source, - // child: Column( - // mainAxisSize: MainAxisSize.min, - // children: [ - // audioSourceToIconMap[source]!, - // Text(source.label), - // ], - // ), - // ), - // ), - // ], - // ), - // ), - // const Gap(16), - // Text( - // audioSourceToDescription[preferences.audioSource]!, - // ).small().muted(), - const Gap(16), - ButtonTile( - title: Text(context.l10n.endless_playback), - subtitle: Text( - context.l10n.endless_playback_description, - ).small().muted(), - onPressed: () { - preferencesNotifier - .setEndlessPlayback(!preferences.endlessPlayback); - }, - trailing: Switch( - value: preferences.endlessPlayback, - onChanged: (value) { - preferencesNotifier.setEndlessPlayback(value); - }, - ), - ), - const Gap(34), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Button.secondary( - leading: const Icon(SpotubeIcons.angleLeft), - onPressed: onPrevious, - child: Text(context.l10n.previous), - ), - Directionality( - textDirection: TextDirection.rtl, - child: Button.primary( - leading: const Icon(SpotubeIcons.angleRight), - onPressed: onNext, - child: Text(context.l10n.next), - ), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/pages/getting_started/sections/region.dart b/lib/pages/getting_started/sections/region.dart deleted file mode 100644 index 917cc41e..00000000 --- a/lib/pages/getting_started/sections/region.dart +++ /dev/null @@ -1,194 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/language_codes.dart'; -import 'package:spotube/collections/markets.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/getting_started/blur_card.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/l10n/l10n.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class GettingStartedPageLanguageRegionSection extends HookConsumerWidget { - final void Function() onNext; - const GettingStartedPageLanguageRegionSection( - {super.key, required this.onNext}); - - bool filterMarkets(dynamic item, String query) { - final market = - marketsMap.firstWhere((element) => element.$1 == item).$2.toLowerCase(); - - return market.contains(query.toLowerCase()); - } - - bool filterLocale(Locale locale, String query) { - final language = LanguageLocals.getDisplayLanguage( - locale.languageCode, - locale.countryCode, - ).toString(); - - return language.toLowerCase().contains(query.toLowerCase()); - } - - @override - Widget build(BuildContext context, ref) { - final preferences = ref.watch(userPreferencesProvider); - - return SafeArea( - child: Center( - child: BlurCard( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - const Icon( - SpotubeIcons.language, - size: 16, - ), - const SizedBox(width: 8), - Text(context.l10n.language_region).semiBold(), - ], - ), - const Gap(30), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(context.l10n.choose_your_region).semiBold(), - Text( - context.l10n.choose_your_region_description, - ).small().muted(), - const Gap(16), - Text(context.l10n.market_place_region).small(), - const Gap(8), - SizedBox( - width: double.infinity, - child: Select( - value: preferences.market, - onChanged: (value) { - if (value == null) return; - ref - .read(userPreferencesProvider.notifier) - .setRecommendationMarket(value); - }, - placeholder: Text(preferences.market.name), - itemBuilder: (context, value) => Text( - marketsMap - .firstWhere((element) => element.$1 == value) - .$2, - ), - popup: SelectPopup.builder( - searchPlaceholder: Text(context.l10n.search), - builder: (context, searchQuery) { - final filteredMarkets = searchQuery == null || - searchQuery.isEmpty - ? marketsMap - : marketsMap - .where( - (element) => - filterMarkets(element.$1, searchQuery), - ) - .toList(); - return SelectItemBuilder( - childCount: filteredMarkets.length, - builder: (context, index) { - final market = filteredMarkets[index]; - return SelectItemButton( - value: market.$1, - child: Text(market.$2), - ); - }, - ); - }, - ).call, - ), - ), - const Gap(36), - Text( - context.l10n.choose_your_language, - ).semiBold(), - const Gap(16), - Text(context.l10n.language).small(), - const Gap(8), - SizedBox( - width: double.infinity, - child: Select( - value: preferences.locale, - onChanged: (locale) { - if (locale == null) return; - ref - .read(userPreferencesProvider.notifier) - .setLocale(locale); - }, - placeholder: Text(context.l10n.system_default), - itemBuilder: (context, value) => - value.languageCode == "system" - ? Text(context.l10n.system_default) - : Text( - LanguageLocals.getDisplayLanguage( - value.languageCode, - value.countryCode, - ).toString(), - ), - popup: SelectPopup.builder( - searchPlaceholder: Text(context.l10n.search), - builder: (context, searchQuery) { - final hasNotQueried = - searchQuery == null || searchQuery.trim().isEmpty; - final filteredLocale = hasNotQueried - ? [ - const Locale("system", "system"), - ...L10n.all, - ] - : L10n.all - .where( - (element) => filterLocale( - element, - searchQuery.trim(), - ), - ) - .toList(); - - return SelectItemBuilder( - childCount: filteredLocale.length, - builder: (context, index) { - final locale = filteredLocale[index]; - if (locale == const Locale("system", "system")) { - return SelectItemButton( - value: locale, - child: Text(context.l10n.system_default), - ); - } - return SelectItemButton( - value: locale, - child: Text( - LanguageLocals.getDisplayLanguage( - locale.languageCode, - locale.countryCode, - ).toString(), - ), - ); - }, - ); - }, - ).call, - ), - ), - ], - ), - const Gap(48), - Align( - alignment: Alignment.centerRight, - child: Button.primary( - trailing: const Icon(SpotubeIcons.angleRight), - onPressed: onNext, - child: Text(context.l10n.next), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart deleted file mode 100644 index ef549296..00000000 --- a/lib/pages/getting_started/sections/support.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/env.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/getting_started/blur_card.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -class GettingStartedScreenSupportSection extends HookConsumerWidget { - const GettingStartedScreenSupportSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - BlurCard( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(SpotubeIcons.heartFilled, color: Colors.pink), - const SizedBox(width: 8), - Text( - context.l10n.help_project_grow, - style: const TextStyle(color: Colors.pink), - ).semiBold(), - ], - ), - const Gap(16), - Text(context.l10n.help_project_grow_description), - const Gap(16), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Button( - leading: const Icon(SpotubeIcons.github), - style: ButtonVariance.primary.copyWith( - decoration: (context, states, value) { - if (states.isNotEmpty) { - return ButtonVariance.primary - .decoration(context, states); - } - - return BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(8), - ); - }), - onPressed: () async { - await launchUrlString( - "https://github.com/KRTirtho/spotube", - mode: LaunchMode.externalApplication, - ); - }, - child: Text( - context.l10n.contribute_on_github, - style: const TextStyle(color: Colors.white), - ), - ), - if (!Env.hideDonations) ...[ - const Gap(16), - Button( - leading: const Icon(SpotubeIcons.openCollective), - style: ButtonVariance.primary.copyWith( - decoration: (context, states, value) { - if (states.isNotEmpty) { - return ButtonVariance.primary - .decoration(context, states); - } - - return BoxDecoration( - color: const Color(0xff4cb7f6), - borderRadius: BorderRadius.circular(8), - ); - }), - onPressed: () async { - await launchUrlString( - "https://opencollective.com/spotube", - mode: LaunchMode.externalApplication, - ); - }, - child: Text( - context.l10n.donate_on_open_collective, - style: const TextStyle(color: Colors.white), - ), - ), - ] - ], - ), - ], - ), - ), - const Gap(48), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 250), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Button.primary( - leading: const Icon(SpotubeIcons.extensions), - onPressed: () async { - await KVStoreService.setDoneGettingStarted(true); - if (context.mounted) { - context.pushRoute(const SettingsMetadataProviderRoute()); - } - }, - child: Text(context.l10n.install_a_metadata_provider), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart deleted file mode 100644 index a92c776e..00000000 --- a/lib/pages/home/home.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/modules/connect/connect_device.dart'; -import 'package:spotube/modules/home/sections/featured.dart'; -import 'package:spotube/modules/home/sections/sections.dart'; -import 'package:spotube/modules/home/sections/new_releases.dart'; -import 'package:spotube/modules/home/sections/recent.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.dart'; - -@RoutePage() -class HomePage extends HookConsumerWidget { - static const name = "home"; - const HomePage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final controller = useScrollController(); - final mediaQuery = MediaQuery.of(context); - final layoutMode = - ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - - return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - if (kTitlebarVisible) const TitleBar(height: 30), - ], - child: CustomScrollView( - controller: controller, - slivers: [ - if (mediaQuery.smAndDown || layoutMode == LayoutMode.compact) - SliverAppBar( - floating: true, - title: DefaultTextStyle( - style: TextStyle( - fontFamily: "Cookie", - fontSize: 30, - letterSpacing: 1.8, - color: theme.colorScheme.foreground, - ), - child: const Text("Spotube"), - ), - backgroundColor: theme.colorScheme.background, - foregroundColor: theme.colorScheme.foreground, - actions: [ - const ConnectDeviceButton(), - const Gap(10), - IconButton.ghost( - icon: const Icon(SpotubeIcons.settings, size: 20), - onPressed: () { - context.navigateTo(const SettingsRoute()); - }, - ), - const Gap(10), - ], - ) - else if (kIsMacOS) - const SliverGap(10), - const SliverGap(10), - SliverList.builder( - itemCount: 3, - itemBuilder: (context, index) { - return switch (index) { - // 0 => const HomeGenresSection(), - 0 => const HomeRecentlyPlayedSection(), - 1 => const HomeFeaturedSection(), - // 3 => const HomePageFriendsSection(), - _ => const HomeNewReleasesSection() - }; - }, - ), - const SliverSafeArea(sliver: HomePageBrowseSection()), - ], - ), - )); - } -} diff --git a/lib/pages/home/sections/section_items.dart b/lib/pages/home/sections/section_items.dart deleted file mode 100644 index 89666d26..00000000 --- a/lib/pages/home/sections/section_items.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/components/playbutton_view/playbutton_card.dart'; -import 'package:spotube/components/waypoint.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/modules/album/album_card.dart'; -import 'package:spotube/modules/artist/artist_card.dart'; -import 'package:spotube/modules/playlist/playlist_card.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/provider/metadata_plugin/browse/section_items.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; - -const _dummyPlaybuttonCard = PlaybuttonCard( - imageUrl: 'https://placehold.co/150x150.png', - isLoading: false, - isPlaying: false, - title: "Playbutton", - description: "A really cool playbutton", - isOwner: false, -); - -@RoutePage() -class HomeBrowseSectionItemsPage extends HookConsumerWidget { - static const name = "home_browse_section_items"; - - final String sectionId; - final SpotubeBrowseSectionObject section; - const HomeBrowseSectionItemsPage({ - super.key, - @PathParam("sectionId") required this.sectionId, - required this.section, - }); - - @override - Widget build(BuildContext context, ref) { - final scale = context.theme.scaling; - - final sectionItems = - ref.watch(metadataPluginBrowseSectionItemsProvider(sectionId)); - final sectionItemsNotifier = - ref.watch(metadataPluginBrowseSectionItemsProvider(sectionId).notifier); - final items = sectionItems.asData?.value.items ?? []; - final controller = useScrollController(); - - final isLoading = sectionItems.isLoading || sectionItems.isLoadingNextPage; - final itemCount = items.length; - final hasMore = sectionItems.asData?.value.hasMore ?? false; - - return SafeArea( - bottom: false, - child: Skeletonizer( - enabled: sectionItems.isLoading, - child: Scaffold( - headers: [ - TitleBar( - title: Text(section.title), - ) - ], - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: CustomScrollView( - controller: controller, - slivers: [ - SliverGrid.builder( - itemCount: isLoading ? 6 : itemCount + 1, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 150 * scale, - mainAxisExtent: 225 * scale, - crossAxisSpacing: 12 * scale, - mainAxisSpacing: 12 * scale, - ), - itemBuilder: (context, index) { - if (isLoading) { - return const Skeletonizer( - enabled: true, - child: _dummyPlaybuttonCard, - ); - } - - if (index == itemCount) { - if (!hasMore) return const SizedBox.shrink(); - return Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: () async { - await sectionItemsNotifier.fetchMore(); - }, - child: const Skeletonizer( - enabled: true, - child: _dummyPlaybuttonCard, - ), - ); - } - - final item = items[index]; - return switch (item) { - SpotubeFullArtistObject() => ArtistCard(item), - SpotubeSimplePlaylistObject() => PlaylistCard(item), - SpotubeSimpleAlbumObject() => AlbumCard(item), - _ => throw Exception( - "Unsupported item type: ${item.runtimeType}", - ), - }; - }, - ), - const SliverToBoxAdapter( - child: SafeArea( - child: SizedBox(), - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart deleted file mode 100644 index 164b9b0d..00000000 --- a/lib/pages/lastfm_login/lastfm_login.dart +++ /dev/null @@ -1,157 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/button/back_button.dart'; -import 'package:spotube/components/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/scrobbler/scrobbler.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class LastFMLoginPage extends HookConsumerWidget { - static const name = "lastfm_login"; - const LastFMLoginPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final scrobblerNotifier = ref.read(scrobblerProvider.notifier); - - final usernameKey = - useMemoized(() => const FormKey("username"), []); - final passwordKey = - useMemoized(() => const FormKey("password"), []); - - final passwordVisible = useState(false); - - final isLoading = useState(false); - - return Scaffold( - headers: const [ - SafeArea( - bottom: false, - child: TitleBar( - leading: [BackButton()], - ), - ), - ], - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: Container( - constraints: const BoxConstraints(maxWidth: 400), - alignment: Alignment.center, - padding: const EdgeInsets.all(16), - child: Card( - padding: const EdgeInsets.all(16.0), - child: Form( - onSubmit: (context, values) async { - try { - isLoading.value = true; - await scrobblerNotifier.login( - values[usernameKey].trim(), - values[passwordKey], - ); - if (context.mounted) { - context.back(); - } - } catch (e) { - if (context.mounted) { - showPromptDialog( - context: context, - title: context.l10n.error("Authentication failed"), - message: e.toString(), - cancelText: null, - ); - } - } finally { - isLoading.value = false; - } - }, - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 10, - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(30), - color: const Color.fromARGB(255, 186, 0, 0), - ), - padding: const EdgeInsets.all(12), - child: const Icon( - SpotubeIcons.lastFm, - color: Colors.white, - size: 60, - ), - ), - const Text("last.fm").h3(), - Text(context.l10n.login_with_your_lastfm), - AutofillGroup( - child: Column( - spacing: 10, - children: [ - FormField( - label: Text(context.l10n.username), - key: usernameKey, - validator: const NotEmptyValidator( - message: "Username is required", - ), - child: TextField( - autofillHints: const [ - AutofillHints.username, - AutofillHints.email, - ], - placeholder: Text(context.l10n.username), - ), - ), - FormField( - key: passwordKey, - validator: const NotEmptyValidator( - message: "Password is required", - ), - label: Text(context.l10n.password), - child: TextField( - autofillHints: const [ - AutofillHints.password, - ], - obscureText: !passwordVisible.value, - placeholder: Text(context.l10n.password), - features: [ - InputFeature.trailing( - IconButton.ghost( - icon: Icon( - passwordVisible.value - ? SpotubeIcons.eye - : SpotubeIcons.noEye, - ), - onPressed: () => passwordVisible.value = - !passwordVisible.value, - ), - ), - ], - ), - ), - ], - ), - ), - FormErrorBuilder(builder: (context, errors, child) { - return Button.primary( - onPressed: () => context.submitForm(), - enabled: errors.isEmpty && !isLoading.value, - child: Text(context.l10n.login), - ); - }), - ], - ), - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart deleted file mode 100644 index de438451..00000000 --- a/lib/pages/library/library.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart' show Badge; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/side_bar_tiles.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; - -@RoutePage() -class LibraryPage extends HookConsumerWidget { - const LibraryPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final downloadingCount = ref - .watch(downloadManagerProvider) - .where((e) => - e.status == DownloadStatus.downloading || - e.status == DownloadStatus.queued) - .length; - final router = context.watchRouter; - final sidebarLibraryTileList = useMemoized( - () => [ - ...getSidebarLibraryTileList(context.l10n), - SideBarTiles( - id: "downloads", - pathPrefix: "library/downloads", - title: context.l10n.downloads, - route: const UserDownloadsRoute(), - icon: SpotubeIcons.download, - ), - ], - [context.l10n], - ); - final index = sidebarLibraryTileList.indexWhere( - (e) => router.currentPath.startsWith(e.pathPrefix), - ); - - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) { - context.navigateTo(const HomeRoute()); - }, - child: SafeArea( - bottom: false, - child: LayoutBuilder(builder: (context, constraints) { - return Scaffold( - headers: [ - if (constraints.smAndDown) - TitleBar( - automaticallyImplyLeading: false, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: TabList( - index: index, - onChanged: (index) { - context.navigateTo(sidebarLibraryTileList[index].route); - }, - children: [ - for (final tile in sidebarLibraryTileList) - TabItem( - child: Badge( - isLabelVisible: tile.id == 'downloads' && - downloadingCount > 0, - label: Text(downloadingCount.toString()), - child: Text(tile.title), - ), - ), - ], - ), - ), - ) - else - const TitleBar( - automaticallyImplyLeading: false, - backgroundColor: Colors.transparent, - surfaceBlur: 0, - height: 32, - ), - const Gap(10), - ], - child: const AutoRouter(), - ); - }), - ), - ); - } -} diff --git a/lib/pages/library/user_albums.dart b/lib/pages/library/user_albums.dart deleted file mode 100644 index 2d989138..00000000 --- a/lib/pages/library/user_albums.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'package:flutter/material.dart' as material; -import 'package:flutter_undraw/flutter_undraw.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Image; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:collection/collection.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/fallbacks/error_box.dart'; -import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart'; -import 'package:spotube/components/playbutton_view/playbutton_view.dart'; -import 'package:spotube/modules/album/album_card.dart'; -import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/library/albums.dart'; -import 'package:auto_route/auto_route.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; - -@RoutePage() -class UserAlbumsPage extends HookConsumerWidget { - static const name = 'user_albums'; - const UserAlbumsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final authenticated = ref.watch(metadataPluginAuthenticatedProvider); - final albumsQuery = ref.watch(metadataPluginSavedAlbumsProvider); - final albumsQueryNotifier = - ref.watch(metadataPluginSavedAlbumsProvider.notifier); - - final controller = useScrollController(); - - final searchText = useState(''); - - final albums = useMemoized(() { - if (searchText.value.isEmpty) { - return albumsQuery.asData?.value.items ?? []; - } - return albumsQuery.asData?.value.items - .map((e) => ( - weightedRatio(e.name, searchText.value), - e, - )) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList() ?? - []; - }, [albumsQuery.asData?.value, searchText.value]); - - if (albumsQuery.error - case MetadataPluginException( - errorCode: MetadataPluginErrorCode.noDefaultMetadataPlugin, - message: _, - )) { - return const Center(child: NoDefaultMetadataPlugin()); - } - - if (authenticated.asData?.value != true) { - return const AnonymousFallback(); - } - - if (albumsQuery.hasError) { - return ErrorBox( - error: albumsQuery.error!, - onRetry: () { - ref.invalidate(metadataPluginSavedAlbumsProvider); - }, - ); - } - - return SafeArea( - bottom: false, - child: Scaffold( - child: material.RefreshIndicator.adaptive( - onRefresh: () async { - ref.invalidate(metadataPluginSavedAlbumsProvider); - }, - child: InterScrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - slivers: [ - SliverAppBar( - automaticallyImplyLeading: false, - backgroundColor: Theme.of(context).colorScheme.background, - floating: true, - flexibleSpace: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: SizedBox( - height: 48, - child: TextField( - onChanged: (value) => searchText.value = value, - features: const [ - InputFeature.leading(Icon(SpotubeIcons.filter)) - ], - placeholder: Text(context.l10n.filter_albums), - ), - ), - ), - ), - const SliverGap(10), - if (albums.isEmpty && - !albumsQuery.isLoading && - searchText.value.isEmpty) - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 8), - sliver: SliverToBoxAdapter( - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 10, - children: [ - Undraw( - height: 200 * context.theme.scaling, - illustration: UndrawIllustration.followMeDrone, - color: Theme.of(context).colorScheme.primary, - ), - Text( - context.l10n.no_favorite_albums_yet, - textAlign: TextAlign.center, - ).muted().small() - ], - ), - ), - ) - else - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 8), - sliver: PlaybuttonView( - controller: controller, - itemCount: albums.length, - hasMore: albumsQuery.asData?.value.hasMore == true, - isLoading: albumsQuery.isLoading, - onRequestMore: albumsQueryNotifier.fetchMore, - gridItemBuilder: (context, index) => AlbumCard( - albums[index], - ), - listItemBuilder: (context, index) => - AlbumCard.tile(albums[index]), - ), - ), - const SliverSafeArea(sliver: SliverGap(10)), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/library/user_artists.dart b/lib/pages/library/user_artists.dart deleted file mode 100644 index 750cb50b..00000000 --- a/lib/pages/library/user_artists.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'package:flutter/material.dart' as material; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter_undraw/flutter_undraw.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/fallbacks/error_box.dart'; -import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart'; -import 'package:spotube/modules/artist/artist_card.dart'; -import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/waypoint.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/library/artists.dart'; -import 'package:auto_route/auto_route.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; - -@RoutePage() -class UserArtistsPage extends HookConsumerWidget { - static const name = 'user_artists'; - const UserArtistsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final authenticated = ref.watch(metadataPluginAuthenticatedProvider); - - final artistQuery = ref.watch(metadataPluginSavedArtistsProvider); - final artistQueryNotifier = - ref.watch(metadataPluginSavedArtistsProvider.notifier); - - final searchText = useState(''); - - final filteredArtists = useMemoized(() { - final artists = artistQuery.asData?.value.items ?? []; - - if (searchText.value.isEmpty) { - return artists.toList(); - } - return artists - .map((e) => ( - weightedRatio(e.name, searchText.value), - e, - )) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(); - }, [artistQuery.asData?.value.items, searchText.value]); - - final controller = useScrollController(); - - if (artistQuery.error - case MetadataPluginException( - errorCode: MetadataPluginErrorCode.noDefaultMetadataPlugin, - message: _, - )) { - return const Center(child: NoDefaultMetadataPlugin()); - } - - if (authenticated.asData?.value != true) { - return const AnonymousFallback(); - } - - if (artistQuery.hasError) { - return ErrorBox( - error: artistQuery.error!, - onRetry: () { - ref.invalidate(metadataPluginSavedArtistsProvider); - }, - ); - } - - return SafeArea( - bottom: false, - child: Scaffold( - child: material.RefreshIndicator.adaptive( - onRefresh: () async { - ref.invalidate(metadataPluginSavedArtistsProvider); - }, - child: InterScrollbar( - controller: controller, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: CustomScrollView( - controller: controller, - slivers: [ - SliverAppBar( - automaticallyImplyLeading: false, - backgroundColor: Theme.of(context).colorScheme.background, - floating: true, - flexibleSpace: SizedBox( - height: 48, - child: TextField( - onChanged: (value) => searchText.value = value, - features: const [ - InputFeature.leading(Icon(SpotubeIcons.filter)), - ], - placeholder: Text(context.l10n.filter_artist), - ), - ), - ), - const SliverGap(10), - if (filteredArtists.isNotEmpty || artistQuery.isLoading) - SliverLayoutBuilder(builder: (context, constrains) { - return SliverGrid.builder( - itemCount: filteredArtists.length + 1, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: constrains.smAndDown ? 225 : 250, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemBuilder: (context, index) { - if (filteredArtists.isNotEmpty && - index == filteredArtists.length) { - if (artistQuery.asData?.value.hasMore != true) { - return const SizedBox.shrink(); - } - - return Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: artistQueryNotifier.fetchMore, - child: Skeletonizer( - enabled: true, - child: ArtistCard(FakeData.artist), - ), - ); - } - - return Skeletonizer( - enabled: artistQuery.isLoading, - child: ArtistCard( - filteredArtists.elementAtOrNull(index) ?? - FakeData.artist, - ), - ); - }, - ); - }) - else if (filteredArtists.isEmpty && - searchText.value.isEmpty && - !artistQuery.isLoading) - SliverToBoxAdapter( - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 10, - children: [ - Undraw( - height: 200 * context.theme.scaling, - illustration: UndrawIllustration.followMeDrone, - color: Theme.of(context).colorScheme.primary, - ), - Text( - context.l10n.not_following_artists, - textAlign: TextAlign.center, - ).muted().small() - ], - ), - ) - else - SliverToBoxAdapter( - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 10, - children: [ - Undraw( - height: 200 * context.theme.scaling, - illustration: UndrawIllustration.taken, - color: Theme.of(context).colorScheme.primary, - ), - Text( - context.l10n.nothing_found, - textAlign: TextAlign.center, - ).muted().small() - ], - ), - ), - const SliverSafeArea(sliver: SliverGap(10)), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/library/user_downloads.dart b/lib/pages/library/user_downloads.dart deleted file mode 100644 index f6a130bb..00000000 --- a/lib/pages/library/user_downloads.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -import 'package:spotube/modules/library/user_downloads/download_item.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class UserDownloadsPage extends HookConsumerWidget { - static const name = 'user_downloads'; - const UserDownloadsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final downloadQueue = ref.watch(downloadManagerProvider); - final downloadManagerNotifier = ref.watch(downloadManagerProvider.notifier); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: AutoSizeText( - context.l10n.currently_downloading(downloadQueue.length), - maxLines: 1, - ).semiBold(), - ), - const SizedBox(width: 10), - Button.destructive( - onPressed: downloadQueue.isEmpty - ? null - : downloadManagerNotifier.clearAll, - child: Text(context.l10n.cancel_all), - ), - ], - ), - ), - Expanded( - child: SafeArea( - child: ListView.builder( - itemCount: downloadQueue.length, - padding: const EdgeInsets.only(bottom: 200), - itemBuilder: (context, index) { - return DownloadItem( - task: downloadQueue.elementAt(index), - ); - }, - ), - ), - ), - ], - ); - } -} diff --git a/lib/pages/library/user_local_tracks/local_folder.dart b/lib/pages/library/user_local_tracks/local_folder.dart deleted file mode 100644 index 523097e1..00000000 --- a/lib/pages/library/user_local_tracks/local_folder.dart +++ /dev/null @@ -1,504 +0,0 @@ -import 'dart:io'; -import 'dart:math'; - -import 'package:flutter/material.dart' as material; -import 'package:collection/collection.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_undraw/flutter_undraw.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/button/back_button.dart'; -import 'package:spotube/components/track_presentation/presentation_actions.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/string.dart'; -import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/modules/library/local_folder/cache_export_dialog.dart'; -import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart'; -import 'package:spotube/components/expandable_search/expandable_search.dart'; -import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/components/track_presentation/sort_tracks_dropdown.dart'; -import 'package:spotube/components/track_tile/track_tile.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class LocalLibraryPage extends HookConsumerWidget { - static const name = "local_library_page"; - - final String location; - final bool isDownloads; - final bool isCache; - const LocalLibraryPage( - this.location, { - super.key, - this.isDownloads = false, - this.isCache = false, - }); - - Future playLocalTracks( - WidgetRef ref, - List tracks, { - SpotubeLocalTrackObject? currentTrack, - }) async { - final playlist = ref.read(audioPlayerProvider); - final playback = ref.read(audioPlayerProvider.notifier); - currentTrack ??= tracks.first; - final isPlaylistPlaying = playlist.containsTracks(tracks); - if (!isPlaylistPlaying) { - var indexWhere = tracks.indexWhere((s) => s.id == currentTrack?.id); - await playback.load( - tracks, - initialIndex: indexWhere, - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != playlist.activeTrack?.id) { - await playback.jumpToTrack(currentTrack); - } - } - - Future shufflePlayLocalTracks( - WidgetRef ref, - List tracks, - ) async { - final playlist = ref.read(audioPlayerProvider); - final playback = ref.read(audioPlayerProvider.notifier); - final isPlaylistPlaying = playlist.containsTracks(tracks); - final shuffledTracks = tracks.shuffled(); - if (isPlaylistPlaying) return; - - await playback.load( - shuffledTracks, - initialIndex: 0, - autoPlay: true, - ); - } - - Future addToQueueLocalTracks( - BuildContext context, - WidgetRef ref, - List tracks, - ) async { - final playlist = ref.read(audioPlayerProvider); - final playback = ref.read(audioPlayerProvider.notifier); - final isPlaylistPlaying = playlist.containsTracks(tracks); - if (isPlaylistPlaying) return; - await playback.addTracks(tracks); - if (!context.mounted) return; - showToastForAction(context, "add-to-queue", tracks.length); - } - - @override - Widget build(BuildContext context, ref) { - final scale = context.theme.scaling; - - final sortBy = useState(SortBy.none); - final playlist = ref.watch(audioPlayerProvider); - final trackSnapshot = ref.watch(localTracksProvider); - final isPlaylistPlaying = useMemoized( - () => playlist.containsTracks( - trackSnapshot.asData?.value[location] ?? [], - ), - [playlist, trackSnapshot, location], - ); - - final searchController = useShadcnTextEditingController(); - useValueListenable(searchController); - final searchFocus = useFocusNode(); - final isFiltering = useState(false); - - final controller = useScrollController(); - - final directorySize = useMemoized(() async { - final dir = Directory(location); - final files = await dir.list(recursive: true).toList(); - - final filesLength = - await Future.wait(files.whereType().map((e) => e.length())); - - return (filesLength.sum.toInt() / pow(10, 9)).toStringAsFixed(2); - }, [location]); - - return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - TitleBar( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 0, - ), - surfaceBlur: 0, - leading: const [BackButton()], - title: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isDownloads - ? context.l10n.downloads - : isCache - ? context.l10n.cache_folder.capitalize() - : location, - ), - FutureBuilder( - future: directorySize, - builder: (context, snapshot) { - return Text( - "${(snapshot.data ?? 0)} GB", - ).xSmall().muted(); - }, - ) - ], - ), - backgroundColor: Colors.transparent, - trailingGap: 10, - trailing: [ - if (isCache) ...[ - IconButton.outline( - size: ButtonSize.small, - icon: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.delete), - Text(context.l10n.clear_cache) - ], - ).xSmall().iconSmall(), - onPressed: () async { - final accepted = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.l10n.clear_cache_confirmation), - actions: [ - Button.outline( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text(context.l10n.decline), - ), - Button.destructive( - onPressed: () async { - Navigator.of(context).pop(true); - }, - child: Text(context.l10n.accept), - ), - ], - ), - ); - - if (accepted != true) return; - - final cacheDir = Directory( - await UserPreferencesNotifier.getMusicCacheDir(), - ); - - if (cacheDir.existsSync()) { - await cacheDir.delete(recursive: true); - } - - ref.invalidate(localTracksProvider); - }, - ), - IconButton.outline( - size: ButtonSize.small, - icon: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.export), - Text( - context.l10n.export, - ) - ], - ).xSmall().iconSmall(), - onPressed: () async { - final exportPath = - await FilePicker.platform.getDirectoryPath(); - - if (exportPath == null) return; - final exportDirectory = Directory(exportPath); - - if (!exportDirectory.existsSync()) { - await exportDirectory.create(recursive: true); - } - - final cacheDir = Directory( - await UserPreferencesNotifier.getMusicCacheDir()); - - if (!context.mounted) return; - await showDialog( - context: context, - builder: (context) { - return LocalFolderCacheExportDialog( - cacheDir: cacheDir, - exportDir: exportDirectory, - ); - }, - ); - }, - ), - ] - ], - ), - ], - child: LayoutBuilder( - builder: (context, constraints) => Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - const Gap(5), - Tooltip( - tooltip: - TooltipContainer(child: Text(context.l10n.play)).call, - child: IconButton.primary( - onPressed: trackSnapshot.asData?.value != null - ? () async { - if (trackSnapshot.asData?.value.isNotEmpty == - true) { - if (!isPlaylistPlaying) { - await playLocalTracks( - ref, - trackSnapshot.asData!.value[location] ?? - [], - ); - } - } - } - : null, - icon: Icon( - isPlaylistPlaying - ? SpotubeIcons.stop - : SpotubeIcons.play, - ), - ), - ), - const Gap(5), - Tooltip( - tooltip: - TooltipContainer(child: Text(context.l10n.shuffle)) - .call, - child: IconButton.outline( - onPressed: trackSnapshot.asData?.value != null - ? () async { - if (trackSnapshot.asData?.value.isNotEmpty == - true) { - if (!isPlaylistPlaying) { - await shufflePlayLocalTracks( - ref, - trackSnapshot.asData!.value[location] ?? - [], - ); - } - } - } - : null, - enabled: !isPlaylistPlaying, - icon: const Icon(SpotubeIcons.shuffle), - ), - ), - const Gap(5), - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.add_to_queue)) - .call, - child: IconButton.outline( - onPressed: trackSnapshot.asData?.value != null - ? () async { - if (trackSnapshot.asData?.value.isNotEmpty == - true) { - if (!isPlaylistPlaying) { - await addToQueueLocalTracks( - context, - ref, - trackSnapshot.asData!.value[location] ?? - [], - ); - } - } - } - : null, - enabled: !isPlaylistPlaying, - icon: const Icon(SpotubeIcons.queueAdd), - ), - ), - const Spacer(), - if (constraints.smAndDown) - ExpandableSearchButton( - isFiltering: isFiltering.value, - onPressed: (value) => isFiltering.value = value, - searchFocus: searchFocus, - ) - else - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: 300 * scale, - maxHeight: 38 * scale, - ), - child: ExpandableSearchField( - isFiltering: true, - onChangeFiltering: (value) {}, - searchController: searchController, - searchFocus: searchFocus, - ), - ), - const Gap(5), - SortTracksDropdown( - value: sortBy.value, - onChanged: (value) { - sortBy.value = value; - }, - ), - const Gap(5), - IconButton.outline( - icon: const Icon(SpotubeIcons.refresh), - onPressed: () { - ref.invalidate(localTracksProvider); - }, - ) - ], - ), - ), - ExpandableSearchField( - searchController: searchController, - searchFocus: searchFocus, - isFiltering: isFiltering.value, - onChangeFiltering: (value) => isFiltering.value = value, - ), - HookBuilder(builder: (context) { - return trackSnapshot.when( - data: (tracks) { - final sortedTracks = useMemoized(() { - return ServiceUtils.sortTracks( - tracks[location] ?? [], - sortBy.value); - }, [sortBy.value, tracks]); - - final filteredTracks = useMemoized(() { - if (searchController.text.isEmpty) { - return sortedTracks; - } - return sortedTracks - .map((e) => ( - weightedRatio( - "${e.name} - ${e.artists.asString()}", - searchController.text, - ), - e, - )) - .toList() - .sorted( - (a, b) => b.$1.compareTo(a.$1), - ) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList() - .toList(); - }, [searchController.text, sortedTracks]); - - if (!trackSnapshot.isLoading && filteredTracks.isEmpty) { - return Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Undraw( - illustration: UndrawIllustration.empty, - height: 200 * scale, - color: context.theme.colorScheme.primary, - ), - const Gap(10), - Text( - context.l10n.nothing_found, - textAlign: TextAlign.center, - ).muted().small() - ], - ), - ); - } - - return Expanded( - child: material.RefreshIndicator.adaptive( - onRefresh: () async { - ref.invalidate(localTracksProvider); - }, - child: InterScrollbar( - controller: controller, - child: Skeletonizer( - enabled: trackSnapshot.isLoading, - child: CustomScrollView( - controller: controller, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverList.builder( - itemCount: trackSnapshot.isLoading - ? 5 - : filteredTracks.length, - itemBuilder: (context, index) { - if (trackSnapshot.isLoading) { - return TrackTile( - playlist: playlist, - track: FakeData.track, - index: index, - ); - } - - final track = filteredTracks[index]; - return TrackTile( - index: index, - playlist: playlist, - track: track, - userPlaylist: false, - onTap: () async { - await playLocalTracks( - ref, - sortedTracks, - currentTrack: track, - ); - }, - ); - }, - ), - const SliverGap(200), - ], - ), - ), - ), - ), - ); - }, - loading: () => Expanded( - child: Skeletonizer( - enabled: true, - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) => TrackTile( - track: FakeData.track, - index: index, - playlist: playlist, - ), - ), - ), - ), - error: (error, stackTrace) => - Text(error.toString() + stackTrace.toString()), - ); - }), - ], - ), - ), - ), - ); - } -} diff --git a/lib/pages/library/user_local_tracks/user_local_tracks.dart b/lib/pages/library/user_local_tracks/user_local_tracks.dart deleted file mode 100644 index 5f7502e6..00000000 --- a/lib/pages/library/user_local_tracks/user_local_tracks.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:file_selector/file_selector.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/library/local_folder/local_folder_item.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.dart'; - -enum SortBy { - none, - ascending, - descending, - newest, - oldest, - duration, - artist, - album, -} - -@RoutePage() -class UserLocalLibraryPage extends HookConsumerWidget { - static const name = 'user_local_library'; - const UserLocalLibraryPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final cacheDir = useFuture(UserPreferencesNotifier.getMusicCacheDir()); - final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); - final preferences = ref.watch(userPreferencesProvider); - - final addLocalLibraryLocation = useCallback(() async { - if (kIsMobile || kIsMacOS) { - final dirStr = await FilePicker.platform.getDirectoryPath( - initialDirectory: preferences.downloadLocation, - ); - if (dirStr == null) return; - if (preferences.localLibraryLocation.contains(dirStr)) return; - preferencesNotifier.setLocalLibraryLocation( - [...preferences.localLibraryLocation, dirStr]); - } else { - String? dirStr = await getDirectoryPath( - initialDirectory: preferences.downloadLocation, - ); - if (dirStr == null) return; - if (preferences.localLibraryLocation.contains(dirStr)) return; - preferencesNotifier.setLocalLibraryLocation( - [...preferences.localLibraryLocation, dirStr]); - } - }, [preferences.localLibraryLocation]); - - // This is just to pre-load the tracks. - // For now, this gets all of them. - ref.watch(localTracksProvider); - - final locations = [ - preferences.downloadLocation, - if (cacheDir.hasData) cacheDir.data!, - ...preferences.localLibraryLocation, - ]; - - return LayoutBuilder( - builder: (context, constrains) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Column( - children: [ - Align( - alignment: Alignment.centerRight, - child: Button.secondary( - leading: const Icon(SpotubeIcons.folderAdd), - onPressed: addLocalLibraryLocation, - child: Text(context.l10n.add_library_location), - ), - ), - const Gap(8), - Expanded( - child: GridView.builder( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: constrains.isXs - ? 230 * context.theme.scaling - : constrains.mdAndDown - ? 280 * context.theme.scaling - : 250 * context.theme.scaling, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - ), - itemCount: locations.length, - itemBuilder: (context, index) { - return LocalFolderItem( - folder: locations[index], - ); - }, - ), - ), - ], - ), - )); - } -} diff --git a/lib/pages/library/user_playlists.dart b/lib/pages/library/user_playlists.dart deleted file mode 100644 index 740bc947..00000000 --- a/lib/pages/library/user_playlists.dart +++ /dev/null @@ -1,172 +0,0 @@ -import 'package:flutter/material.dart' as material; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:collection/collection.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Image; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/assets.gen.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/fallbacks/error_box.dart'; -import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart'; -import 'package:spotube/components/playbutton_view/playbutton_view.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; -import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/modules/playlist/playlist_card.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/library/playlists.dart'; -import 'package:spotube/provider/metadata_plugin/core/user.dart'; -import 'package:auto_route/auto_route.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; - -@RoutePage() -class UserPlaylistsPage extends HookConsumerWidget { - static const name = 'user_playlists'; - const UserPlaylistsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final searchText = useState(''); - - final authenticated = ref.watch(metadataPluginAuthenticatedProvider); - - final me = ref.watch(metadataPluginUserProvider); - final playlistsQuery = ref.watch(metadataPluginSavedPlaylistsProvider); - final playlistsQueryNotifier = - ref.watch(metadataPluginSavedPlaylistsProvider.notifier); - - final likedTracksPlaylist = useMemoized( - () => me.asData?.value == null - ? null - : SpotubeSimplePlaylistObject( - id: "user-liked-tracks", - name: context.l10n.liked_tracks, - description: context.l10n.liked_tracks_description, - externalUri: "", - owner: me.asData!.value!, - images: [ - SpotubeImageObject( - url: Assets.images.likedTracks.path, - width: 300, - height: 300, - ) - ]), - [context.l10n, me.asData?.value], - ); - - final playlists = useMemoized( - () { - if (searchText.value.isEmpty) { - return [ - if (likedTracksPlaylist != null) likedTracksPlaylist, - ...?playlistsQuery.asData?.value.items, - ]; - } - return [ - if (likedTracksPlaylist != null) likedTracksPlaylist, - ...?playlistsQuery.asData?.value.items, - ] - .map((e) => (weightedRatio(e.name, searchText.value), e)) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(); - }, - [playlistsQuery, searchText.value], - ); - - final controller = useScrollController(); - - if (playlistsQuery.error - case MetadataPluginException( - errorCode: MetadataPluginErrorCode.noDefaultMetadataPlugin, - message: _, - )) { - return const Center(child: NoDefaultMetadataPlugin()); - } - - if (authenticated.asData?.value != true) { - return const AnonymousFallback(); - } - - if (playlistsQuery.hasError) { - return ErrorBox( - error: playlistsQuery.error!, - onRetry: () { - ref.invalidate(metadataPluginSavedPlaylistsProvider); - }, - ); - } - - return material.RefreshIndicator.adaptive( - onRefresh: () async { - ref.invalidate(metadataPluginSavedPlaylistsProvider); - }, - child: SafeArea( - bottom: false, - child: InterScrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - slivers: [ - SliverAppBar( - automaticallyImplyLeading: false, - floating: true, - backgroundColor: context.theme.colorScheme.background, - flexibleSpace: Container( - padding: const EdgeInsets.symmetric(horizontal: 8), - height: 48, - child: TextField( - onChanged: (value) => searchText.value = value, - placeholder: Text(context.l10n.filter_playlists), - features: const [ - InputFeature.leading(Icon(SpotubeIcons.filter)), - ], - ), - ), - ), - const SliverGap(10), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 8), - sliver: PlaybuttonView( - leading: const Expanded( - child: Row( - children: [ - PlaylistCreateDialogButton(), - // const Gap(10), - // Button.primary( - // leading: const Icon(SpotubeIcons.magic), - // child: Text(context.l10n.generate), - // onPressed: () { - // context.navigateTo(const PlaylistGeneratorRoute()); - // }, - // ), - // const Gap(10), - ], - ), - ), - controller: controller, - hasMore: playlistsQuery.asData?.value.hasMore == true, - isLoading: playlistsQuery.isLoading, - onRequestMore: playlistsQueryNotifier.fetchMore, - itemCount: playlists.length, - gridItemBuilder: (context, index) { - return PlaylistCard(playlists[index]); - }, - listItemBuilder: (context, index) { - return PlaylistCard.tile(playlists[index]); - }, - ), - ), - const SliverSafeArea(sliver: SliverGap(10)), - ], - ), - ), - ), - ); - } -} diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart deleted file mode 100644 index b55dc02e..00000000 --- a/lib/pages/lyrics/lyrics.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Consumer; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; - -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/utils/use_palette_color.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/pages/lyrics/plain_lyrics.dart'; -import 'package:spotube/pages/lyrics/synced_lyrics.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/lyrics/synced.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class LyricsPage extends HookConsumerWidget { - static const name = "lyrics"; - - const LyricsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch(audioPlayerProvider); - String albumArt = useMemoized( - () => (playlist.activeTrack?.album.images).asUrlString( - index: (playlist.activeTrack?.album.images.length ?? 1) - 1, - placeholder: ImagePlaceholder.albumArt, - ), - [playlist.activeTrack?.album.images], - ); - final palette = usePaletteColor(albumArt, ref); - final selectedIndex = useState(0); - - Widget tabbar = Padding( - padding: const EdgeInsets.all(10), - child: Tabs( - index: selectedIndex.value, - onChanged: (index) => selectedIndex.value = index, - children: [ - TabItem(child: Text(context.l10n.synced)), - TabItem(child: Text(context.l10n.plain)), - ], - ), - ); - - tabbar = Row( - children: [ - tabbar, - const Spacer(), - Consumer( - builder: (context, ref, child) { - final playback = ref.watch(audioPlayerProvider); - final lyric = ref.watch(syncedLyricsProvider(playback.activeTrack)); - final providerName = lyric.asData?.value.provider; - - if (providerName == null) { - return const SizedBox.shrink(); - } - - return Align( - alignment: Alignment.bottomRight, - child: Text(context.l10n.powered_by_provider(providerName)), - ); - }, - ), - const Gap(5), - ], - ); - - return SafeArea( - bottom: false, - child: Scaffold( - floatingHeader: true, - headers: [ - !kIsMacOS - ? TitleBar( - backgroundColor: Colors.transparent, - title: tabbar, - height: 58 * context.theme.scaling, - surfaceBlur: 0, - automaticallyImplyLeading: false, - ) - : tabbar - ], - child: Container( - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - image: DecorationImage( - image: UniversalImage.imageProvider(albumArt), - fit: BoxFit.cover, - ), - ), - margin: const EdgeInsets.only(bottom: 10), - child: SurfaceCard( - surfaceBlur: context.theme.surfaceBlur, - surfaceOpacity: context.theme.surfaceOpacity, - padding: EdgeInsets.zero, - borderRadius: BorderRadius.zero, - borderWidth: 0, - child: ColoredBox( - color: palette.color.withValues(alpha: .7), - child: SafeArea( - child: IndexedStack( - index: selectedIndex.value, - children: [ - SyncedLyrics(palette: palette, isModal: false), - PlainLyrics(palette: palette, isModal: false), - ], - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart deleted file mode 100644 index 4c28eddd..00000000 --- a/lib/pages/lyrics/mini_lyrics.dart +++ /dev/null @@ -1,287 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Consumer; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/player/player_controls.dart'; -import 'package:spotube/modules/player/player_queue.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/utils/use_force_update.dart'; -import 'package:spotube/pages/lyrics/plain_lyrics.dart'; -import 'package:spotube/pages/lyrics/synced_lyrics.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:window_manager/window_manager.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class MiniLyricsPage extends HookConsumerWidget { - static const name = "mini_lyrics"; - - final Size prevSize; - const MiniLyricsPage({super.key, required this.prevSize}); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final update = useForceUpdate(); - final wasMaximized = useRef(false); - - final playlistQueue = ref.watch(audioPlayerProvider); - - final index = useState(0); - - final areaActive = useState(false); - final hoverMode = useState(true); - final showLyrics = useState(true); - - useEffect(() { - if (kIsDesktop) { - WidgetsBinding.instance.addPostFrameCallback((_) async { - wasMaximized.value = await windowManager.isMaximized(); - }); - } - return null; - }, []); - - return MouseRegion( - onEnter: !hoverMode.value - ? null - : (event) { - areaActive.value = true; - }, - onExit: !hoverMode.value - ? null - : (event) { - areaActive.value = false; - }, - child: Scaffold( - backgroundColor: theme.colorScheme.background.withValues(alpha: 0.4), - headers: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: AnimatedCrossFade( - duration: const Duration(milliseconds: 200), - crossFadeState: areaActive.value - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - secondChild: const SizedBox(), - firstChild: DragToMoveArea( - child: Row( - spacing: 2, - children: [ - const Gap(10), - if (kIsMacOS) const SizedBox(width: 65), - if (showLyrics.value) - Tabs( - index: index.value, - onChanged: (i) { - index.value = i; - }, - children: [ - TabItem(child: Text(context.l10n.synced)), - TabItem(child: Text(context.l10n.plain)), - ], - ), - const Spacer(), - Tooltip( - tooltip: - TooltipContainer(child: Text(context.l10n.lyrics)) - .call, - child: IconButton( - variance: showLyrics.value - ? ButtonVariance.secondary - : ButtonVariance.ghost, - icon: showLyrics.value - ? const Icon(SpotubeIcons.lyrics) - : const Icon(SpotubeIcons.lyricsOff), - onPressed: () async { - showLyrics.value = !showLyrics.value; - areaActive.value = true; - hoverMode.value = false; - - if (kIsDesktop) { - await windowManager.setSize( - showLyrics.value - ? const Size(400, 500) - : const Size(400, 150), - ); - } - }, - ), - ), - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.show_hide_ui_on_hover), - ).call, - child: IconButton( - variance: hoverMode.value - ? ButtonVariance.secondary - : ButtonVariance.ghost, - icon: hoverMode.value - ? const Icon(SpotubeIcons.hoverOn) - : const Icon(SpotubeIcons.hoverOff), - onPressed: () async { - areaActive.value = true; - hoverMode.value = !hoverMode.value; - }, - ), - ), - if (kIsDesktop) - FutureBuilder( - future: windowManager.isAlwaysOnTop(), - builder: (context, snapshot) { - return Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.always_on_top), - ).call, - child: IconButton( - variance: snapshot.data == true - ? ButtonVariance.secondary - : ButtonVariance.ghost, - icon: Icon( - snapshot.data == true - ? SpotubeIcons.pinOn - : SpotubeIcons.pinOff, - ), - onPressed: snapshot.data == null - ? null - : () async { - await windowManager.setAlwaysOnTop( - snapshot.data == true ? false : true, - ); - update(); - }, - ), - ); - }, - ), - ], - ), - ), - ), - ), - ], - child: Column( - children: [ - if (playlistQueue.activeTrack != null) - Text(playlistQueue.activeTrack!.name!).semiBold(), - if (showLyrics.value) - Expanded( - child: IndexedStack( - index: index.value, - children: [ - SyncedLyrics( - palette: PaletteColor(theme.colorScheme.background, 0), - isModal: true, - defaultTextZoom: 65, - ), - PlainLyrics( - palette: PaletteColor(theme.colorScheme.background, 0), - isModal: true, - defaultTextZoom: 65, - ), - ], - ), - ) - else - const Gap(20), - AnimatedCrossFade( - crossFadeState: areaActive.value - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - duration: const Duration(milliseconds: 200), - secondChild: const SizedBox(), - firstChild: Row( - children: [ - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.queue), - ).call, - child: IconButton.ghost( - icon: const Icon(SpotubeIcons.queue), - onPressed: playlistQueue.activeTrack != null - ? () { - openDrawer( - context: context, - barrierDismissible: true, - draggable: true, - barrierColor: Colors.black.withAlpha(100), - borderRadius: BorderRadius.circular(10), - transformBackdrop: false, - position: OverlayPosition.bottom, - surfaceBlur: context.theme.surfaceBlur, - surfaceOpacity: 0.7, - expands: true, - builder: (context) => Consumer( - builder: (context, ref, _) { - final playlist = ref.watch( - audioPlayerProvider, - ); - final playlistNotifier = - ref.read(audioPlayerProvider.notifier); - return ConstrainedBox( - constraints: BoxConstraints( - maxHeight: - MediaQuery.of(context).size.height * - 0.8, - ), - child: - PlayerQueue.fromAudioPlayerNotifier( - floating: false, - playlist: playlist, - notifier: playlistNotifier, - ), - ); - }, - ), - ); - } - : null, - ), - ), - const Flexible(child: PlayerControls(compact: true)), - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.exit_mini_player)) - .call, - child: IconButton.ghost( - icon: const Icon(SpotubeIcons.maximize), - onPressed: () async { - if (!kIsDesktop) return; - - try { - await windowManager - .setMinimumSize(const Size(300, 700)); - await windowManager.setAlwaysOnTop(false); - if (wasMaximized.value) { - await windowManager.maximize(); - } else { - await windowManager.setSize(prevSize); - } - await windowManager.setAlignment(Alignment.center); - if (!kIsLinux) { - await windowManager.setHasShadow(true); - } - await Future.delayed( - const Duration(milliseconds: 200)); - } finally { - if (context.mounted) { - context.navigateTo(const LyricsRoute()); - } - } - }, - ), - ), - ], - ), - ) - ], - ), - ), - ); - } -} diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart deleted file mode 100644 index 3f0d7d1b..00000000 --- a/lib/pages/lyrics/plain_lyrics.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/modules/lyrics/zoom_controls.dart'; -import 'package:spotube/components/shimmers/shimmer_lyrics.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/lyrics/synced.dart'; - -class PlainLyrics extends HookConsumerWidget { - final PaletteColor palette; - final bool? isModal; - final int defaultTextZoom; - const PlainLyrics({ - required this.palette, - this.isModal, - this.defaultTextZoom = 100, - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch(audioPlayerProvider); - final lyricsQuery = ref.watch(syncedLyricsProvider(playlist.activeTrack)); - final mediaQuery = MediaQuery.of(context); - final typography = Theme.of(context).typography; - - final textZoomLevel = useState(defaultTextZoom); - - return Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (isModal != true) ...[ - Center( - child: Text( - playlist.activeTrack?.name ?? "", - style: mediaQuery.mdAndUp - ? typography.h3 - : typography.h4.copyWith( - color: palette.titleTextColor, - ), - ), - ), - Center( - child: Text( - playlist.activeTrack?.artists.asString() ?? "", - style: (mediaQuery.mdAndUp ? typography.h4 : typography.large) - .copyWith( - color: palette.bodyTextColor, - ), - ), - ) - ], - Expanded( - child: SingleChildScrollView( - child: Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Builder( - builder: (context) { - if (lyricsQuery.isLoading || lyricsQuery.isRefreshing) { - return const ShimmerLyrics(); - } else if (lyricsQuery.hasError) { - return Container( - alignment: Alignment.center, - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.l10n.no_lyrics_available, - style: typography.large.copyWith( - color: palette.bodyTextColor, - ), - textAlign: TextAlign.center, - ), - const Gap(26), - const Icon(SpotubeIcons.noLyrics, size: 60), - ], - ), - ); - } - - final lyrics = - lyricsQuery.asData?.value.lyrics.mapIndexed((i, e) { - final next = lyricsQuery.asData?.value.lyrics - .elementAtOrNull(i + 1); - if (next != null && - e.time - next.time > - const Duration(milliseconds: 700)) { - return "${e.text}\n"; - } - - return e.text; - }).join("\n"); - - return AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 200), - style: TextStyle( - color: isModal == true - ? context.theme.colorScheme.foreground - : palette.bodyTextColor, - fontSize: 24 * textZoomLevel.value / 100, - height: textZoomLevel.value < 70 - ? 1.5 - : textZoomLevel.value > 150 - ? 1.7 - : 2, - ), - child: SelectableText( - lyrics == null && playlist.activeTrack == null - ? context.l10n.no_tracks_playing - : lyrics ?? "", - textAlign: TextAlign.center, - ), - ); - }, - ), - ), - ), - ), - ), - ], - ), - Align( - alignment: Alignment.bottomRight, - child: ZoomControls( - value: textZoomLevel.value, - onChanged: (value) => textZoomLevel.value = value, - min: 50, - max: 200, - ), - ), - ], - ); - } -} diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart deleted file mode 100644 index cb331724..00000000 --- a/lib/pages/lyrics/synced_lyrics.dart +++ /dev/null @@ -1,278 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/modules/lyrics/zoom_controls.dart'; -import 'package:spotube/components/shimmers/shimmer_lyrics.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; -import 'package:spotube/modules/lyrics/use_synced_lyrics.dart'; -import 'package:scroll_to_index/scroll_to_index.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/lyrics/synced.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/logger/logger.dart'; - -class SyncedLyrics extends HookConsumerWidget { - final PaletteColor palette; - final bool? isModal; - final int defaultTextZoom; - - const SyncedLyrics({ - required this.palette, - this.isModal, - this.defaultTextZoom = 100, - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.sizeOf(context); - final theme = Theme.of(context); - - final playlist = ref.watch(audioPlayerProvider); - - final controller = useAutoScrollController(); - - final delay = ref.watch(syncedLyricsDelayProvider); - - final timedLyricsQuery = - ref.watch(syncedLyricsProvider(playlist.activeTrack)); - - final lyricValue = timedLyricsQuery.asData?.value; - - final lyricsState = ref.watch( - syncedLyricsMapProvider(playlist.activeTrack), - ); - final currentTime = - useSyncedLyrics(ref, lyricsState.asData?.value.lyricsMap ?? {}, delay); - final textZoomLevel = useState(defaultTextZoom); - - final typography = Theme.of(context).typography; - - ref.listen( - audioPlayerProvider.select((s) => s.activeTrack), - (previous, next) { - controller.animateTo( - 0, - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOut, - ); - ref.read(syncedLyricsDelayProvider.notifier).state = 0; - }, - ); - - final headlineTextStyle = (mediaQuery.mdAndUp - ? typography.h3 - : typography.h4.copyWith(fontSize: 25)) - .copyWith( - color: palette.titleTextColor, - ); - - final bodyTextTheme = typography.large.copyWith( - color: palette.bodyTextColor, - ); - - useEffect(() { - StreamSubscription? subscription; - WidgetsBinding.instance.addPostFrameCallback((_) { - subscription = audioPlayer.positionStream.listen((event) { - try { - if (event > Duration.zero || !controller.hasClients) return; - controller.animateTo( - 0, - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOut, - ); - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }); - }); - - return subscription?.cancel; - }, [controller]); - - return Stack( - children: [ - CustomScrollView( - controller: controller, - slivers: [ - if (isModal != true) - SliverAppBar( - automaticallyImplyLeading: false, - backgroundColor: Colors.transparent, - centerTitle: true, - title: Text( - playlist.activeTrack?.name ?? context.l10n.not_playing, - style: headlineTextStyle, - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(40), - child: Text( - playlist.activeTrack?.artists.asString() ?? "", - style: - mediaQuery.mdAndUp ? typography.h4 : typography.x2Large, - ), - ), - ), - if (lyricValue != null && - lyricValue.lyrics.isNotEmpty && - lyricsState.asData?.value.static != true) - SliverList.builder( - itemCount: lyricValue.lyrics.length, - itemBuilder: (context, index) { - final lyricSlice = lyricValue.lyrics[index]; - final isActive = lyricSlice.time.inSeconds == currentTime; - - if (isActive) { - controller.scrollToIndex( - index, - preferPosition: AutoScrollPosition.middle, - ); - } - return AutoScrollTag( - key: ValueKey(index), - index: index, - controller: controller, - child: lyricSlice.text.isEmpty - ? Container( - padding: index == lyricValue.lyrics.length - 1 - ? EdgeInsets.only( - bottom: mediaQuery.height / 2, - ) - : null, - ) - : Center( - child: Padding( - padding: index == lyricValue.lyrics.length - 1 - ? const EdgeInsets.all(8.0).copyWith( - bottom: 100, - ) - : const EdgeInsets.all(8.0), - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 250), - style: TextStyle( - color: isActive - ? theme.colorScheme.foreground - : theme.colorScheme.mutedForeground, - fontWeight: isActive - ? FontWeight.w500 - : FontWeight.normal, - fontSize: (isActive ? 28 : 26) * - (textZoomLevel.value / 100), - ), - textAlign: TextAlign.center, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () async { - final time = Duration( - seconds: - lyricSlice.time.inSeconds - delay, - ); - if (time > audioPlayer.duration || - time.isNegative) { - return; - } - audioPlayer.seek(time); - }, - child: Text(lyricSlice.text), - ), - ), - ), - ), - ), - ); - }, - ), - if (playlist.activeTrack != null && - (timedLyricsQuery.isLoading || timedLyricsQuery.isRefreshing)) - const SliverToBoxAdapter(child: ShimmerLyrics()) - else if (playlist.activeTrack != null && - (timedLyricsQuery.hasError)) ...[ - SliverToBoxAdapter( - child: Container( - alignment: Alignment.center, - padding: const EdgeInsets.all(16), - child: Text( - context.l10n.no_lyrics_available, - style: bodyTextTheme, - textAlign: TextAlign.center, - ), - ), - ), - const SliverGap(26), - const SliverToBoxAdapter( - child: Icon(SpotubeIcons.noLyrics, size: 60), - ), - ] else if (lyricsState.asData?.value.static == true) - SliverFillRemaining( - child: Center( - child: RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: bodyTextTheme, - children: [ - TextSpan( - text: context.l10n.synced_lyrics_not_available, - ), - TextSpan( - text: " ${context.l10n.plain_lyrics} ", - style: typography.large.copyWith( - color: palette.bodyTextColor, - fontWeight: FontWeight.bold, - ), - ), - TextSpan(text: context.l10n.tab_instead), - ], - ), - ), - ), - ), - ], - ), - Align( - alignment: Alignment.bottomRight, - child: Builder(builder: (context) { - final actions = [ - ZoomControls( - value: delay, - onChanged: (value) => - ref.read(syncedLyricsDelayProvider.notifier).state = value, - interval: 1, - unit: "s", - increaseIcon: const Icon(SpotubeIcons.add), - decreaseIcon: const Icon(SpotubeIcons.remove), - direction: isModal == true ? Axis.horizontal : Axis.vertical, - ), - ZoomControls( - value: textZoomLevel.value, - onChanged: (value) => textZoomLevel.value = value, - min: 50, - max: 200, - ), - ]; - - return isModal == true - ? Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: actions, - ) - : Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: actions, - ); - }), - ), - ], - ); - } -} diff --git a/lib/pages/player/lyrics.dart b/lib/pages/player/lyrics.dart deleted file mode 100644 index 093b0aa2..00000000 --- a/lib/pages/player/lyrics.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:auto_route/annotations.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/button/back_button.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/utils/use_palette_color.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/pages/lyrics/plain_lyrics.dart'; -import 'package:spotube/pages/lyrics/synced_lyrics.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; - -@RoutePage() -class PlayerLyricsPage extends HookConsumerWidget { - const PlayerLyricsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch(audioPlayerProvider); - String albumArt = useMemoized( - () => (playlist.activeTrack?.album.images).asUrlString( - index: (playlist.activeTrack?.album.images.length ?? 1) - 1, - placeholder: ImagePlaceholder.albumArt, - ), - [playlist.activeTrack?.album.images], - ); - final selectedIndex = useState(0); - final palette = usePaletteColor(albumArt, ref); - - final tabbar = TabList( - index: selectedIndex.value, - onChanged: (index) => selectedIndex.value = index, - children: [ - TabItem( - child: Text(context.l10n.synced), - ), - TabItem( - child: Text(context.l10n.plain), - ), - ], - ); - - return Scaffold( - headers: [ - AppBar( - leading: [tabbar], - trailing: const [ - BackButton(icon: SpotubeIcons.angleDown), - ], - ), - ], - child: IndexedStack( - index: selectedIndex.value, - children: [ - SyncedLyrics(palette: palette, isModal: false), - PlainLyrics(palette: palette, isModal: false), - ], - ), - ); - } -} diff --git a/lib/pages/player/queue.dart b/lib/pages/player/queue.dart deleted file mode 100644 index 829db6eb..00000000 --- a/lib/pages/player/queue.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:auto_route/annotations.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/modules/player/player_queue.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; - -@RoutePage() -class PlayerQueuePage extends HookConsumerWidget { - const PlayerQueuePage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch( - audioPlayerProvider, - ); - final playlistNotifier = ref.read(audioPlayerProvider.notifier); - return Scaffold( - child: SafeArea( - bottom: false, - child: PlayerQueue.fromAudioPlayerNotifier( - floating: false, - playlist: playlist, - notifier: playlistNotifier, - ), - ), - ); - } -} diff --git a/lib/pages/player/sources.dart b/lib/pages/player/sources.dart deleted file mode 100644 index 8e370daf..00000000 --- a/lib/pages/player/sources.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/modules/player/sibling_tracks_sheet.dart'; - -@RoutePage() -class PlayerTrackSourcesPage extends StatelessWidget { - const PlayerTrackSourcesPage({super.key}); - - @override - Widget build(BuildContext context) { - return const Scaffold( - child: SiblingTracksSheet(floating: false), - ); - } -} diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart deleted file mode 100644 index 3897acef..00000000 --- a/lib/pages/playlist/liked_playlist.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/material.dart' as material; -import 'package:flutter/widgets.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/track_presentation/presentation_props.dart'; -import 'package:spotube/components/track_presentation/track_presentation.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/pages/playlist/playlist.dart'; -import 'package:spotube/provider/metadata_plugin/library/tracks.dart'; -import 'package:auto_route/auto_route.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; - -@RoutePage() -class LikedPlaylistPage extends HookConsumerWidget { - static const name = PlaylistPage.name; - - final SpotubeSimplePlaylistObject playlist; - const LikedPlaylistPage({ - super.key, - required this.playlist, - }); - - @override - Widget build(BuildContext context, ref) { - final likedTracks = ref.watch(metadataPluginSavedTracksProvider); - final likedTracksNotifier = - ref.watch(metadataPluginSavedTracksProvider.notifier); - final tracks = likedTracks.asData?.value.items ?? []; - - return material.RefreshIndicator.adaptive( - onRefresh: () async { - ref.invalidate(metadataPluginSavedTracksProvider); - }, - child: TrackPresentation( - options: TrackPresentationOptions( - collection: playlist, - image: Assets.images.likedTracks.path, - pagination: PaginationProps( - hasNextPage: likedTracks.asData?.value.hasMore ?? false, - isLoading: likedTracks.isLoadingNextPage && !likedTracks.isLoading, - onFetchMore: () async { - await likedTracksNotifier.fetchMore(); - }, - onFetchAll: () async { - return await likedTracksNotifier.fetchAll(); - }, - onRefresh: () async { - ref.invalidate(metadataPluginSavedTracksProvider); - }, - ), - title: playlist.name, - description: playlist.description, - tracks: tracks, - error: likedTracks.error, - routePath: '/playlist/${playlist.id}', - isLiked: false, - shareUrl: null, - onHeart: null, - owner: playlist.owner.name, - ), - ), - ); - } -} diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart deleted file mode 100644 index 4aca5945..00000000 --- a/lib/pages/playlist/playlist.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:flutter/material.dart' as material; -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart' hide Page; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/track_presentation/presentation_props.dart'; -import 'package:spotube/components/track_presentation/track_presentation.dart'; -import 'package:spotube/components/track_presentation/use_is_user_playlist.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/library/playlists.dart'; -import 'package:auto_route/auto_route.dart'; -import 'package:spotube/provider/metadata_plugin/tracks/playlist.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; - -@RoutePage() -class PlaylistPage extends HookConsumerWidget { - static const name = "playlist"; - - final SpotubeSimplePlaylistObject _playlist; - final String id; - const PlaylistPage({ - super.key, - @PathParam("id") required this.id, - required SpotubeSimplePlaylistObject playlist, - }) : _playlist = playlist; - - @override - Widget build(BuildContext context, ref) { - final playlist = ref - .watch( - metadataPluginSavedPlaylistsProvider.select( - (value) => value.whenData( - (value) => - value.items.firstWhereOrNull((s) => s.id == _playlist.id), - ), - ), - ) - .asData - ?.value ?? - _playlist; - - final tracks = ref.watch(metadataPluginPlaylistTracksProvider(playlist.id)); - final tracksNotifier = - ref.watch(metadataPluginPlaylistTracksProvider(playlist.id).notifier); - final isFavoritePlaylist = - ref.watch(metadataPluginIsSavedPlaylistProvider(playlist.id)); - - final favoritePlaylistsNotifier = - ref.watch(metadataPluginSavedPlaylistsProvider.notifier); - - final isUserPlaylist = useIsUserPlaylist(ref, playlist.id); - - return material.RefreshIndicator.adaptive( - onRefresh: () async { - ref.invalidate(metadataPluginPlaylistTracksProvider(playlist.id)); - ref.invalidate(metadataPluginSavedPlaylistsProvider); - ref.invalidate(metadataPluginIsSavedPlaylistProvider(playlist.id)); - }, - child: TrackPresentation( - options: TrackPresentationOptions( - collection: playlist, - image: playlist.images.asUrlString( - placeholder: ImagePlaceholder.collection, - ), - pagination: PaginationProps( - hasNextPage: tracks.asData?.value.hasMore ?? false, - isLoading: tracks.isLoading || tracks.isLoadingNextPage, - onFetchMore: tracksNotifier.fetchMore, - onRefresh: () async { - ref.invalidate(metadataPluginPlaylistTracksProvider(playlist.id)); - }, - onFetchAll: () async { - return await tracksNotifier.fetchAll(); - }, - ), - title: playlist.name, - description: playlist.description, - owner: playlist.owner.name, - ownerImage: playlist.owner.images.lastOrNull?.url, - tracks: tracks.asData?.value.items ?? [], - error: tracks.error, - routePath: '/playlist/${playlist.id}', - isLiked: isFavoritePlaylist.asData?.value ?? false, - shareUrl: playlist.externalUri, - onHeart: isFavoritePlaylist.asData?.value == null - ? null - : () async { - final confirmed = isUserPlaylist - ? await showPromptDialog( - context: context, - title: context.l10n.delete_playlist, - message: context.l10n.delete_playlist_confirmation, - ) - : true; - if (!confirmed) return null; - - if (isFavoritePlaylist.asData!.value) { - if (isUserPlaylist) { - await favoritePlaylistsNotifier.delete(playlist.id); - } else { - await favoritePlaylistsNotifier.removeFavorite(playlist); - } - } else { - await favoritePlaylistsNotifier.addFavorite(playlist); - } - return isUserPlaylist; - }, - ), - ), - ); - } -} diff --git a/lib/pages/profile/profile.dart b/lib/pages/profile/profile.dart deleted file mode 100644 index eb3dec2a..00000000 --- a/lib/pages/profile/profile.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:sliver_tools/sliver_tools.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/core/user.dart'; -import 'package:url_launcher/url_launcher_string.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class ProfilePage extends HookConsumerWidget { - static const name = "profile"; - - const ProfilePage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final me = ref.watch(metadataPluginUserProvider); - final meData = me.asData?.value ?? FakeData.user; - - // final userProperties = useMemoized( - // () => { - // context.l10n.email: meData.email ?? "N/A", - // context.l10n.profile_followers: - // meData.followers?.total.toString() ?? "N/A", - // context.l10n.birthday: meData.birthdate ?? context.l10n.not_born, - // context.l10n.country: markets - // .firstWhere((market) => market.$1 == meData.country) - // .$2, - // context.l10n.subscription: meData.product ?? context.l10n.hacker, - // }, - // [meData], - // ); - - return SafeArea( - child: Scaffold( - headers: [ - TitleBar( - title: Text(context.l10n.profile), - ) - ], - child: Skeletonizer( - enabled: me.isLoading, - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(600), - child: UniversalImage( - path: meData.images.asUrlString( - index: 1, - placeholder: ImagePlaceholder.artist, - ), - width: 300, - height: 300, - fit: BoxFit.cover, - ), - ), - ], - ), - ), - const SliverGap(10), - SliverToBoxAdapter( - child: Text( - meData.name, - textAlign: TextAlign.center, - ).h4(), - ), - const SliverGap(20), - SliverCrossAxisConstrained( - maxCrossAxisExtent: 500, - child: SliverToBoxAdapter( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Button.text( - leading: const Icon(SpotubeIcons.edit), - onPressed: () { - launchUrlString( - meData.externalUri, - mode: LaunchMode.externalApplication, - ); - }, - child: Text(context.l10n.edit), - ), - ], - ), - ), - ), - // SliverCrossAxisConstrained( - // maxCrossAxisExtent: 500, - // child: SliverToBoxAdapter( - // child: Card( - // child: Padding( - // padding: const EdgeInsets.all(8.0), - // child: Table( - // columnWidths: const { - // 0: FixedTableSize(120), - // }, - // defaultRowHeight: const FixedTableSize(40), - // rows: [ - // for (final MapEntry(:key, :value) - // in userProperties.entries) - // TableRow( - // cells: [ - // TableCell( - // child: Padding( - // padding: const EdgeInsets.all(6), - // child: Text(key).large(), - // ), - // ), - // TableCell( - // child: Padding( - // padding: const EdgeInsets.all(6), - // child: Text(value), - // ), - // ), - // ], - // ) - // ], - // ), - // ), - // ), - // ), - // ), - const SliverGap(200), - ], - ), - ), - ), - ); - } -} diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart deleted file mode 100644 index 44b8416f..00000000 --- a/lib/pages/root/root_app.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/hooks/configurators/use_check_yt_dlp_installed.dart'; -import 'package:spotube/modules/root/bottom_player.dart'; -import 'package:spotube/modules/root/sidebar/sidebar.dart'; -import 'package:spotube/modules/root/spotube_navigation_bar.dart'; -import 'package:spotube/hooks/configurators/use_endless_playback.dart'; -import 'package:spotube/modules/root/use_global_subscriptions.dart'; -import 'package:spotube/provider/glance/glance.dart'; - -@RoutePage() -class RootAppPage extends HookConsumerWidget { - const RootAppPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final backgroundColor = Theme.of(context).colorScheme.background; - final brightness = Theme.of(context).brightness; - - ref.listen(glanceProvider, (_, __) {}); - - useGlobalSubscriptions(ref); - useEndlessPlayback(ref); - useCheckYtDlpInstalled(ref); - - useEffect(() { - SystemChrome.setSystemUIOverlayStyle( - SystemUiOverlayStyle( - statusBarColor: backgroundColor, // status bar color - statusBarIconBrightness: brightness == Brightness.dark - ? Brightness.light - : Brightness.dark, - ), - ); - return null; - }, [backgroundColor, brightness]); - - final scaffold = MediaQuery.removeViewInsets( - context: context, - removeBottom: true, - child: SafeArea( - top: false, - child: Scaffold( - footers: const [ - BottomPlayer(), - SpotubeNavigationBar(), - ], - floatingFooter: true, - child: Sidebar( - child: MediaQuery( - data: MediaQuery.of(context).copyWith( - padding: MediaQuery.paddingOf(context) - .copyWith(bottom: 100 * context.theme.scaling), - ), - child: const AutoRouter(), - ), - ), - ), - ), - ); - - return scaffold; - } -} diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart deleted file mode 100644 index da5cc0e2..00000000 --- a/lib/pages/search/search.dart +++ /dev/null @@ -1,233 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/routes.gr.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/fallbacks/error_box.dart'; -import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/string.dart'; -import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; -import 'package:spotube/pages/search/tabs/albums.dart'; -import 'package:spotube/pages/search/tabs/all.dart'; -import 'package:spotube/pages/search/tabs/artists.dart'; -import 'package:spotube/pages/search/tabs/playlists.dart'; -import 'package:spotube/pages/search/tabs/tracks.dart'; -import 'package:spotube/provider/metadata_plugin/search/all.dart'; -import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:auto_route/auto_route.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; - -final searchTermStateProvider = StateProvider((ref) { - return ""; -}); - -@RoutePage() -class SearchPage extends HookConsumerWidget { - static const name = "search"; - - const SearchPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final controller = useShadcnTextEditingController(); - final focusNode = useFocusNode(); - - final searchTerm = ref.watch(searchTermStateProvider); - final searchChipSnapshot = ref.watch(metadataPluginSearchChipsProvider); - final selectedChip = useState( - searchChipSnapshot.asData?.value.first ?? "all", - ); - - ref.listen( - metadataPluginSearchChipsProvider, - (previous, next) { - selectedChip.value = next.asData?.value.first ?? "all"; - }, - ); - - useEffect(() { - controller.text = searchTerm; - - return null; - }, []); - - void onSubmitted(String value) { - ref.read(searchTermStateProvider.notifier).state = value; - focusNode.unfocus(); - if (value.trim().isEmpty) { - return; - } - KVStoreService.setRecentSearches( - { - value, - ...KVStoreService.recentSearches, - }.toList(), - ); - } - - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) { - context.navigateTo(const HomeRoute()); - }, - child: SafeArea( - bottom: false, - child: Scaffold( - headers: [ - if (kTitlebarVisible) - const TitleBar(automaticallyImplyLeading: false, height: 30) - ], - child: Builder(builder: (context) { - if (searchChipSnapshot.error - case MetadataPluginException( - errorCode: MetadataPluginErrorCode.noDefaultMetadataPlugin, - message: _ - )) { - return const NoDefaultMetadataPlugin(); - } - - if (searchChipSnapshot.hasError) { - return ErrorBox( - error: searchChipSnapshot.error!, - onRetry: () { - ref.invalidate(metadataPluginSearchChipsProvider); - }, - ); - } - - return Column( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 10, - ), - child: ListenableBuilder( - listenable: controller, - builder: (context, _) { - final suggestions = controller.text.isEmpty - ? KVStoreService.recentSearches - : KVStoreService.recentSearches - .where( - (s) => - weightedRatio( - s.toLowerCase(), - controller.text.toLowerCase(), - ) > - 50, - ) - .toList(); - - return AutoComplete( - suggestions: suggestions.length <= 2 - ? [ - ...suggestions, - "Twenty One Pilots", - "Linkin Park", - ] - : suggestions, - completer: (suggestion) => suggestion, - mode: AutoCompleteMode.replaceAll, - child: TextField( - autofocus: true, - controller: controller, - focusNode: focusNode, - features: [ - const InputFeature.leading( - Icon(SpotubeIcons.search), - ), - InputFeature.trailing( - AnimatedCrossFade( - duration: - const Duration(milliseconds: 300), - crossFadeState: - controller.text.isNotEmpty - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: IconButton.ghost( - size: ButtonSize.small, - icon: const Icon(SpotubeIcons.close), - onPressed: () { - controller.clear(); - }, - ), - secondChild: const SizedBox.square( - dimension: 28), - ), - ) - ], - textInputAction: TextInputAction.search, - placeholder: Text(context.l10n.search), - onSubmitted: onSubmitted, - ), - ); - }), - ), - ), - ], - ), - Row( - spacing: 8, - children: [ - const Gap(12), - if (searchChipSnapshot.asData?.value != null) - for (final chip in searchChipSnapshot.asData!.value) - Chip( - style: selectedChip.value == chip - ? ButtonVariance.primary.copyWith( - decoration: (context, states, value) { - return ButtonVariance.primary - .decoration(context, states) - .copyWithIfBoxDecoration( - borderRadius: - BorderRadius.circular(100), - ); - }, - ) - : ButtonVariance.secondary.copyWith( - decoration: (context, states, value) { - return ButtonVariance.secondary - .decoration(context, states) - .copyWithIfBoxDecoration( - borderRadius: - BorderRadius.circular(100), - ); - }, - ), - child: Text(chip.capitalize()), - onPressed: () { - selectedChip.value = chip; - }, - ), - ], - ), - Expanded( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: switch (selectedChip.value) { - "tracks" => const SearchPageTracksTab(), - "albums" => const SearchPageAlbumsTab(), - "artists" => const SearchPageArtistsTab(), - "playlists" => const SearchPagePlaylistsTab(), - _ => const SearchPageAllTab(), - }, - ), - ), - ], - ); - }), - ), - ), - ); - } -} diff --git a/lib/pages/search/tabs/albums.dart b/lib/pages/search/tabs/albums.dart deleted file mode 100644 index e27772c6..00000000 --- a/lib/pages/search/tabs/albums.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/components/fallbacks/error_box.dart'; -import 'package:spotube/components/playbutton_view/playbutton_view.dart'; -import 'package:spotube/modules/album/album_card.dart'; -import 'package:spotube/modules/search/loading.dart'; -import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/provider/metadata_plugin/search/albums.dart'; - -class SearchPageAlbumsTab extends HookConsumerWidget { - const SearchPageAlbumsTab({super.key}); - - @override - Widget build(BuildContext context, ref) { - final controller = useScrollController(); - - final searchTerm = ref.watch(searchTermStateProvider); - final searchAlbumsSnapshot = - ref.watch(metadataPluginSearchAlbumsProvider(searchTerm)); - final searchAlbumsNotifier = - ref.read(metadataPluginSearchAlbumsProvider(searchTerm).notifier); - final searchAlbums = - searchAlbumsSnapshot.asData?.value.items ?? [FakeData.albumSimple]; - - if (searchAlbumsSnapshot.hasError) { - return ErrorBox( - error: searchAlbumsSnapshot.error!, - onRetry: () { - ref.invalidate(metadataPluginSearchAlbumsProvider(searchTerm)); - }, - ); - } - - return SearchPlaceholder( - snapshot: searchAlbumsSnapshot, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: CustomScrollView( - slivers: [ - PlaybuttonView( - controller: controller, - itemCount: searchAlbums.length, - hasMore: searchAlbumsSnapshot.asData?.value.hasMore == true, - isLoading: searchAlbumsSnapshot.isLoading, - onRequestMore: searchAlbumsNotifier.fetchMore, - gridItemBuilder: (context, index) => - AlbumCard(searchAlbums[index]), - listItemBuilder: (context, index) => - AlbumCard.tile(searchAlbums[index]), - ), - ], - ), - ), - ); - } -} diff --git a/lib/pages/search/tabs/all.dart b/lib/pages/search/tabs/all.dart deleted file mode 100644 index 306bdfce..00000000 --- a/lib/pages/search/tabs/all.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/fallbacks/error_box.dart'; -import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/modules/search/loading.dart'; -import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/modules/search/sections/albums.dart'; -import 'package:spotube/modules/search/sections/artists.dart'; -import 'package:spotube/modules/search/sections/playlists.dart'; -import 'package:spotube/modules/search/sections/tracks.dart'; -import 'package:spotube/provider/metadata_plugin/search/all.dart'; - -class SearchPageAllTab extends HookConsumerWidget { - const SearchPageAllTab({super.key}); - - @override - Widget build(BuildContext context, ref) { - final scrollController = ScrollController(); - final searchTerm = ref.watch(searchTermStateProvider); - final searchSnapshot = - ref.watch(metadataPluginSearchAllProvider(searchTerm)); - - if (searchSnapshot.hasError) { - return ErrorBox( - error: searchSnapshot.error!, - onRetry: () { - ref.invalidate(metadataPluginSearchAllProvider(searchTerm)); - }, - ); - } - - return SearchPlaceholder( - snapshot: searchSnapshot, - child: InterScrollbar( - controller: scrollController, - child: SingleChildScrollView( - controller: scrollController, - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SearchTracksSection(), - SearchPlaylistsSection(), - Gap(20), - SearchArtistsSection(), - Gap(20), - SearchAlbumsSection(), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/search/tabs/artists.dart b/lib/pages/search/tabs/artists.dart deleted file mode 100644 index 8cea7b58..00000000 --- a/lib/pages/search/tabs/artists.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_undraw/flutter_undraw.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/components/fallbacks/error_box.dart'; -import 'package:spotube/components/waypoint.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/modules/artist/artist_card.dart'; -import 'package:spotube/modules/search/loading.dart'; -import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/provider/metadata_plugin/search/artists.dart'; - -class SearchPageArtistsTab extends HookConsumerWidget { - const SearchPageArtistsTab({super.key}); - - @override - Widget build(BuildContext context, ref) { - final controller = useScrollController(); - - final searchTerm = ref.watch(searchTermStateProvider); - final searchArtistsSnapshot = - ref.watch(metadataPluginSearchArtistsProvider(searchTerm)); - final searchArtistsNotifier = - ref.read(metadataPluginSearchArtistsProvider(searchTerm).notifier); - final searchArtists = searchArtistsSnapshot.asData?.value.items ?? []; - - if (searchArtistsSnapshot.hasError) { - return ErrorBox( - error: searchArtistsSnapshot.error!, - onRetry: () { - ref.invalidate(metadataPluginSearchArtistsProvider(searchTerm)); - }, - ); - } - - return SearchPlaceholder( - snapshot: searchArtistsSnapshot, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: LayoutBuilder(builder: (context, constrains) { - if (searchArtistsSnapshot.hasValue && searchArtists.isEmpty) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 10, - children: [ - Undraw( - height: 200 * context.theme.scaling, - illustration: UndrawIllustration.taken, - color: Theme.of(context).colorScheme.primary, - ), - Text( - context.l10n.nothing_found, - textAlign: TextAlign.center, - ).muted().small() - ], - ), - ); - } - - return GridView.builder( - padding: const EdgeInsets.all(16), - itemCount: searchArtists.length + 1, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: constrains.smAndDown ? 225 : 250, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemBuilder: (context, index) { - if (searchArtists.isNotEmpty && index == searchArtists.length) { - if (searchArtistsSnapshot.asData?.value.hasMore != true) { - return const SizedBox.shrink(); - } - - return Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: searchArtistsNotifier.fetchMore, - child: Skeletonizer( - enabled: true, - child: ArtistCard(FakeData.artist), - ), - ); - } - - return Skeletonizer( - enabled: searchArtistsSnapshot.isLoading, - child: ArtistCard( - searchArtists.elementAtOrNull(index) ?? FakeData.artist, - ), - ); - }, - ); - }), - ), - ); - } -} diff --git a/lib/pages/search/tabs/playlists.dart b/lib/pages/search/tabs/playlists.dart deleted file mode 100644 index f00153cb..00000000 --- a/lib/pages/search/tabs/playlists.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/components/fallbacks/error_box.dart'; -import 'package:spotube/components/playbutton_view/playbutton_view.dart'; -import 'package:spotube/modules/playlist/playlist_card.dart'; -import 'package:spotube/modules/search/loading.dart'; -import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/provider/metadata_plugin/search/playlists.dart'; - -class SearchPagePlaylistsTab extends HookConsumerWidget { - const SearchPagePlaylistsTab({super.key}); - - @override - Widget build(BuildContext context, ref) { - final controller = useScrollController(); - - final searchTerm = ref.watch(searchTermStateProvider); - final searchPlaylistsSnapshot = - ref.watch(metadataPluginSearchPlaylistsProvider(searchTerm)); - final searchPlaylistsNotifier = - ref.read(metadataPluginSearchPlaylistsProvider(searchTerm).notifier); - final searchPlaylists = searchPlaylistsSnapshot.asData?.value.items ?? - [FakeData.playlistSimple]; - - if (searchPlaylistsSnapshot.hasError) { - return ErrorBox( - error: searchPlaylistsSnapshot.error!, - onRetry: () { - ref.invalidate(metadataPluginSearchPlaylistsProvider(searchTerm)); - }, - ); - } - - return SearchPlaceholder( - snapshot: searchPlaylistsSnapshot, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: CustomScrollView( - slivers: [ - PlaybuttonView( - controller: controller, - itemCount: searchPlaylists.length, - hasMore: searchPlaylistsSnapshot.asData?.value.hasMore == true, - isLoading: searchPlaylistsSnapshot.isLoading, - onRequestMore: searchPlaylistsNotifier.fetchMore, - gridItemBuilder: (context, index) => - PlaylistCard(searchPlaylists[index]), - listItemBuilder: (context, index) => - PlaylistCard.tile(searchPlaylists[index]), - ), - ], - ), - ), - ); - } -} diff --git a/lib/pages/search/tabs/tracks.dart b/lib/pages/search/tabs/tracks.dart deleted file mode 100644 index e4c56891..00000000 --- a/lib/pages/search/tabs/tracks.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/components/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/fallbacks/error_box.dart'; -import 'package:spotube/components/track_tile/track_tile.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/modules/search/loading.dart'; -import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/metadata_plugin/search/tracks.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; - -class SearchPageTracksTab extends HookConsumerWidget { - const SearchPageTracksTab({super.key}); - - @override - Widget build(BuildContext context, ref) { - final searchTerm = ref.watch(searchTermStateProvider); - final searchTracksSnapshot = - ref.watch(metadataPluginSearchTracksProvider(searchTerm)); - final searchTracksNotifier = - ref.read(metadataPluginSearchTracksProvider(searchTerm).notifier); - final searchTracks = - searchTracksSnapshot.asData?.value.items ?? [FakeData.track]; - - final playlist = ref.watch(audioPlayerProvider); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - - if (searchTracksSnapshot.hasError) { - return ErrorBox( - error: searchTracksSnapshot.error!, - onRetry: () { - ref.invalidate(metadataPluginSearchTracksProvider(searchTerm)); - }, - ); - } - - return SearchPlaceholder( - snapshot: searchTracksSnapshot, - child: InfiniteList( - itemCount: searchTracksSnapshot.asData?.value.items.length ?? 0, - hasReachedMax: searchTracksSnapshot.asData?.value.hasMore != true, - isLoading: searchTracksSnapshot.isLoading && - !searchTracksSnapshot.isLoadingNextPage, - loadingBuilder: (context) { - return Skeletonizer( - enabled: true, - child: TrackTile(track: FakeData.track, playlist: playlist), - ); - }, - onFetchData: () { - searchTracksNotifier.fetchMore(); - }, - itemBuilder: (context, index) { - final track = searchTracks[index]; - - return TrackTile( - track: track, - playlist: playlist, - index: index, - onTap: () async { - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - - if (isRemoteDevice == null) return; - - if (isRemoteDevice) { - final remotePlayback = ref.read(connectProvider.notifier); - final remotePlaylist = ref.read(queueProvider); - - final isTrackPlaying = - remotePlaylist.activeTrack?.id == track.id; - - if (!isTrackPlaying && context.mounted) { - final shouldPlay = (playlist.tracks.length) > 20 - ? await showPromptDialog( - context: context, - title: context.l10n.playing_track( - track.name, - ), - message: context.l10n.queue_clear_alert( - playlist.tracks.length, - ), - ) - : true; - - if (shouldPlay) { - await remotePlayback.load( - WebSocketLoadEventData.playlist( - tracks: [track], - ), - ); - } - } - } else { - final isTrackPlaying = playlist.activeTrack?.id == track.id; - if (!isTrackPlaying && context.mounted) { - final shouldPlay = (playlist.tracks.length) > 20 - ? await showPromptDialog( - context: context, - title: context.l10n.playing_track( - track.name, - ), - message: context.l10n.queue_clear_alert( - playlist.tracks.length, - ), - ) - : true; - - if (shouldPlay) { - await playlistNotifier.load( - [track], - autoPlay: true, - ); - } - } - } - }, - ); - }, - ), - ); - } -} diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart deleted file mode 100644 index 5a95c0eb..00000000 --- a/lib/pages/settings/about.dart +++ /dev/null @@ -1,211 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/env.dart'; -import 'package:spotube/components/button/back_button.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/links/hyper_link.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/controllers/use_package_info.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:auto_route/auto_route.dart'; - -final _licenseProvider = FutureProvider((ref) async { - return await rootBundle.loadString("LICENSE"); -}); - -@RoutePage() -class AboutSpotubePage extends HookConsumerWidget { - static const name = "about"; - - const AboutSpotubePage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final packageInfo = usePackageInfo(); - final license = ref.watch(_licenseProvider); - final theme = Theme.of(context); - - const colon = TableCell(child: Text(":")); - - return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - TitleBar( - leading: const [BackButton()], - title: Text(context.l10n.about_spotube), - ) - ], - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - children: [ - Assets.branding.spotubeLogoPng.image( - height: 200, - width: 200, - ), - Center( - child: Column( - children: [ - Text(context.l10n.spotube_description).semiBold().large(), - const SizedBox(height: 20), - Table( - columnWidths: const { - 0: FixedTableSize(95), - 1: FixedTableSize(10), - 2: IntrinsicTableSize(), - }, - defaultRowHeight: const FixedTableSize(40), - rows: [ - TableRow( - cells: [ - TableCell(child: Text(context.l10n.founder)), - colon, - TableCell( - child: Hyperlink( - context.l10n.kingkor_roy_tirtho, - "https://github.com/KRTirtho", - ), - ) - ], - ), - TableRow( - cells: [ - TableCell(child: Text(context.l10n.version)), - colon, - TableCell(child: Text("v${packageInfo.version}")) - ], - ), - TableRow( - cells: [ - TableCell(child: Text(context.l10n.channel)), - colon, - TableCell(child: Text(Env.releaseChannel.name)) - ], - ), - TableRow( - cells: [ - TableCell(child: Text(context.l10n.build_number)), - colon, - TableCell( - child: Text(packageInfo.buildNumber - .replaceAll(".", " ")), - ) - ], - ), - const TableRow( - cells: [ - TableCell(child: Text("Website")), - colon, - TableCell( - child: Hyperlink( - "spotube.krtirtho.dev", - "https://spotube.krtirtho.dev", - ), - ), - ], - ), - TableRow( - cells: [ - TableCell(child: Text(context.l10n.repository)), - colon, - const TableCell( - child: Hyperlink( - "github.com/KRTirtho/spotube", - "https://github.com/KRTirtho/spotube", - ), - ), - ], - ), - TableRow( - cells: [ - TableCell(child: Text(context.l10n.license)), - colon, - const TableCell( - child: Hyperlink( - "BSD-4-Clause", - "https://raw.githubusercontent.com/KRTirtho/spotube/master/LICENSE", - ), - ), - ], - ), - TableRow( - cells: [ - TableCell(child: Text(context.l10n.bug_issues)), - colon, - const TableCell( - child: Hyperlink( - "Discord#chat", - "https://discord.gg/uJ94vxB6vg", - ), - ), - ], - ), - ], - ), - ], - ), - ), - const SizedBox(height: 20), - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => launchUrl( - Uri.parse("https://discord.gg/uJ94vxB6vg"), - mode: LaunchMode.externalApplication, - ), - child: const UniversalImage( - path: - "https://discord.com/api/guilds/1012234096237350943/widget.png?style=banner2", - ), - ), - ), - const SizedBox(height: 20), - Text( - context.l10n.made_with, - textAlign: TextAlign.center, - style: theme.typography.small, - ), - Text( - context.l10n.copyright(DateTime.now().year), - textAlign: TextAlign.center, - style: theme.typography.small, - ), - const SizedBox(height: 20), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 750), - child: SafeArea( - child: license.when( - data: (data) { - return Text( - data, - style: theme.typography.small, - ); - }, - loading: () { - return const Center( - child: CircularProgressIndicator(), - ); - }, - error: (e, s) { - return Text( - e.toString(), - style: theme.typography.small, - ); - }, - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart deleted file mode 100644 index 2af899f3..00000000 --- a/lib/pages/settings/blacklist.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:collection/collection.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/button/back_button.dart'; -import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/components/ui/button_tile.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class BlackListPage extends HookConsumerWidget { - static const name = "blacklist"; - - const BlackListPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final controller = useScrollController(); - final blacklist = ref.watch(blacklistProvider); - final searchText = useState(""); - - final filteredBlacklist = useMemoized( - () { - if (searchText.value.isEmpty) { - return blacklist.asData?.value ?? []; - } - return blacklist.asData?.value - .map( - (e) => ( - weightedRatio( - "${e.name} ${e.elementType.name}", searchText.value), - e, - ), - ) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList() ?? - []; - }, - [blacklist, searchText.value], - ); - - return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - TitleBar( - title: Text(context.l10n.blacklist), - leading: const [BackButton()], - ) - ], - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - onChanged: (value) => searchText.value = value, - placeholder: Text(context.l10n.search), - // prefixIcon: const Icon(SpotubeIcons.search), - ), - ), - InterScrollbar( - controller: controller, - child: ListView.builder( - controller: controller, - shrinkWrap: true, - itemCount: filteredBlacklist.length, - itemBuilder: (context, index) { - final item = filteredBlacklist.elementAt(index); - return ButtonTile( - style: ButtonVariance.ghost, - leading: Text("${index + 1}."), - title: Text("${item.name} (${item.elementType.name})"), - subtitle: Text(item.elementId), - trailing: IconButton.ghost( - icon: Icon(SpotubeIcons.trash, color: Colors.red[400]), - onPressed: () { - ref.read(blacklistProvider.notifier).remove( - filteredBlacklist.elementAt(index).elementId); - }, - ), - ); - }, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart deleted file mode 100644 index 61269456..00000000 --- a/lib/pages/settings/logs.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_undraw/flutter_undraw.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/button/back_button.dart'; -import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/logs/logs_provider.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class LogsPage extends HookConsumerWidget { - static const name = "logs"; - - const LogsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final controller = useScrollController(); - - final logsQuery = ref.watch(logsProvider); - - return Scaffold( - headers: [ - SafeArea( - bottom: false, - child: TitleBar( - title: Text(context.l10n.logs), - leading: const [BackButton()], - trailing: [ - IconButton.ghost( - icon: const Icon(SpotubeIcons.clipboard, size: 16), - onPressed: () async { - final logsSnapshot = await ref.read(logsProvider.future); - - await Clipboard.setData(ClipboardData(text: logsSnapshot)); - if (context.mounted) { - showToast( - context: context, - location: ToastLocation.topRight, - builder: (context, overlay) { - return SurfaceCard( - child: Basic( - title: Text(context.l10n.copied_to_clipboard("")), - ), - ); - }, - ); - } - }, - ), - IconButton.ghost( - icon: const Icon( - SpotubeIcons.trash, - size: 16, - ), - onPressed: () async { - ref.invalidate(logsProvider); - - final logsFile = await AppLogger.getLogsPath(); - - await logsFile.writeAsString(""); - }, - ) - ], - ), - ) - ], - child: SafeArea( - child: switch (logsQuery) { - AsyncData(:final value) => InterScrollbar( - controller: controller, - child: SingleChildScrollView( - padding: const EdgeInsets.all(8.0), - controller: controller, - child: Card(child: SelectableText(value)), - ), - ), - AsyncError(:final error) => switch (error) { - StateError() => Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Undraw( - illustration: UndrawIllustration.noData, - height: 200 * context.theme.scaling, - width: 200 * context.theme.scaling, - color: context.theme.colorScheme.primary, - ), - Text(context.l10n.no_logs_found).muted().small(), - ], - ), - _ => Center(child: Text(error.toString())), - }, - _ => const Center(child: CircularProgressIndicator()), - }, - ), - ); - } -} diff --git a/lib/pages/settings/metadata/metadata_form.dart b/lib/pages/settings/metadata/metadata_form.dart deleted file mode 100644 index b0aeb8bb..00000000 --- a/lib/pages/settings/metadata/metadata_form.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:form_builder_validators/form_builder_validators.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/components/markdown/markdown.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -@RoutePage() -class SettingsMetadataProviderFormPage extends HookConsumerWidget { - final String title; - final List fields; - const SettingsMetadataProviderFormPage({ - super.key, - required this.title, - required this.fields, - }); - - @override - Widget build(BuildContext context, ref) { - final formKey = useMemoized(() => GlobalKey(), []); - - return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - TitleBar( - title: Text(title), - ), - ], - child: FormBuilder( - key: formKey, - child: Center( - child: Container( - padding: const EdgeInsets.all(16), - constraints: const BoxConstraints(maxWidth: 600), - child: CustomScrollView( - shrinkWrap: true, - slivers: [ - SliverToBoxAdapter( - child: Text( - title, - textAlign: TextAlign.center, - style: context.theme.typography.h2, - ), - ), - const SliverGap(24), - SliverList.separated( - itemCount: fields.length, - separatorBuilder: (context, index) => const Gap(12), - itemBuilder: (context, index) { - if (fields[index] is MetadataFormFieldTextObject) { - final field = - fields[index] as MetadataFormFieldTextObject; - return AppMarkdown(data: field.text); - } - - final field = - fields[index] as MetadataFormFieldInputObject; - return FormBuilderField( - name: field.id, - initialValue: field.defaultValue, - validator: FormBuilderValidators.compose([ - if (field.required == true) - FormBuilderValidators.required( - errorText: 'This field is required', - ), - if (field.regex != null) - FormBuilderValidators.match( - RegExp(field.regex!), - errorText: - context.l10n.input_does_not_match_format, - ), - ]), - builder: (formField) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, - children: [ - TextField( - placeholder: field.placeholder == null - ? null - : Text(field.placeholder!), - initialValue: formField.value, - onChanged: (value) { - formField.didChange(value); - }, - obscureText: - field.variant == FormFieldVariant.password, - keyboardType: - field.variant == FormFieldVariant.number - ? TextInputType.number - : TextInputType.text, - features: [ - if (field.variant == - FormFieldVariant.password) - const InputFeature.passwordToggle(), - ], - ), - if (formField.hasError) - Text( - formField.errorText ?? '', - style: const TextStyle( - color: Colors.red, fontSize: 12), - ), - ], - ); - }, - ); - }, - ), - const SliverGap(24), - SliverToBoxAdapter( - child: Button.primary( - onPressed: () { - if (formKey.currentState?.saveAndValidate() != true) { - return; - } - - final data = formKey.currentState!.value.entries - .map((e) => { - "id": e.key, - "value": e.value, - }) - .toList(); - - context.router.maybePop(data); - }, - child: Text(context.l10n.submit), - ), - ), - const SliverGap(200) - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/settings/metadata_plugins.dart b/lib/pages/settings/metadata_plugins.dart deleted file mode 100644 index d4cb1ecf..00000000 --- a/lib/pages/settings/metadata_plugins.dart +++ /dev/null @@ -1,358 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:collection/collection.dart'; -import 'package:file_selector/file_selector.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:form_builder_validators/form_builder_validators.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/form/text_form_field.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/modules/metadata_plugins/installed_plugin.dart'; -import 'package:spotube/modules/metadata_plugins/plugin_repository.dart'; -import 'package:spotube/provider/metadata_plugin/core/repositories.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; -import 'package:sliver_tools/sliver_tools.dart'; - -@RoutePage() -class SettingsMetadataProviderPage extends HookConsumerWidget { - const SettingsMetadataProviderPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final tabState = useState(0); - final formKey = useMemoized(() => GlobalKey(), []); - - final plugins = ref.watch(metadataPluginsProvider); - final pluginsNotifier = ref.watch(metadataPluginsProvider.notifier); - - final pluginReposSnapshot = ref.watch(metadataPluginRepositoriesProvider); - final pluginReposNotifier = - ref.watch(metadataPluginRepositoriesProvider.notifier); - - final pluginRepos = useMemoized( - () { - final installedPluginIds = plugins.asData?.value.plugins - .map((e) => e.repository) - .nonNulls - .toList() ?? - []; - - final pluginRepos = pluginReposSnapshot.asData?.value.items ?? []; - if (installedPluginIds.isEmpty) return pluginRepos; - final availablePlugins = pluginRepos - .whereNot((repo) => installedPluginIds.contains(repo.repoUrl)) - .toList(); - - if (tabState.value != 0) { - // metadata only plugins - return availablePlugins.where( - (d) { - return d.topics.contains( - tabState.value == 1 - ? "spotube-metadata-plugin" - : "spotube-audio-source-plugin", - ); - }, - ).toList(); - } - - return availablePlugins; // all plugins - }, - [ - plugins.asData?.value.plugins, - pluginReposSnapshot.asData?.value, - tabState.value, - ], - ); - - final installedPlugins = useMemoized?>(() { - if (tabState.value == 0) return plugins.asData?.value.plugins; - - return plugins.asData?.value.plugins.where((d) { - return d.abilities.contains( - tabState.value == 1 - ? PluginAbilities.metadata - : PluginAbilities.audioSource, - ); - }).toList(); - }, [tabState.value, plugins.asData?.value]); - - return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - TitleBar( - title: Text(context.l10n.plugins), - ) - ], - child: Padding( - padding: const EdgeInsets.all(8), - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Row( - spacing: 8, - children: [ - Expanded( - child: FormBuilder( - key: formKey, - child: TextFormBuilderField( - name: "plugin_url", - validator: FormBuilderValidators.url( - protocols: ["http", "https"]), - placeholder: - Text(context.l10n.paste_plugin_download_url), - ), - ), - ), - HookBuilder(builder: (context) { - final isLoading = useState(false); - - return Tooltip( - tooltip: TooltipContainer( - child: Text(context - .l10n.download_and_install_plugin_from_url), - ).call, - child: IconButton.secondary( - icon: isLoading.value - ? const SizedBox.square( - dimension: 22, - child: - CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(SpotubeIcons.download), - enabled: !isLoading.value, - onPressed: () async { - try { - if (formKey.currentState?.saveAndValidate() ?? - false) { - final url = formKey.currentState - ?.fields["plugin_url"]?.value as String; - - if (url.isNotEmpty) { - isLoading.value = true; - final pluginConfig = await pluginsNotifier - .downloadAndCachePlugin(url); - - await pluginsNotifier.addPlugin(pluginConfig); - - formKey.currentState?.fields["plugin_url"] - ?.reset(); - } - } - } catch (e, stackTrace) { - AppLogger.reportError(e, stackTrace); - if (context.mounted) { - showToast( - showDuration: const Duration(seconds: 5), - context: context, - builder: (context, overlay) { - return SurfaceCard( - child: Basic( - leading: const Icon( - SpotubeIcons.error, - color: Colors.red, - ), - title: Text( - context.l10n - .failed_to_add_plugin_error( - e.toString()), - ), - ), - ); - }, - ); - } - } finally { - isLoading.value = false; - } - }, - ), - ); - }), - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.upload_plugin_from_file), - ).call, - child: IconButton.primary( - icon: const Icon(SpotubeIcons.upload), - onPressed: () async { - Uint8List bytes; - - if (kIsFlatpak) { - final result = await openFile( - acceptedTypeGroups: [ - const XTypeGroup( - label: 'Spotube Metadata Plugin', - extensions: ['smplug'], - ), - ], - ); - if (result == null) return; - bytes = await result.readAsBytes(); - } else { - final result = await FilePicker.platform.pickFiles( - type: kIsAndroid ? FileType.any : FileType.custom, - allowedExtensions: kIsAndroid ? [] : ["smplug"], - withData: true, - ); - - if (result == null) return; - - final file = result.files.first; - if (file.bytes == null) return; - bytes = file.bytes!; - } - - final pluginConfig = - await pluginsNotifier.extractPluginArchive(bytes); - await pluginsNotifier.addPlugin(pluginConfig); - }, - ), - ), - ], - ), - ), - const SliverGap(12), - SliverToBoxAdapter( - child: TabList( - index: tabState.value, - onChanged: (value) { - tabState.value = value; - }, - children: const [ - TabItem(child: Text("All")), - TabItem(child: Text("Metadata")), - TabItem(child: Text("Audio Source")), - ], - ), - ), - const SliverGap(12), - if (plugins.asData?.value.plugins.isNotEmpty ?? false) - SliverToBoxAdapter( - child: Row( - children: [ - const Gap(8), - Text(context.l10n.installed).h4, - const Gap(8), - const Expanded(child: Divider()), - const Gap(8), - ], - ), - ), - const SliverGap(20), - SliverList.separated( - itemCount: installedPlugins?.length ?? 0, - separatorBuilder: (context, index) => const Gap(12), - itemBuilder: (context, index) { - final plugin = installedPlugins![index]; - final isDefaultMetadata = - plugins.asData!.value.defaultMetadataPluginConfig?.slug == - plugin.slug; - final isDefaultAudioSource = plugins - .asData!.value.defaultAudioSourcePluginConfig?.slug == - plugin.slug; - return MetadataInstalledPluginItem( - plugin: plugin, - isDefaultMetadata: isDefaultMetadata, - isDefaultAudioSource: isDefaultAudioSource, - ); - }, - ), - const SliverGap(12), - SliverToBoxAdapter( - child: Row( - children: [ - const Gap(8), - Text(context.l10n.available_plugins).h4, - const Gap(8), - const Expanded(child: Divider()), - const Gap(8), - ], - ), - ), - const SliverGap(12), - SliverInfiniteList( - isLoading: pluginReposSnapshot.isLoading && - !pluginReposSnapshot.isLoadingNextPage, - itemCount: pluginRepos.length, - onFetchData: pluginReposNotifier.fetchMore, - separatorBuilder: (context, index) { - return const Gap(12); - }, - loadingBuilder: (context) { - return Skeletonizer( - enabled: true, - child: MetadataPluginRepositoryItem( - pluginRepo: MetadataPluginRepository( - name: "Loading...", - description: "Loading...", - repoUrl: "", - owner: "", - topics: [], - ), - ), - ); - }, - itemBuilder: (context, index) { - final pluginRepo = pluginRepos[index]; - - return MetadataPluginRepositoryItem( - pluginRepo: pluginRepo, - ); - }, - ), - const SliverGap(20), - SliverCrossAxisConstrained( - maxCrossAxisExtent: 720, - child: SliverFillRemaining( - hasScrollBody: false, - child: Container( - alignment: Alignment.bottomCenter, - margin: const EdgeInsets.only(bottom: 20), - child: SafeArea( - child: Card( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 12, - children: [ - Row( - spacing: 8, - children: [ - const Icon(SpotubeIcons.warning, size: 16), - Text( - context.l10n.disclaimer, - style: const TextStyle( - fontWeight: FontWeight.bold), - ).bold, - ], - ), - Text(context.l10n.third_party_plugin_dmca_notice) - .muted - .xSmall, - ], - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/pages/settings/scrobbling/scrobbling.dart b/lib/pages/settings/scrobbling/scrobbling.dart deleted file mode 100644 index 9c7f3296..00000000 --- a/lib/pages/settings/scrobbling/scrobbling.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart' - show ListTile, ListTileTheme, ListTileThemeData, Material, MaterialType; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/context.dart'; - -@RoutePage() -class SettingsScrobblingPage extends HookConsumerWidget { - static const name = "settings_scrobbling"; - - const SettingsScrobblingPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - return Material( - type: MaterialType.transparency, - child: ListTileTheme( - data: ListTileThemeData( - contentPadding: EdgeInsets.zero, - minVerticalPadding: 0, - shape: RoundedRectangleBorder( - borderRadius: context.theme.borderRadiusLg, - side: BorderSide( - color: context.theme.colorScheme.border, - width: .5, - ), - ), - textColor: context.theme.colorScheme.foreground, - iconColor: context.theme.colorScheme.foreground, - selectedColor: context.theme.colorScheme.accent, - subtitleTextStyle: context.theme.typography.xSmall, - ), - child: SafeArea( - bottom: false, - child: Scaffold( - headers: [TitleBar(title: Text(context.l10n.scrobbling))], - child: ListView( - padding: const EdgeInsets.all(8), - children: [ - Card( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: ListTile( - leading: const Icon(SpotubeIcons.lastFm, color: Colors.red), - title: Text(context.l10n.login_with_lastfm), - subtitle: Text(context.l10n.scrobble_to_lastfm), - trailing: Button.secondary( - leading: const Icon(SpotubeIcons.lastFm), - onPressed: () { - context.navigateTo(const LastFMLoginRoute()); - }, - child: Text(context.l10n.connect), - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/settings/sections/about.dart b/lib/pages/settings/sections/about.dart deleted file mode 100644 index 82c98e90..00000000 --- a/lib/pages/settings/sections/about.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart' show ListTile; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide ButtonStyle; -import 'package:spotube/collections/env.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/settings/section_card_with_heading.dart'; -import 'package:spotube/components/adaptive/adaptive_list_tile.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -class SettingsAboutSection extends HookConsumerWidget { - const SettingsAboutSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final preferences = ref.watch(userPreferencesProvider); - final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); - - return SectionCardWithHeading( - heading: context.l10n.about, - children: [ - if (!Env.hideDonations) - AdaptiveListTile( - leading: const Icon( - SpotubeIcons.heart, - color: Colors.pink, - ), - title: SizedBox( - height: 50, - width: 200, - child: Align( - alignment: Alignment.centerLeft, - child: AutoSizeText( - context.l10n.u_love_spotube, - maxLines: 1, - style: const TextStyle( - color: Colors.pink, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - trailing: (context, update) => Button( - style: ButtonVariance.primary.copyWith( - decoration: (context, states, value) { - final decoration = ButtonVariance.primary - .decoration(context, states) as BoxDecoration; - - if (states.contains(WidgetState.hovered)) { - return decoration.copyWith(color: Colors.pink[400]); - } else if (states.contains(WidgetState.focused)) { - return decoration.copyWith(color: Colors.pink[300]); - } else if (states.isNotEmpty) { - return decoration; - } - - return decoration.copyWith(color: Colors.pink); - }, - textStyle: (context, states, value) => ButtonVariance.primary - .textStyle(context, states) - .copyWith(color: Colors.white), - ), - onPressed: () { - launchUrlString( - "https://opencollective.com/spotube", - mode: LaunchMode.externalApplication, - ); - }, - leading: const Icon(SpotubeIcons.heart), - child: Text(context.l10n.please_sponsor), - ), - ), - if (Env.enableUpdateChecker) - ListTile( - leading: const Icon(SpotubeIcons.update), - title: Text(context.l10n.check_for_updates), - trailing: Switch( - value: preferences.checkUpdate, - onChanged: (checked) => - preferencesNotifier.setCheckUpdate(checked), - ), - ), - ListTile( - leading: const Icon(SpotubeIcons.info), - title: Text(context.l10n.about_spotube), - trailing: const Icon(SpotubeIcons.angleRight), - onTap: () { - context.navigateTo(const AboutSpotubeRoute()); - }, - ) - ], - ); - } -} diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart deleted file mode 100644 index ca859ada..00000000 --- a/lib/pages/settings/sections/accounts.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart' show ListTile; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/settings/section_card_with_heading.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/scrobbler/scrobbler.dart'; - -class SettingsAccountSection extends HookConsumerWidget { - const SettingsAccountSection({super.key}); - - @override - Widget build(context, ref) { - final scrobbler = ref.watch(scrobblerProvider); - - return SectionCardWithHeading( - heading: context.l10n.account, - children: [ - ListTile( - leading: const Icon(SpotubeIcons.extensions), - title: Text(context.l10n.plugins), - subtitle: Text(context.l10n.configure_plugins), - onTap: () { - context.pushRoute(const SettingsMetadataProviderRoute()); - }, - trailing: const Icon(SpotubeIcons.angleRight), - ), - if (scrobbler.asData?.value == null) - ListTile( - leading: const Icon(SpotubeIcons.music), - title: Text(context.l10n.audio_scrobblers), - onTap: () { - context.pushRoute(const SettingsScrobblingRoute()); - }, - trailing: const Icon(SpotubeIcons.angleRight), - ) - else - ListTile( - leading: const Icon(SpotubeIcons.lastFm), - title: Text(context.l10n.disconnect_lastfm), - trailing: Button.destructive( - onPressed: () { - ref.read(scrobblerProvider.notifier).logout(); - }, - child: Text(context.l10n.disconnect), - ), - ), - ], - ); - } -} diff --git a/lib/pages/settings/sections/appearance.dart b/lib/pages/settings/sections/appearance.dart deleted file mode 100644 index 88f39a25..00000000 --- a/lib/pages/settings/sections/appearance.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'package:flutter/material.dart' show ListTile; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; -import 'package:spotube/modules/settings/section_card_with_heading.dart'; -import 'package:spotube/components/adaptive/adaptive_select_tile.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class SettingsAppearanceSection extends HookConsumerWidget { - final bool isGettingStarted; - const SettingsAppearanceSection({ - super.key, - this.isGettingStarted = false, - }); - - @override - Widget build(BuildContext context, ref) { - final preferences = ref.watch(userPreferencesProvider); - final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); - final pickColorScheme = useCallback(() { - return () => showDialog( - context: context, - builder: (context) { - return const ColorSchemePickerDialog(); - }); - }, []); - - final children = [ - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.dashboard), - title: Text(context.l10n.layout_mode), - subtitle: Text(context.l10n.override_layout_settings), - value: preferences.layoutMode, - onChanged: (value) { - if (value != null) { - preferencesNotifier.setLayoutMode(value); - } - }, - options: [ - SelectItemButton( - value: LayoutMode.adaptive, - child: Text(context.l10n.adaptive), - ), - SelectItemButton( - value: LayoutMode.compact, - child: Text(context.l10n.compact), - ), - SelectItemButton( - value: LayoutMode.extended, - child: Text(context.l10n.extended), - ), - ], - ), - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.darkMode), - title: Text(context.l10n.theme), - value: preferences.themeMode, - options: [ - SelectItemButton( - value: ThemeMode.dark, - child: Text(context.l10n.dark), - ), - SelectItemButton( - value: ThemeMode.light, - child: Text(context.l10n.light), - ), - SelectItemButton( - value: ThemeMode.system, - child: Text(context.l10n.system), - ), - ], - onChanged: (value) { - if (value != null) { - preferencesNotifier.setThemeMode(value); - } - }, - ), - // ListTile( - // leading: const Icon(SpotubeIcons.amoled), - // title: Text(context.l10n.use_amoled_mode), - // subtitle: Text(context.l10n.pitch_dark_theme), - // trailing: Switch( - // value: preferences.amoledDarkTheme, - // onChanged: preferencesNotifier.setAmoledDarkTheme, - // )), - ListTile( - leading: const Icon(SpotubeIcons.palette), - title: Text(context.l10n.accent_color), - contentPadding: const EdgeInsets.symmetric( - horizontal: 15, - vertical: 5, - ), - trailing: ColorChip( - color: preferences.accentColorScheme, - name: preferences.accentColorScheme.name, - onPressed: pickColorScheme(), - isActive: false, - ), - onTap: pickColorScheme(), - ), - // ListTile( - // leading: const Icon(SpotubeIcons.colorSync), - // title: Text(context.l10n.sync_album_color), - // subtitle: Text(context.l10n.sync_album_color_description), - // trailing: Switch( - // value: preferences.albumColorSync, - // onChanged: preferencesNotifier.setAlbumColorSync, - // )), - ]; - - if (isGettingStarted) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final child in children) ...[ - child, - const Gap(16), - ], - ], - ); - } - - return SectionCardWithHeading( - heading: context.l10n.appearance, - children: children, - ); - } -} diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart deleted file mode 100644 index ad45c689..00000000 --- a/lib/pages/settings/sections/desktop.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter/material.dart' show ListTile; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/modules/settings/section_card_with_heading.dart'; -import 'package:spotube/components/adaptive/adaptive_select_tile.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class SettingsDesktopSection extends HookConsumerWidget { - const SettingsDesktopSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final preferences = ref.watch(userPreferencesProvider); - final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); - - return SectionCardWithHeading( - heading: context.l10n.desktop, - children: [ - const Gap(10), - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.close), - title: Text(context.l10n.close_behavior), - value: preferences.closeBehavior, - options: [ - SelectItemButton( - value: CloseBehavior.close, - child: Text(context.l10n.close), - ), - SelectItemButton( - value: CloseBehavior.minimizeToTray, - child: Text(context.l10n.minimize_to_tray), - ), - ], - onChanged: (value) { - if (value != null) { - preferencesNotifier.setCloseBehavior(value); - } - }, - ), - ListTile( - leading: const Icon(SpotubeIcons.tray), - title: Text(context.l10n.show_tray_icon), - trailing: Switch( - value: preferences.showSystemTrayIcon, - onChanged: preferencesNotifier.setShowSystemTrayIcon, - ), - ), - ListTile( - leading: const Icon(SpotubeIcons.window), - title: Text(context.l10n.use_system_title_bar), - trailing: Switch( - value: preferences.systemTitleBar, - onChanged: preferencesNotifier.setSystemTitleBar, - ), - ), - ListTile( - leading: const Icon(SpotubeIcons.discord), - title: Text(context.l10n.discord_rich_presence), - trailing: Switch( - value: preferences.discordPresence, - onChanged: preferencesNotifier.setDiscordPresence, - ), - ), - ], - ); - } -} diff --git a/lib/pages/settings/sections/developers.dart b/lib/pages/settings/sections/developers.dart deleted file mode 100644 index 0862e023..00000000 --- a/lib/pages/settings/sections/developers.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart' show ListTile; -import 'package:flutter_hooks/flutter_hooks.dart'; - -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/settings/section_card_with_heading.dart'; -import 'package:spotube/extensions/context.dart'; - -class SettingsDevelopersSection extends HookWidget { - const SettingsDevelopersSection({super.key}); - - @override - Widget build(BuildContext context) { - return SectionCardWithHeading( - heading: context.l10n.developers, - children: [ - ListTile( - leading: const Icon(SpotubeIcons.logs), - title: Text(context.l10n.logs), - trailing: const Icon(SpotubeIcons.angleRight), - onTap: () { - context.navigateTo(const LogsRoute()); - }, - ) - ], - ); - } -} diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart deleted file mode 100644 index 516d2aca..00000000 --- a/lib/pages/settings/sections/downloads.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:file_picker/file_picker.dart'; -import 'package:file_selector/file_selector.dart'; -import 'package:flutter/material.dart' show ListTile; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/settings/section_card_with_heading.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.dart'; - -class SettingsDownloadsSection extends HookConsumerWidget { - const SettingsDownloadsSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); - final preferences = ref.watch(userPreferencesProvider); - - final pickDownloadLocation = useCallback(() async { - if (kIsMobile || kIsMacOS) { - final dirStr = await FilePicker.platform.getDirectoryPath( - initialDirectory: preferences.downloadLocation, - ); - if (dirStr == null) return; - preferencesNotifier.setDownloadLocation(dirStr); - } else { - String? dirStr = await getDirectoryPath( - initialDirectory: preferences.downloadLocation, - ); - if (dirStr == null) return; - preferencesNotifier.setDownloadLocation(dirStr); - } - }, [preferences.downloadLocation]); - - return SectionCardWithHeading( - heading: context.l10n.downloads, - children: [ - ListTile( - leading: const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_location), - subtitle: Text(preferences.downloadLocation), - trailing: IconButton.secondary( - onPressed: pickDownloadLocation, - icon: const Icon(SpotubeIcons.folder), - ), - onTap: pickDownloadLocation, - ), - ], - ); - } -} diff --git a/lib/pages/settings/sections/language_region.dart b/lib/pages/settings/sections/language_region.dart deleted file mode 100644 index 920b0df7..00000000 --- a/lib/pages/settings/sections/language_region.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/language_codes.dart'; -import 'package:spotube/collections/markets.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/models/metadata/market.dart'; -import 'package:spotube/modules/settings/section_card_with_heading.dart'; -import 'package:spotube/components/adaptive/adaptive_select_tile.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/l10n/l10n.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -final localWithName = L10n.all.map((e) { - final isoCodeName = - LanguageLocals.getDisplayLanguage(e.languageCode, e.countryCode); - return ( - locale: e, - name: "${isoCodeName.name} (${isoCodeName.nativeName})", - ); -}).sortedBy((e) => e.name); - -class SettingsLanguageRegionSection extends HookConsumerWidget { - const SettingsLanguageRegionSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final preferences = ref.watch(userPreferencesProvider); - final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); - final mediaQuery = MediaQuery.of(context); - - return SectionCardWithHeading( - heading: context.l10n.language_region, - children: [ - AdaptiveSelectTile( - value: preferences.locale, - onChanged: (locale) { - if (locale == null) return; - preferencesNotifier.setLocale(locale); - }, - title: Text(context.l10n.language), - secondary: const Icon(SpotubeIcons.language), - options: [ - SelectItemButton( - value: const Locale("system", "system"), - child: Text(context.l10n.system_default), - ), - for (final (:locale, :name) in localWithName) - SelectItemButton(value: locale, child: Text(name)), - ], - ), - AdaptiveSelectTile( - breakLayout: mediaQuery.lgAndUp, - secondary: const Icon(SpotubeIcons.shoppingBag), - title: Text(context.l10n.market_place_region), - subtitle: Text(context.l10n.recommendation_country), - value: preferences.market, - onChanged: (value) { - if (value == null) return; - preferencesNotifier.setRecommendationMarket(value); - }, - options: marketsMap - .map( - (country) => SelectItemButton( - value: country.$1, - child: Text(country.$2), - ), - ) - .toList(), - ), - ], - ); - } -} diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart deleted file mode 100644 index 0a29c991..00000000 --- a/lib/pages/settings/sections/playback.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'dart:io'; - -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart' show ListTile; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/adaptive/adaptive_select_tile.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/modules/settings/playback/edit_connect_port_dialog.dart'; -import 'package:spotube/modules/settings/section_card_with_heading.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/modules/settings/youtube_engine_not_installed_dialog.dart'; -import 'package:spotube/provider/metadata_plugin/audio_source/quality_presets.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart'; - -import 'package:spotube/utils/platform.dart'; - -class SettingsPlaybackSection extends HookConsumerWidget { - const SettingsPlaybackSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final preferences = ref.watch(userPreferencesProvider); - final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); - final sourcePresets = ref.watch(audioSourcePresetsProvider); - final sourcePresetsNotifier = - ref.watch(audioSourcePresetsProvider.notifier); - final theme = Theme.of(context); - - return SectionCardWithHeading( - heading: context.l10n.playback, - children: [ - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.engine), - title: Text(context.l10n.youtube_engine), - value: preferences.youtubeClientEngine, - options: YoutubeClientEngine.values - .where((e) => e.isAvailableForPlatform()) - .map((e) => SelectItemButton( - value: e, - child: Text(e.label), - )) - .toList(), - onChanged: (value) async { - if (value == null) return; - if (value == YoutubeClientEngine.ytDlp) { - final customPath = KVStoreService.getYoutubeEnginePath(value); - if (!await YtDlpEngine.isInstalled() && - (customPath == null || !await File(customPath).exists()) && - context.mounted) { - final hasInstalled = await showDialog( - context: context, - builder: (context) => - YouTubeEngineNotInstalledDialog(engine: value), - ); - if (hasInstalled != true) return; - } - } - preferencesNotifier.setYoutubeClientEngine(value); - }, - ), - if (sourcePresets.presets.isNotEmpty) ...[ - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.plugin), - title: Text(context.l10n.streaming_music_format), - value: sourcePresets.selectedStreamingContainerIndex, - options: [ - for (final MapEntry(:key, value: preset) - in sourcePresets.presets.asMap().entries) - SelectItemButton(value: key, child: Text(preset.name)), - ], - onChanged: (value) { - if (value == null) return; - sourcePresetsNotifier.setSelectedStreamingContainerIndex(value); - }, - ), - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.audioQuality), - title: Text(context.l10n.streaming_music_quality), - value: sourcePresets.selectedStreamingQualityIndex, - options: [ - for (final MapEntry(:key, value: quality) in sourcePresets - .presets[sourcePresets.selectedStreamingContainerIndex] - .qualities - .asMap() - .entries) - SelectItemButton(value: key, child: Text(quality.toString())), - ], - onChanged: (value) { - if (value == null) return; - sourcePresetsNotifier.setSelectedStreamingQualityIndex(value); - }, - ), - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.plugin), - title: Text(context.l10n.download_music_format), - value: sourcePresets.selectedDownloadingContainerIndex, - options: [ - for (final MapEntry(:key, value: preset) - in sourcePresets.presets.asMap().entries) - SelectItemButton(value: key, child: Text(preset.name)), - ], - onChanged: (value) { - if (value == null) return; - sourcePresetsNotifier.setSelectedDownloadingContainerIndex(value); - }, - ), - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.audioQuality), - title: Text(context.l10n.download_music_quality), - value: sourcePresets.selectedStreamingQualityIndex, - options: [ - for (final MapEntry(:key, value: quality) in sourcePresets - .presets[sourcePresets.selectedDownloadingContainerIndex] - .qualities - .asMap() - .entries) - SelectItemButton(value: key, child: Text(quality.toString())), - ], - onChanged: (value) { - if (value == null) return; - sourcePresetsNotifier.setSelectedStreamingQualityIndex(value); - }, - ), - ], - ListTile( - title: Text(context.l10n.cache_music), - subtitle: kIsMobile - ? null - : Text.rich( - TextSpan( - children: [ - TextSpan(text: "${context.l10n.open} "), - TextSpan( - text: context.l10n.cache_folder.toLowerCase(), - recognizer: TapGestureRecognizer() - ..onTap = preferencesNotifier.openCacheFolder, - style: theme.typography.normal.copyWith( - color: theme.colorScheme.primary, - decoration: TextDecoration.underline, - ), - ) - ], - ), - ), - leading: const Icon(SpotubeIcons.cache), - trailing: Switch( - value: preferences.cacheMusic, - onChanged: preferencesNotifier.setCacheMusic, - ), - ), - ListTile( - leading: const Icon(SpotubeIcons.playlistRemove), - title: Text(context.l10n.blacklist), - subtitle: Text(context.l10n.blacklist_description), - onTap: () { - context.navigateTo(const BlackListRoute()); - }, - trailing: const Icon(SpotubeIcons.angleRight), - ), - ListTile( - leading: const Icon(SpotubeIcons.normalize), - title: Text(context.l10n.normalize_audio), - trailing: Switch( - value: preferences.normalizeAudio, - onChanged: preferencesNotifier.setNormalizeAudio, - ), - ), - ListTile( - leading: const Icon(SpotubeIcons.repeat), - title: Text(context.l10n.endless_playback), - trailing: Switch( - value: preferences.endlessPlayback, - onChanged: preferencesNotifier.setEndlessPlayback, - )), - ListTile( - title: Text(context.l10n.enable_connect), - subtitle: Text(context.l10n.enable_connect_description), - leading: const Icon(SpotubeIcons.connect), - trailing: Row( - mainAxisSize: MainAxisSize.min, - spacing: 10, - children: [ - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.edit_port), - ).call, - child: IconButton.outline( - icon: const Icon(SpotubeIcons.edit), - size: ButtonSize.small, - onPressed: () { - showDialog( - context: context, - barrierColor: Colors.black.withValues(alpha: 0.5), - builder: (context) => - const SettingsPlaybackEditConnectPortDialog(), - ); - }, - ), - ), - Switch( - value: preferences.enableConnect, - onChanged: preferencesNotifier.setEnableConnect, - ), - ], - ), - ), - ], - ); - } -} diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart deleted file mode 100644 index 0948bdeb..00000000 --- a/lib/pages/settings/settings.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart' show Material, MaterialType; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/settings/sections/about.dart'; -import 'package:spotube/pages/settings/sections/accounts.dart'; -import 'package:spotube/pages/settings/sections/appearance.dart'; -import 'package:spotube/pages/settings/sections/desktop.dart'; -import 'package:spotube/pages/settings/sections/developers.dart'; -import 'package:spotube/pages/settings/sections/downloads.dart'; -import 'package:spotube/pages/settings/sections/language_region.dart'; -import 'package:spotube/pages/settings/sections/playback.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class SettingsPage extends HookConsumerWidget { - static const name = "settings"; - - const SettingsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final controller = useScrollController(); - final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); - - return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - TitleBar( - title: Text(context.l10n.settings), - ) - ], - child: Scrollbar( - controller: controller, - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 1366), - child: ScrollConfiguration( - behavior: const ScrollBehavior().copyWith(scrollbars: false), - child: Material( - type: MaterialType.transparency, - child: ListView( - controller: controller, - children: [ - const SettingsAccountSection(), - const SettingsLanguageRegionSection(), - const SettingsAppearanceSection(), - const SettingsPlaybackSection(), - const SettingsDownloadsSection(), - if (kIsDesktop) const SettingsDesktopSection(), - if (!kIsWeb) const SettingsDevelopersSection(), - const SettingsAboutSection(), - Center( - child: Button.destructive( - onPressed: preferencesNotifier.reset, - child: Text(context.l10n.restore_defaults), - ), - ), - const SizedBox(height: 200), - ], - ), - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart deleted file mode 100644 index 363e7962..00000000 --- a/lib/pages/stats/albums/albums.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/modules/stats/common/album_item.dart'; -import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/provider/history/top/albums.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class StatsAlbumsPage extends HookConsumerWidget { - static const name = "stats_albums"; - const StatsAlbumsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final topAlbums = - ref.watch(historyTopAlbumsProvider(HistoryDuration.allTime)); - final topAlbumsNotifier = - ref.watch(historyTopAlbumsProvider(HistoryDuration.allTime).notifier); - - final albumsData = topAlbums.asData?.value.items ?? []; - - return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - TitleBar( - title: Text(context.l10n.albums), - ) - ], - child: Skeletonizer( - enabled: topAlbums.isLoading && !topAlbums.isLoadingNextPage, - child: InfiniteList( - onFetchData: () async { - await topAlbumsNotifier.fetchMore(); - }, - hasError: topAlbums.hasError, - isLoading: topAlbums.isLoading && !topAlbums.isLoadingNextPage, - hasReachedMax: topAlbums.asData?.value.hasMore ?? true, - itemCount: albumsData.length, - itemBuilder: (context, index) { - final album = albumsData[index]; - return StatsAlbumItem( - album: album.album, - info: Text(context.l10n - .count_plays(compactNumberFormatter.format(album.count))), - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/pages/stats/artists/artists.dart b/lib/pages/stats/artists/artists.dart deleted file mode 100644 index 340f7b4b..00000000 --- a/lib/pages/stats/artists/artists.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/modules/stats/common/artist_item.dart'; -import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/provider/history/top/tracks.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class StatsArtistsPage extends HookConsumerWidget { - static const name = "stats_artists"; - const StatsArtistsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final topTracks = ref.watch( - historyTopTracksProvider(HistoryDuration.allTime), - ); - final topTracksNotifier = - ref.watch(historyTopTracksProvider(HistoryDuration.allTime).notifier); - - final artistsData = useMemoized( - () => topTracksNotifier.artists, - [topTracks.asData?.value], - ); - - return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - TitleBar( - title: Text(context.l10n.artists), - ) - ], - child: Skeletonizer( - enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, - child: InfiniteList( - onFetchData: () async { - await topTracksNotifier.fetchMore(); - }, - hasError: topTracks.hasError, - isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, - hasReachedMax: topTracks.asData?.value.hasMore ?? true, - itemCount: artistsData.length, - itemBuilder: (context, index) { - final artist = artistsData[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text(context.l10n - .count_plays(compactNumberFormatter.format(artist.count))), - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart deleted file mode 100644 index 15b93057..00000000 --- a/lib/pages/stats/fees/fees.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:sliver_tools/sliver_tools.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/modules/stats/common/artist_item.dart'; -import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/provider/history/top/tracks.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class StatsStreamFeesPage extends HookConsumerWidget { - static const name = "stats_stream_fees"; - - const StatsStreamFeesPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final duration = useState(HistoryDuration.days30); - - final topTracks = ref.watch( - historyTopTracksProvider(duration.value), - ); - final topTracksNotifier = - ref.watch(historyTopTracksProvider(duration.value).notifier); - - final artistsData = useMemoized( - () => topTracksNotifier.artists, - [topTracks.asData?.value], - ); - - final total = useMemoized( - () => artistsData.fold( - 0, - (previousValue, element) => previousValue + element.count * 0.005, - ), - [artistsData], - ); - - final translations = { - HistoryDuration.days7: context.l10n.this_week, - HistoryDuration.days30: context.l10n.this_month, - HistoryDuration.months6: context.l10n.last_6_months, - HistoryDuration.year: context.l10n.this_year, - HistoryDuration.years2: context.l10n.last_2_years, - HistoryDuration.allTime: context.l10n.all_time, - }; - - return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - TitleBar( - title: Text(context.l10n.streaming_fees_hypothetical), - ) - ], - child: CustomScrollView( - slivers: [ - SliverCrossAxisConstrained( - maxCrossAxisExtent: 600, - alignment: -1, - child: SliverPadding( - padding: const EdgeInsets.all(16.0), - sliver: SliverToBoxAdapter( - child: Text( - context.l10n.hipotetical_calculation, - ).small().muted(), - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.l10n.total_money(usdFormatter.format(total)), - ).semiBold().large(), - Select( - value: duration.value, - onChanged: (value) { - if (value == null) return; - duration.value = value; - }, - itemBuilder: (context, value) => - Text(translations[value]!), - constraints: const BoxConstraints(maxWidth: 150), - popupWidthConstraint: PopoverConstraint.anchorMaxSize, - popup: SelectPopup( - items: SelectItemBuilder( - childCount: translations.length, - builder: (context, index) { - final entry = translations.entries.elementAt(index); - return SelectItemButton( - value: entry.key, - child: Text(entry.value), - ); - }, - ), - ).call, - ), - ], - ), - ), - ), - SliverSafeArea( - sliver: Skeletonizer.sliver( - enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, - child: SliverInfiniteList( - onFetchData: () async { - await topTracksNotifier.fetchMore(); - }, - hasError: topTracks.hasError, - isLoading: - topTracks.isLoading && !topTracks.isLoadingNextPage, - hasReachedMax: topTracks.asData?.value.hasMore ?? true, - itemCount: artistsData.length, - itemBuilder: (context, index) { - final artist = artistsData[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text(usdFormatter.format(artist.count * 0.005)), - ); - }, - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart deleted file mode 100644 index a6c95992..00000000 --- a/lib/pages/stats/minutes/minutes.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/modules/stats/common/track_item.dart'; -import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/provider/history/top/tracks.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class StatsMinutesPage extends HookConsumerWidget { - static const name = "stats_minutes"; - - const StatsMinutesPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final topTracks = ref.watch( - historyTopTracksProvider(HistoryDuration.allTime), - ); - final topTracksNotifier = - ref.watch(historyTopTracksProvider(HistoryDuration.allTime).notifier); - - final tracksData = topTracks.asData?.value.items ?? []; - - return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - TitleBar( - title: Text(context.l10n.minutes_listened), - ) - ], - child: Skeletonizer( - enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, - child: InfiniteList( - separatorBuilder: (context, index) => const Gap(8), - onFetchData: () async { - await topTracksNotifier.fetchMore(); - }, - hasError: topTracks.hasError, - isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, - hasReachedMax: topTracks.asData?.value.hasMore ?? true, - itemCount: tracksData.length, - itemBuilder: (context, index) { - final track = tracksData[index]; - return StatsTrackItem( - track: track.track, - info: Text( - context.l10n.count_mins( - compactNumberFormatter.format( - track.count * - Duration(milliseconds: track.track.durationMs) - .inMinutes, - ), - ), - ), - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart deleted file mode 100644 index 369066f7..00000000 --- a/lib/pages/stats/playlists/playlists.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/modules/stats/common/playlist_item.dart'; -import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/provider/history/top/playlists.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class StatsPlaylistsPage extends HookConsumerWidget { - static const name = "stats_playlists"; - const StatsPlaylistsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final topPlaylists = - ref.watch(historyTopPlaylistsProvider(HistoryDuration.allTime)); - - final topPlaylistsNotifier = ref - .watch(historyTopPlaylistsProvider(HistoryDuration.allTime).notifier); - - final playlistsData = topPlaylists.asData?.value.items ?? []; - - return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - TitleBar( - title: Text(context.l10n.playlists), - ) - ], - child: Skeletonizer( - enabled: topPlaylists.isLoading && !topPlaylists.isLoadingNextPage, - child: InfiniteList( - onFetchData: () async { - await topPlaylistsNotifier.fetchMore(); - }, - hasError: topPlaylists.hasError, - isLoading: - topPlaylists.isLoading && !topPlaylists.isLoadingNextPage, - hasReachedMax: topPlaylists.asData?.value.hasMore ?? true, - itemCount: playlistsData.length, - itemBuilder: (context, index) { - final playlist = playlistsData[index]; - return StatsPlaylistItem( - playlist: playlist.playlist, - info: Text( - context.l10n.count_plays( - compactNumberFormatter.format(playlist.count)), - ), - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/pages/stats/stats.dart b/lib/pages/stats/stats.dart deleted file mode 100644 index da7c64f3..00000000 --- a/lib/pages/stats/stats.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/modules/stats/summary/summary.dart'; -import 'package:spotube/modules/stats/top/top.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class StatsPage extends HookConsumerWidget { - static const name = "stats"; - - const StatsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) { - context.navigateTo(const HomeRoute()); - }, - child: SafeArea( - bottom: false, - child: Scaffold( - headers: [ - if (kTitlebarVisible) - const TitleBar(automaticallyImplyLeading: false), - ], - child: CustomScrollView( - slivers: [ - if (kIsMacOS) const SliverGap(20), - const StatsPageSummarySection(), - const StatsPageTopSection(), - const SliverToBoxAdapter( - child: SafeArea( - child: SizedBox(), - ), - ) - ], - ), - ), - ), - ); - } -} diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart deleted file mode 100644 index b2cc671d..00000000 --- a/lib/pages/stats/streams/streams.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/modules/stats/common/track_item.dart'; -import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/provider/history/top/tracks.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class StatsStreamsPage extends HookConsumerWidget { - static const name = "stats_streams"; - - const StatsStreamsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final topTracks = ref.watch( - historyTopTracksProvider(HistoryDuration.allTime), - ); - final topTracksNotifier = - ref.watch(historyTopTracksProvider(HistoryDuration.allTime).notifier); - - final tracksData = topTracks.asData?.value.items ?? []; - - return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - TitleBar( - title: Text(context.l10n.streamed_songs), - ) - ], - child: Skeletonizer( - enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, - child: InfiniteList( - separatorBuilder: (context, index) => const Gap(8), - onFetchData: () async { - await topTracksNotifier.fetchMore(); - }, - hasError: topTracks.hasError, - isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, - hasReachedMax: topTracks.asData?.value.hasMore ?? true, - itemCount: tracksData.length, - itemBuilder: (context, index) { - final track = tracksData[index]; - return StatsTrackItem( - track: track.track, - info: Text( - context.l10n - .count_plays(compactNumberFormatter.format(track.count)), - ), - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart deleted file mode 100644 index 44453ebd..00000000 --- a/lib/pages/track/track.dart +++ /dev/null @@ -1,257 +0,0 @@ -import 'dart:ui'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/heart_button/heart_button.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/links/artist_link.dart'; -import 'package:spotube/components/links/link_text.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/components/track_tile/track_options_button.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/list.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/metadata_plugin/tracks/track.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; - -import 'package:spotube/extensions/constrains.dart'; -import 'package:auto_route/auto_route.dart'; - -@RoutePage() -class TrackPage extends HookConsumerWidget { - static const name = "track"; - - final String trackId; - const TrackPage({ - super.key, - @PathParam("id") required this.trackId, - }); - - @override - Widget build(BuildContext context, ref) { - final ThemeData(:typography, :colorScheme) = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - - final playlist = ref.watch(audioPlayerProvider); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - - final isActive = playlist.activeTrack?.id == trackId; - - final trackQuery = ref.watch(metadataPluginTrackProvider(trackId)); - - final track = trackQuery.asData?.value ?? FakeData.track; - - void onPlay() async { - if (isActive) { - audioPlayer.pause(); - } else { - await playlistNotifier.load([track], autoPlay: true); - } - } - - return SafeArea( - bottom: false, - child: Scaffold( - headers: const [ - TitleBar( - backgroundColor: Colors.transparent, - surfaceBlur: 0, - ) - ], - floatingHeader: true, - child: Stack( - children: [ - Positioned.fill( - child: Container( - decoration: BoxDecoration( - image: DecorationImage( - image: UniversalImage.imageProvider( - track.album.images.asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - ), - fit: BoxFit.cover, - colorFilter: ColorFilter.mode( - colorScheme.background.withValues(alpha: 0.5), - BlendMode.srcOver, - ), - alignment: Alignment.topCenter, - ), - ), - ), - ), - Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: Skeletonizer( - enabled: trackQuery.isLoading, - child: Container( - alignment: Alignment.topCenter, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - colorScheme.background, - Colors.transparent, - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: const [0.2, 1], - ), - ), - child: SafeArea( - child: Wrap( - spacing: 20, - runSpacing: 20, - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only(top: 20), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: UniversalImage( - path: track.album.images.asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - height: 200, - width: 200, - ), - ), - ), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - crossAxisAlignment: mediaQuery.smAndDown - ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - track.name, - ).large().semiBold(), - const Gap(10), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.album), - const Gap(5), - Flexible( - child: LinkText( - track.album.name, - AlbumRoute( - id: track.album.id, - album: track.album, - ), - push: true, - ), - ), - ], - ), - const Gap(10), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.artist), - const Gap(5), - Flexible( - child: ArtistLink( - artists: track.artists, - hideOverflowArtist: false, - ), - ), - ], - ), - const Gap(10), - ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 350), - child: Row( - mainAxisSize: mediaQuery.smAndDown - ? MainAxisSize.max - : MainAxisSize.min, - children: [ - const Gap(5), - if (!isActive && - !playlist.tracks - .containsBy(track, (t) => t.id)) - Button.outline( - leading: - const Icon(SpotubeIcons.queueAdd), - child: Text(context.l10n.queue), - onPressed: () { - playlistNotifier.addTrack(track); - }, - ), - const Gap(5), - if (!isActive && - !playlist.tracks - .containsBy(track, (t) => t.id)) - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.play_next), - ).call, - child: IconButton.outline( - icon: const Icon( - SpotubeIcons.lightning), - onPressed: () { - playlistNotifier - .addTracksAtFirst([track]); - }, - ), - ), - const Gap(5), - Tooltip( - tooltip: TooltipContainer( - child: Text( - isActive - ? context.l10n.pause_playback - : context.l10n.play, - ), - ).call, - child: IconButton.primary( - shape: ButtonShape.circle, - icon: Icon( - isActive - ? SpotubeIcons.pause - : SpotubeIcons.play, - ), - onPressed: onPlay, - ), - ), - const Gap(5), - if (mediaQuery.smAndDown) - const Spacer() - else - const Gap(20), - TrackHeartButton(track: track), - TrackOptionsButton( - track: track, - userPlaylist: false, - ), - const Gap(5), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart deleted file mode 100644 index 66878714..00000000 --- a/lib/provider/audio_player/audio_player.dart +++ /dev/null @@ -1,478 +0,0 @@ -import 'dart:math'; - -import 'package:collection/collection.dart'; -import 'package:drift/drift.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:spotube/extensions/list.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/state.dart'; -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/database/database.dart'; -import 'package:spotube/provider/discord_provider.dart'; -import 'package:spotube/provider/server/sourced_track_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/logger/logger.dart'; - -class AudioPlayerNotifier extends Notifier { - BlackListNotifier get _blacklist => ref.read(blacklistProvider.notifier); - - void _assertAllowedTracks(Iterable tracks) { - assert( - tracks.every( - (track) => - track is SpotubeFullTrackObject || track is SpotubeLocalTrackObject, - ), - 'All tracks must be either SpotubeFullTrackObject or SpotubeLocalTrackObject', - ); - } - - void _assertAllowedTrack(SpotubeTrackObject tracks) { - assert( - tracks is SpotubeFullTrackObject || tracks is SpotubeLocalTrackObject, - 'Track must be either SpotubeFullTrackObject or SpotubeLocalTrackObject', - ); - } - - Future _syncSavedState() async { - final database = ref.read(databaseProvider); - - var playerState = - await database.select(database.audioPlayerStateTable).getSingleOrNull(); - - if (playerState == null) { - await database.into(database.audioPlayerStateTable).insert( - AudioPlayerStateTableCompanion.insert( - playing: audioPlayer.isPlaying, - loopMode: audioPlayer.loopMode, - shuffled: audioPlayer.isShuffled, - collections: [], - tracks: const Value([]), - currentIndex: const Value(0), - id: const Value(0), - ), - ); - - playerState = - await database.select(database.audioPlayerStateTable).getSingle(); - } else { - await audioPlayer.setLoopMode(playerState.loopMode); - await audioPlayer.setShuffle(playerState.shuffled); - } - - final tracks = playerState.tracks; - final currentIndex = playerState.currentIndex; - - if (tracks.isEmpty && state.tracks.isNotEmpty) { - await _updatePlayerState( - AudioPlayerStateTableCompanion( - tracks: Value(state.tracks), - currentIndex: Value(currentIndex), - ), - ); - } else if (tracks.isNotEmpty) { - state = state.copyWith( - tracks: tracks, - currentIndex: currentIndex, - ); - await audioPlayer.openPlaylist( - tracks.asMediaList(), - initialIndex: currentIndex, - autoPlay: false, - ); - } - - if (playerState.collections.isNotEmpty) { - state = state.copyWith( - collections: playerState.collections, - ); - } - } - - Future _updatePlayerState( - AudioPlayerStateTableCompanion companion, - ) async { - final database = ref.read(databaseProvider); - - await (database.update(database.audioPlayerStateTable) - ..where((tb) => tb.id.equals(0))) - .write(companion); - } - - @override - build() { - final subscriptions = [ - audioPlayer.playingStream.listen((playing) async { - try { - state = state.copyWith(playing: playing); - - await _updatePlayerState( - AudioPlayerStateTableCompanion( - playing: Value(playing), - ), - ); - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }), - audioPlayer.loopModeStream.listen((loopMode) async { - try { - state = state.copyWith(loopMode: loopMode); - - await _updatePlayerState( - AudioPlayerStateTableCompanion( - loopMode: Value(loopMode), - ), - ); - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }), - audioPlayer.shuffledStream.listen((shuffled) async { - try { - state = state.copyWith(shuffled: shuffled); - - await _updatePlayerState( - AudioPlayerStateTableCompanion( - shuffled: Value(shuffled), - ), - ); - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }), - audioPlayer.playlistStream.listen((playlist) async { - try { - final tracks = - playlist.medias.map((e) => SpotubeMedia.media(e).track).toList(); - - state = state.copyWith( - tracks: tracks, - currentIndex: playlist.index, - ); - - await _updatePlayerState( - AudioPlayerStateTableCompanion( - currentIndex: Value(state.currentIndex), - tracks: Value(state.tracks), - ), - ); - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }), - ]; - - _syncSavedState(); - - ref.onDispose(() { - for (final subscription in subscriptions) { - subscription.cancel(); - } - }); - - return AudioPlayerState( - loopMode: audioPlayer.loopMode, - playing: audioPlayer.isPlaying, - shuffled: audioPlayer.isShuffled, - tracks: [], - collections: [], - ); - } - - // Collection related methods - Future addCollections(List collectionIds) async { - state = state.copyWith(collections: [ - ...state.collections, - ...collectionIds, - ]); - - await _updatePlayerState( - AudioPlayerStateTableCompanion( - collections: Value(state.collections), - ), - ); - } - - Future addCollection(String collectionId) async { - await addCollections([collectionId]); - } - - Future removeCollections(List collectionIds) async { - state = state.copyWith( - collections: state.collections - .where((element) => !collectionIds.contains(element)) - .toList(), - ); - - await _updatePlayerState( - AudioPlayerStateTableCompanion( - collections: Value(state.collections), - ), - ); - } - - Future removeCollection(String collectionId) async { - await removeCollections([collectionId]); - } - - Future addTracksAtFirst( - Iterable tracks, { - bool allowDuplicates = false, - }) async { - _assertAllowedTracks(tracks); - if (state.tracks.length == 1) { - return addTracks(tracks); - } - - final addableTracks = _blacklist - .filter(tracks) - .where( - (track) => - allowDuplicates || - !state.tracks.any((element) => _compareTracks(element, track)), - ) - .toList(); - - state = state.copyWith( - tracks: [...addableTracks, ...state.tracks], - ); - - for (int i = 0; i < addableTracks.length; i++) { - final track = addableTracks.elementAt(i); - - await audioPlayer.addTrackAt( - SpotubeMedia(track), - max(state.currentIndex, 0) + i + 1, - ); - } - - await _updatePlayerState( - AudioPlayerStateTableCompanion( - tracks: Value(state.tracks), - currentIndex: Value(max(state.currentIndex, 0)), - ), - ); - } - - Future addTrack(SpotubeTrackObject track) async { - _assertAllowedTrack(track); - - if (_blacklist.contains(track)) return; - if (state.tracks.any((element) => _compareTracks(element, track))) return; - - state = state.copyWith( - tracks: [...state.tracks, track], - ); - - await audioPlayer.addTrack(SpotubeMedia(track)); - - await _updatePlayerState( - AudioPlayerStateTableCompanion( - tracks: Value(state.tracks), - currentIndex: Value(max(state.currentIndex, 0)), - ), - ); - } - - Future addTracks(Iterable tracks) async { - _assertAllowedTracks(tracks); - - tracks = _blacklist.filter(tracks).toList(); - state = state.copyWith( - tracks: [...state.tracks, ...tracks], - ); - - for (final track in tracks) { - await audioPlayer.addTrack(SpotubeMedia(track)); - } - - await _updatePlayerState( - AudioPlayerStateTableCompanion( - tracks: Value(state.tracks), - currentIndex: Value(max(state.currentIndex, 0)), - ), - ); - } - - Future removeTrack(String trackId) async { - final index = state.tracks.indexWhere((element) => element.id == trackId); - - if (index == -1) return; - - state = state.copyWith( - tracks: List.of(state.tracks)..removeAt(index), - ); - - await audioPlayer.removeTrack(index); - - await _updatePlayerState( - AudioPlayerStateTableCompanion( - tracks: Value(state.tracks), - currentIndex: Value(max(state.currentIndex, 0)), - ), - ); - } - - Future removeTracks(Iterable trackIds) async { - final trackIndexes = state.tracks - .where((element) => trackIds.any((trackId) => trackId == element.id)) - .mapIndexed((index, element) => index); - - final tracks = state.tracks.where( - (element) => !trackIds.contains(element.id), - ); - - state = state.copyWith( - tracks: tracks.toList(), - ); - - for (final index in trackIndexes) { - await audioPlayer.removeTrack(index); - } - - await _updatePlayerState( - AudioPlayerStateTableCompanion( - tracks: Value(state.tracks), - currentIndex: Value(max(state.currentIndex, 0)), - ), - ); - } - - bool _compareTracks(SpotubeTrackObject a, SpotubeTrackObject b) { - if (a.runtimeType != b.runtimeType) { - return false; - } - - return a is SpotubeLocalTrackObject && b is SpotubeLocalTrackObject - ? a.path == b.path - : a.id == b.id; - } - - Future load( - List tracks, { - int initialIndex = 0, - bool autoPlay = false, - }) async { - _assertAllowedTracks(tracks); - - final medias = _blacklist - .filter(tracks) - .toList() - .asMediaList() - .unique((a, b) => a.uri == b.uri); - - // Giving the initial track a boost so MediaKit won't skip - // because of timeout - final intendedActiveTrack = medias.elementAt(initialIndex); - if (intendedActiveTrack.track is! SpotubeLocalTrackObject) { - ref.read( - sourcedTrackProvider( - intendedActiveTrack.track as SpotubeFullTrackObject, - ).future, - ); - } - - if (medias.isEmpty) return; - - state = state.copyWith( - // These are filtered tracks as well - tracks: medias.map((media) => media.track).toList(), - currentIndex: initialIndex, - collections: [], - ); - - await audioPlayer.openPlaylist( - medias, - initialIndex: initialIndex, - autoPlay: autoPlay, - ); - - await _updatePlayerState( - AudioPlayerStateTableCompanion( - tracks: Value(state.tracks), - currentIndex: Value(max(state.currentIndex, 0)), - ), - ); - } - - Future swapActiveSource() async { - if (state.tracks.isEmpty || state.activeTrack is! SpotubeFullTrackObject) { - return; - } - - final oldState = state; - await audioPlayer.stop(); - - await load( - oldState.tracks, - initialIndex: oldState.currentIndex, - autoPlay: true, - ); - state = state.copyWith( - collections: oldState.collections, - loopMode: oldState.loopMode, - playing: oldState.playing, - shuffled: false, - ); - await audioPlayer.setLoopMode(oldState.loopMode); - await _updatePlayerState( - AudioPlayerStateTableCompanion( - tracks: Value(state.tracks), - currentIndex: Value(state.currentIndex), - collections: Value(state.collections), - loopMode: Value(state.loopMode), - playing: Value(state.playing), - shuffled: Value(state.shuffled), - ), - ); - } - - Future jumpToTrack(SpotubeTrackObject track) async { - final index = - state.tracks.toList().indexWhere((element) => element.id == track.id); - if (index == -1) return; - await audioPlayer.jumpTo(index); - } - - Future moveTrack(int oldIndex, int newIndex) async { - if (oldIndex == newIndex || - newIndex < 0 || - oldIndex < 0 || - newIndex > state.tracks.length - 1 || - oldIndex > state.tracks.length - 1) { - return; - } - - await audioPlayer.moveTrack(oldIndex, newIndex); - } - - Future stop() async { - state = state.copyWith( - tracks: [], - currentIndex: 0, - collections: [], - loopMode: PlaylistMode.none, - playing: false, - shuffled: false, - ); - await audioPlayer.stop(); - await _updatePlayerState( - AudioPlayerStateTableCompanion( - tracks: Value(state.tracks), - currentIndex: const Value(0), - collections: const Value([]), - loopMode: const Value(PlaylistMode.none), - playing: const Value(false), - shuffled: const Value(false), - ), - ); - ref.read(discordProvider.notifier).clear(); - } -} - -final audioPlayerProvider = - NotifierProvider( - () => AudioPlayerNotifier(), -); diff --git a/lib/provider/audio_player/audio_player_streams.dart b/lib/provider/audio_player/audio_player_streams.dart deleted file mode 100644 index eff13134..00000000 --- a/lib/provider/audio_player/audio_player_streams.dart +++ /dev/null @@ -1,175 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/audio_player/state.dart'; -import 'package:spotube/provider/discord_provider.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/metadata_plugin/core/scrobble.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/server/sourced_track_provider.dart'; -import 'package:spotube/provider/skip_segments/skip_segments.dart'; -import 'package:spotube/provider/scrobbler/scrobbler.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_services/audio_services.dart'; -import 'package:spotube/services/logger/logger.dart'; - -class AudioPlayerStreamListeners { - final Ref ref; - late final AudioServices notificationService; - AudioPlayerStreamListeners(this.ref) { - AudioServices.create(ref, ref.read(audioPlayerProvider.notifier)).then( - (value) => notificationService = value, - ); - - final subscriptions = [ - subscribeToPlaylist(), - subscribeToSkipSponsor(), - subscribeToScrobbleChanged(), - subscribeToPosition(), - subscribeToPlayerError(), - ]; - - ref.onDispose(() { - for (final subscription in subscriptions) { - subscription.cancel(); - } - }); - } - - ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier); - UserPreferences get preferences => ref.read(userPreferencesProvider); - DiscordNotifier get discord => ref.read(discordProvider.notifier); - AudioPlayerState get audioPlayerState => ref.read(audioPlayerProvider); - PlaybackHistoryActions get history => - ref.read(playbackHistoryActionsProvider); - - StreamSubscription subscribeToPlaylist() { - return audioPlayer.playlistStream.listen((mpvPlaylist) { - try { - if (audioPlayerState.activeTrack == null) return; - notificationService.addTrack(audioPlayerState.activeTrack!); - discord.updatePresence(audioPlayerState.activeTrack!); - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }); - } - - StreamSubscription subscribeToSkipSponsor() { - return audioPlayer.positionStream.listen((position) async { - try { - final currentSegments = await ref.read(segmentProvider.future); - - if (currentSegments?.segments.isNotEmpty != true || - position < const Duration(seconds: 3)) { - return; - } - - for (final segment in currentSegments!.segments) { - final seconds = position.inSeconds; - - if (seconds < segment.start || seconds >= segment.end) continue; - - await audioPlayer.seek(Duration(seconds: segment.end + 1)); - } - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }); - } - - StreamSubscription subscribeToScrobbleChanged() { - String? lastScrobbled; - return audioPlayer.positionStream.listen((position) async { - try { - final uid = audioPlayerState.activeTrack is SpotubeLocalTrackObject - ? (audioPlayerState.activeTrack as SpotubeLocalTrackObject).path - : audioPlayerState.activeTrack?.id; - - /// According to Listenbrainz and Last.fm, a scrobble should be sent - /// after 4 minutes of listening or 50% of the track duration, - /// whichever is less. - final minimumListenTime = min(audioPlayer.duration.inSeconds ~/ 2, 240); - - if (audioPlayerState.activeTrack == null || - lastScrobbled == uid || - position.inSeconds < minimumListenTime || - audioPlayer.duration == Duration.zero || - position == Duration.zero) { - return; - } - - scrobbler.scrobble(audioPlayerState.activeTrack!); - ref - .read(metadataPluginScrobbleProvider.notifier) - .scrobble(audioPlayerState.activeTrack!); - lastScrobbled = uid; - - /// The [Track] from Playlist.getTracks doesn't contain artist images - /// so we need to fetch them from the API - var activeTrack = audioPlayerState.activeTrack!; - if (activeTrack.artists.any((a) => a.images == null)) { - final metadataPlugin = await ref.read(metadataPluginProvider.future); - final artists = await Future.wait( - activeTrack.artists - .map((artist) => metadataPlugin!.artist.getArtist(artist.id)), - ); - activeTrack = activeTrack.copyWith( - artists: artists - .map((e) => SpotubeSimpleArtistObject.fromJson(e.toJson())) - .toList(), - ); - } - - await history.addTrack(activeTrack); - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }); - } - - StreamSubscription subscribeToPosition() { - String lastTrack = ""; // used to prevent multiple calls to the same track - return audioPlayer.positionStream.listen((event) async { - final percentProgress = - (event.inSeconds / max(audioPlayer.duration.inSeconds, 1)) * 100; - try { - if (percentProgress < 80 || - audioPlayerState.currentIndex == -1 || - audioPlayerState.currentIndex == - audioPlayerState.tracks.length - 1) { - return; - } - final nextTrack = audioPlayerState.tracks - .elementAtOrNull(audioPlayerState.currentIndex + 1); - - if (nextTrack == null || - lastTrack == nextTrack.id || - nextTrack is SpotubeLocalTrackObject) { - return; - } - - try { - await ref.read( - sourcedTrackProvider(nextTrack as SpotubeFullTrackObject).future, - ); - } finally { - lastTrack = nextTrack.id; - } - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }); - } - - StreamSubscription subscribeToPlayerError() { - return audioPlayer.errorStream.listen((event) {}); - } -} - -final audioPlayerStreamListenersProvider = - Provider(AudioPlayerStreamListeners.new); diff --git a/lib/provider/audio_player/querying_track_info.dart b/lib/provider/audio_player/querying_track_info.dart deleted file mode 100644 index 06e9653c..00000000 --- a/lib/provider/audio_player/querying_track_info.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/server/sourced_track_provider.dart'; - -final queryingTrackInfoProvider = Provider((ref) { - final audioPlayer = ref.watch(audioPlayerProvider); - - if (audioPlayer.activeTrack == null) { - return false; - } - - if (audioPlayer.activeTrack is! SpotubeFullTrackObject) { - return false; - } - - return ref - .watch( - sourcedTrackProvider( - audioPlayer.activeTrack! as SpotubeFullTrackObject), - ) - .isLoading; -}); diff --git a/lib/provider/audio_player/state.dart b/lib/provider/audio_player/state.dart deleted file mode 100644 index d62155f3..00000000 --- a/lib/provider/audio_player/state.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:media_kit/media_kit.dart' hide Track; -import 'package:spotube/models/metadata/metadata.dart'; - -part 'state.freezed.dart'; -part 'state.g.dart'; - -@freezed -class AudioPlayerState with _$AudioPlayerState { - const AudioPlayerState._(); - - factory AudioPlayerState._inner({ - required bool playing, - required PlaylistMode loopMode, - required bool shuffled, - required List collections, - @Default(0) int currentIndex, - @Default([]) List tracks, - }) = _AudioPlayerState; - - factory AudioPlayerState({ - required bool playing, - required PlaylistMode loopMode, - required bool shuffled, - required List collections, - int currentIndex = 0, - List tracks = const [], - }) { - assert( - tracks.every((track) => - track is SpotubeFullTrackObject || track is SpotubeLocalTrackObject), - 'All tracks must be either SpotubeFullTrackObject or SpotubeLocalTrackObject', - ); - - return AudioPlayerState._inner( - playing: playing, - loopMode: loopMode, - shuffled: shuffled, - currentIndex: currentIndex, - tracks: tracks, - collections: collections, - ); - } - - factory AudioPlayerState.fromJson(Map json) => - _$AudioPlayerStateFromJson(json); - - SpotubeTrackObject? get activeTrack { - if (currentIndex < 0 || currentIndex >= tracks.length) return null; - return tracks[currentIndex]; - } - - bool containsTrack(SpotubeTrackObject track) { - return tracks.isNotEmpty && - tracks.any( - (t) => - t is SpotubeLocalTrackObject && track is SpotubeLocalTrackObject - ? t.path == track.path - : t.id == track.id, - ); - } - - bool containsTracks(List tracks) { - return this.tracks.isNotEmpty && tracks.every(containsTrack); - } - - bool containsCollection(String collectionId) { - return collections.contains(collectionId); - } -} diff --git a/lib/provider/audio_player/state.freezed.dart b/lib/provider/audio_player/state.freezed.dart deleted file mode 100644 index 0299cd2f..00000000 --- a/lib/provider/audio_player/state.freezed.dart +++ /dev/null @@ -1,297 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'state.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -AudioPlayerState _$AudioPlayerStateFromJson(Map json) { - return _AudioPlayerState.fromJson(json); -} - -/// @nodoc -mixin _$AudioPlayerState { - bool get playing => throw _privateConstructorUsedError; - PlaylistMode get loopMode => throw _privateConstructorUsedError; - bool get shuffled => throw _privateConstructorUsedError; - List get collections => throw _privateConstructorUsedError; - int get currentIndex => throw _privateConstructorUsedError; - List get tracks => throw _privateConstructorUsedError; - - /// Serializes this AudioPlayerState to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of AudioPlayerState - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $AudioPlayerStateCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $AudioPlayerStateCopyWith<$Res> { - factory $AudioPlayerStateCopyWith( - AudioPlayerState value, $Res Function(AudioPlayerState) then) = - _$AudioPlayerStateCopyWithImpl<$Res, AudioPlayerState>; - @useResult - $Res call( - {bool playing, - PlaylistMode loopMode, - bool shuffled, - List collections, - int currentIndex, - List tracks}); -} - -/// @nodoc -class _$AudioPlayerStateCopyWithImpl<$Res, $Val extends AudioPlayerState> - implements $AudioPlayerStateCopyWith<$Res> { - _$AudioPlayerStateCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of AudioPlayerState - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? playing = null, - Object? loopMode = null, - Object? shuffled = null, - Object? collections = null, - Object? currentIndex = null, - Object? tracks = null, - }) { - return _then(_value.copyWith( - playing: null == playing - ? _value.playing - : playing // ignore: cast_nullable_to_non_nullable - as bool, - loopMode: null == loopMode - ? _value.loopMode - : loopMode // ignore: cast_nullable_to_non_nullable - as PlaylistMode, - shuffled: null == shuffled - ? _value.shuffled - : shuffled // ignore: cast_nullable_to_non_nullable - as bool, - collections: null == collections - ? _value.collections - : collections // ignore: cast_nullable_to_non_nullable - as List, - currentIndex: null == currentIndex - ? _value.currentIndex - : currentIndex // ignore: cast_nullable_to_non_nullable - as int, - tracks: null == tracks - ? _value.tracks - : tracks // ignore: cast_nullable_to_non_nullable - as List, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$AudioPlayerStateImplCopyWith<$Res> - implements $AudioPlayerStateCopyWith<$Res> { - factory _$$AudioPlayerStateImplCopyWith(_$AudioPlayerStateImpl value, - $Res Function(_$AudioPlayerStateImpl) then) = - __$$AudioPlayerStateImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {bool playing, - PlaylistMode loopMode, - bool shuffled, - List collections, - int currentIndex, - List tracks}); -} - -/// @nodoc -class __$$AudioPlayerStateImplCopyWithImpl<$Res> - extends _$AudioPlayerStateCopyWithImpl<$Res, _$AudioPlayerStateImpl> - implements _$$AudioPlayerStateImplCopyWith<$Res> { - __$$AudioPlayerStateImplCopyWithImpl(_$AudioPlayerStateImpl _value, - $Res Function(_$AudioPlayerStateImpl) _then) - : super(_value, _then); - - /// Create a copy of AudioPlayerState - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? playing = null, - Object? loopMode = null, - Object? shuffled = null, - Object? collections = null, - Object? currentIndex = null, - Object? tracks = null, - }) { - return _then(_$AudioPlayerStateImpl( - playing: null == playing - ? _value.playing - : playing // ignore: cast_nullable_to_non_nullable - as bool, - loopMode: null == loopMode - ? _value.loopMode - : loopMode // ignore: cast_nullable_to_non_nullable - as PlaylistMode, - shuffled: null == shuffled - ? _value.shuffled - : shuffled // ignore: cast_nullable_to_non_nullable - as bool, - collections: null == collections - ? _value._collections - : collections // ignore: cast_nullable_to_non_nullable - as List, - currentIndex: null == currentIndex - ? _value.currentIndex - : currentIndex // ignore: cast_nullable_to_non_nullable - as int, - tracks: null == tracks - ? _value._tracks - : tracks // ignore: cast_nullable_to_non_nullable - as List, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$AudioPlayerStateImpl extends _AudioPlayerState { - _$AudioPlayerStateImpl( - {required this.playing, - required this.loopMode, - required this.shuffled, - required final List collections, - this.currentIndex = 0, - final List tracks = const []}) - : _collections = collections, - _tracks = tracks, - super._(); - - factory _$AudioPlayerStateImpl.fromJson(Map json) => - _$$AudioPlayerStateImplFromJson(json); - - @override - final bool playing; - @override - final PlaylistMode loopMode; - @override - final bool shuffled; - final List _collections; - @override - List get collections { - if (_collections is EqualUnmodifiableListView) return _collections; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_collections); - } - - @override - @JsonKey() - final int currentIndex; - final List _tracks; - @override - @JsonKey() - List get tracks { - if (_tracks is EqualUnmodifiableListView) return _tracks; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_tracks); - } - - @override - String toString() { - return 'AudioPlayerState._inner(playing: $playing, loopMode: $loopMode, shuffled: $shuffled, collections: $collections, currentIndex: $currentIndex, tracks: $tracks)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$AudioPlayerStateImpl && - (identical(other.playing, playing) || other.playing == playing) && - (identical(other.loopMode, loopMode) || - other.loopMode == loopMode) && - (identical(other.shuffled, shuffled) || - other.shuffled == shuffled) && - const DeepCollectionEquality() - .equals(other._collections, _collections) && - (identical(other.currentIndex, currentIndex) || - other.currentIndex == currentIndex) && - const DeepCollectionEquality().equals(other._tracks, _tracks)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash( - runtimeType, - playing, - loopMode, - shuffled, - const DeepCollectionEquality().hash(_collections), - currentIndex, - const DeepCollectionEquality().hash(_tracks)); - - /// Create a copy of AudioPlayerState - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$AudioPlayerStateImplCopyWith<_$AudioPlayerStateImpl> get copyWith => - __$$AudioPlayerStateImplCopyWithImpl<_$AudioPlayerStateImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$AudioPlayerStateImplToJson( - this, - ); - } -} - -abstract class _AudioPlayerState extends AudioPlayerState { - factory _AudioPlayerState( - {required final bool playing, - required final PlaylistMode loopMode, - required final bool shuffled, - required final List collections, - final int currentIndex, - final List tracks}) = _$AudioPlayerStateImpl; - _AudioPlayerState._() : super._(); - - factory _AudioPlayerState.fromJson(Map json) = - _$AudioPlayerStateImpl.fromJson; - - @override - bool get playing; - @override - PlaylistMode get loopMode; - @override - bool get shuffled; - @override - List get collections; - @override - int get currentIndex; - @override - List get tracks; - - /// Create a copy of AudioPlayerState - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$AudioPlayerStateImplCopyWith<_$AudioPlayerStateImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/provider/audio_player/state.g.dart b/lib/provider/audio_player/state.g.dart deleted file mode 100644 index de5f6f1c..00000000 --- a/lib/provider/audio_player/state.g.dart +++ /dev/null @@ -1,40 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'state.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$AudioPlayerStateImpl _$$AudioPlayerStateImplFromJson(Map json) => - _$AudioPlayerStateImpl( - playing: json['playing'] as bool, - loopMode: $enumDecode(_$PlaylistModeEnumMap, json['loopMode']), - shuffled: json['shuffled'] as bool, - collections: (json['collections'] as List) - .map((e) => e as String) - .toList(), - currentIndex: (json['currentIndex'] as num?)?.toInt() ?? 0, - tracks: (json['tracks'] as List?) - ?.map((e) => SpotubeTrackObject.fromJson( - Map.from(e as Map))) - .toList() ?? - const [], - ); - -Map _$$AudioPlayerStateImplToJson( - _$AudioPlayerStateImpl instance) => - { - 'playing': instance.playing, - 'loopMode': _$PlaylistModeEnumMap[instance.loopMode]!, - 'shuffled': instance.shuffled, - 'collections': instance.collections, - 'currentIndex': instance.currentIndex, - 'tracks': instance.tracks.map((e) => e.toJson()).toList(), - }; - -const _$PlaylistModeEnumMap = { - PlaylistMode.none: 'none', - PlaylistMode.single: 'single', - PlaylistMode.loop: 'loop', -}; diff --git a/lib/provider/blacklist_provider.dart b/lib/provider/blacklist_provider.dart deleted file mode 100644 index f916c491..00000000 --- a/lib/provider/blacklist_provider.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/database/database.dart'; - -class BlackListNotifier extends AsyncNotifier> { - @override - build() async { - final database = ref.watch(databaseProvider); - - final subscription = database - .select(database.blacklistTable) - .watch() - .listen((event) => state = AsyncData(event)); - - ref.onDispose(() { - subscription.cancel(); - }); - - return await database.select(database.blacklistTable).get(); - } - - AppDatabase get _database => ref.read(databaseProvider); - - Future add(BlacklistTableCompanion element) async { - _database.into(_database.blacklistTable).insert(element); - } - - Future remove(String elementId) async { - await (_database.delete(_database.blacklistTable) - ..where((tbl) => tbl.elementId.equals(elementId))) - .go(); - } - - bool contains(SpotubeTrackObject track) { - final containsTrack = - state.asData?.value.any((element) => element.elementId == track.id) ?? - false; - - final containsTrackArtists = track.artists.any( - (artist) => - state.asData?.value.any((el) => el.elementId == artist.id) ?? false, - ); - - return containsTrack || containsTrackArtists; - } - - bool containsArtist(String artistId) { - return state.asData?.value - .any((element) => element.elementId == artistId) ?? - false; - } - - /// Filters the non blacklisted tracks from the given [tracks] - Iterable filter(Iterable tracks) { - return tracks.whereNot(contains).toList(); - } -} - -final blacklistProvider = - AsyncNotifierProvider>( - () => BlackListNotifier(), -); diff --git a/lib/provider/connect/clients.dart b/lib/provider/connect/clients.dart deleted file mode 100644 index 51578a7b..00000000 --- a/lib/provider/connect/clients.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:bonsoir/bonsoir.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/services/device_info/device_info.dart'; -import 'package:spotube/services/logger/logger.dart'; - -class ConnectClientsState { - final List services; - final ResolvedBonsoirService? resolvedService; - final BonsoirDiscovery discovery; - - ConnectClientsState({ - required this.services, - required this.discovery, - this.resolvedService, - }); - - ConnectClientsState copyWith({ - List? services, - BonsoirDiscovery? discovery, - ResolvedBonsoirService? resolvedService, - }) { - return ConnectClientsState( - services: services ?? this.services, - discovery: discovery ?? this.discovery, - resolvedService: resolvedService ?? this.resolvedService, - ); - } -} - -class ConnectClientsNotifier extends AsyncNotifier { - ConnectClientsNotifier(); - - @override - build() async { - final discovery = BonsoirDiscovery(type: '_spotube._tcp'); - final deviceId = await DeviceInfoService.instance.deviceId(); - await discovery.ready; - - final subscription = discovery.eventStream?.listen((event) { - // ignore device itself - try { - if (event.service?.attributes["deviceId"] == deviceId) { - return; - } - - switch (event.type) { - case BonsoirDiscoveryEventType.discoveryServiceFound: - state = AsyncData(state.value!.copyWith( - services: [ - ...?state.value?.services, - event.service!, - ], - )); - break; - case BonsoirDiscoveryEventType.discoveryServiceResolved: - state = AsyncData( - state.value!.copyWith( - resolvedService: event.service as ResolvedBonsoirService, - ), - ); - break; - case BonsoirDiscoveryEventType.discoveryServiceLost: - state = AsyncData( - ConnectClientsState( - services: state.value!.services - .where((s) => s.name != event.service!.name) - .toList(), - discovery: state.value!.discovery, - resolvedService: state.value?.resolvedService != null && - event.service?.name == - state.value?.resolvedService?.name - ? null - : state.value!.resolvedService, - ), - ); - break; - default: - break; - } - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }); - - ref.onDispose(() { - subscription?.cancel(); - discovery.stop(); - }); - - await discovery.start(); - - return ConnectClientsState( - services: [], - discovery: discovery, - ); - } - - Future resolveService(BonsoirService service) async { - if (state.value == null) return; - await service.resolve(state.value!.discovery.serviceResolver); - } - - Future clearResolvedService() async { - if (state.value == null) return; - state = AsyncData( - ConnectClientsState( - services: state.value!.services, - discovery: state.value!.discovery, - ), - ); - } -} - -final connectClientsProvider = - AsyncNotifierProvider( - () => ConnectClientsNotifier(), -); diff --git a/lib/provider/connect/connect.dart b/lib/provider/connect/connect.dart deleted file mode 100644 index 268b6567..00000000 --- a/lib/provider/connect/connect.dart +++ /dev/null @@ -1,230 +0,0 @@ -import 'dart:convert'; - -import 'package:media_kit/media_kit.dart' hide Track; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/state.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/models/connect/connect.dart'; - -import 'package:spotube/provider/connect/clients.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; -import 'package:web_socket_channel/status.dart' as status; - -final playingProvider = StateProvider( - (ref) => false, -); - -final positionProvider = StateProvider( - (ref) => Duration.zero, -); - -final durationProvider = StateProvider( - (ref) => Duration.zero, -); - -final shuffleProvider = StateProvider( - (ref) => false, -); - -final loopModeProvider = StateProvider( - (ref) => PlaylistMode.none, -); - -final queueProvider = StateProvider( - (ref) => AudioPlayerState( - playing: audioPlayer.isPlaying, - loopMode: audioPlayer.loopMode, - shuffled: audioPlayer.isShuffled, - tracks: [], - currentIndex: 0, - collections: [], - ), -); - -final volumeProvider = StateProvider( - (ref) => 1.0, -); - -typedef ConnectState = ({WebSocketChannel channel, Stream stream}); - -class ConnectNotifier extends AsyncNotifier { - @override - build() async { - try { - final connectClients = await ref.watch(connectClientsProvider.future); - - if (connectClients.resolvedService == null) return null; - - final service = connectClients.resolvedService!; - - AppLogger.log.t( - '♾️ Connecting to ${service.name}: ws://${service.host}:${service.port}/ws', - ); - - final channel = WebSocketChannel.connect( - Uri.parse('ws://${service.host}:${service.port}/ws'), - ); - - await channel.ready; - - AppLogger.log.t( - '✅ Connected to ${service.name}: ws://${service.host}:${service.port}/ws', - ); - - final stream = channel.stream.asBroadcastStream(); - - final subscription = stream.listen( - (message) { - final event = - WebSocketEvent.fromJson(jsonDecode(message), (data) => data); - - event.onQueue((event) { - ref.read(queueProvider.notifier).state = event.data; - }); - - event.onPlaying((event) { - ref.read(playingProvider.notifier).state = event.data; - }); - - event.onPosition((event) { - ref.read(positionProvider.notifier).state = event.data; - }); - - event.onDuration((event) { - ref.read(durationProvider.notifier).state = event.data; - }); - - event.onShuffle((event) { - ref.read(shuffleProvider.notifier).state = event.data; - }); - - event.onLoop((event) { - ref.read(loopModeProvider.notifier).state = event.data; - }); - - event.onVolume((event) { - ref.read(volumeProvider.notifier).state = event.data; - }); - - event.onError((event) { - if (event.data == "Connection denied") { - ref.read(connectClientsProvider.notifier).clearResolvedService(); - - if (rootNavigatorKey.currentContext?.mounted == true) { - final theme = Theme.of(rootNavigatorKey.currentContext!); - - showToast( - context: rootNavigatorKey.currentContext!, - location: ToastLocation.topRight, - dismissible: true, - builder: (context, overlay) { - return SurfaceCard( - fillColor: theme.colorScheme.destructive, - filled: true, - child: Basic( - leading: const Icon(SpotubeIcons.error), - title: Text( - context.l10n.connection_request_denied, - style: theme.typography.normal.copyWith( - color: theme.colorScheme.destructiveForeground, - ), - ), - leadingAlignment: Alignment.center, - ), - ); - }, - ); - } - } - }); - }, - onError: (error) { - AppLogger.reportError(error, StackTrace.current); - }, - ); - - ref.onDispose(() { - subscription.cancel(); - channel.sink.close(status.goingAway); - }); - - return (channel: channel, stream: stream); - } catch (e, stack) { - AppLogger.reportError(e, stack); - rethrow; - } - } - - Future emit(Object message) async { - if (state.value == null) return; - state.value?.channel.sink.add( - message is String ? message : (message as dynamic).toJson(), - ); - } - - Future resume() async { - emit(WebSocketResumeEvent()); - } - - Future pause() async { - emit(WebSocketPauseEvent()); - } - - Future stop() async { - emit(WebSocketStopEvent()); - } - - Future jumpTo(int position) async { - emit(WebSocketJumpEvent(position)); - } - - Future load(WebSocketLoadEventData data) async { - emit(WebSocketLoadEvent(data)); - } - - Future next() async { - emit(WebSocketNextEvent()); - } - - Future previous() async { - emit(WebSocketPreviousEvent()); - } - - Future seek(Duration position) async { - emit(WebSocketSeekEvent(position)); - } - - Future setShuffle(bool value) async { - emit(WebSocketShuffleEvent(value)); - } - - Future setLoopMode(PlaylistMode value) async { - emit(WebSocketLoopEvent(value)); - } - - Future addTrack(SpotubeFullTrackObject data) async { - emit(WebSocketAddTrackEvent(data)); - } - - Future removeTrack(String data) async { - emit(WebSocketRemoveTrackEvent(data)); - } - - Future reorder(ReorderData data) async { - emit(WebSocketReorderEvent(data)); - } - - Future setVolume(double value) async { - emit(WebSocketVolumeEvent(value)); - } -} - -final connectProvider = AsyncNotifierProvider( - () => ConnectNotifier(), -); diff --git a/lib/provider/database/database.dart b/lib/provider/database/database.dart deleted file mode 100644 index 95976e56..00000000 --- a/lib/provider/database/database.dart +++ /dev/null @@ -1,4 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/database/database.dart'; - -final databaseProvider = Provider((ref) => AppDatabase()); diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart deleted file mode 100644 index fb1c41b1..00000000 --- a/lib/provider/discord_provider.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_discord_rpc/flutter_discord_rpc.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:spotube/utils/platform.dart'; - -class DiscordNotifier extends AsyncNotifier { - @override - FutureOr build() async { - if (!kIsDesktop) return; - - final enabled = ref.watch( - userPreferencesProvider.select((s) => s.discordPresence && kIsDesktop)); - - var lastPosition = audioPlayer.position; - - final subscriptions = [ - FlutterDiscordRPC.instance.isConnectedStream.listen((connected) async { - try { - final playback = ref.read(audioPlayerProvider); - if (connected && playback.activeTrack != null) { - await updatePresence(playback.activeTrack!); - } - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }), - audioPlayer.playerStateStream.listen((state) async { - try { - final playback = ref.read(audioPlayerProvider); - if (playback.activeTrack == null) return; - - await updatePresence(ref.read(audioPlayerProvider).activeTrack!); - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }), - audioPlayer.positionStream.listen((position) async { - try { - final playback = ref.read(audioPlayerProvider); - if (playback.activeTrack != null) { - final diff = position.inMilliseconds - lastPosition.inMilliseconds; - if (diff > 500 || diff < -500) { - await updatePresence(ref.read(audioPlayerProvider).activeTrack!); - } - } - lastPosition = position; - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }) - ]; - - ref.onDispose(() async { - for (final subscription in subscriptions) { - subscription.cancel(); - } - await clear(); - await close(); - await FlutterDiscordRPC.instance.dispose(); - }); - - if (!enabled && FlutterDiscordRPC.instance.isConnected) { - await clear(); - await close(); - } else if (enabled) { - await FlutterDiscordRPC.instance.connect(autoRetry: true); - } - } - - Future updatePresence(SpotubeTrackObject track) async { - if (!kIsDesktop) return; - if (FlutterDiscordRPC.instance.isConnected == false) return; - final artistNames = track.artists.asString(); - final isPlaying = audioPlayer.isPlaying; - final position = audioPlayer.position; - - await FlutterDiscordRPC.instance.setActivity( - activity: RPCActivity( - details: track.name, - state: artistNames, - assets: RPCAssets( - largeImage: - track.album.images.firstOrNull?.url ?? "spotube-logo-foreground", - largeText: track.album.name, - smallImage: "spotube-logo-foreground", - smallText: "Spotube", - ), - buttons: [ - RPCButton( - label: "Listen on Spotube", - url: track.externalUri, - ), - ], - timestamps: RPCTimestamps( - start: isPlaying - ? DateTime.now().millisecondsSinceEpoch - position.inMilliseconds - : null, - ), - activityType: ActivityType.listening, - ), - ); - } - - Future clear() async { - if (!kIsDesktop) return; - await FlutterDiscordRPC.instance.clearActivity(); - } - - Future close() async { - if (!kIsDesktop) return; - await FlutterDiscordRPC.instance.disconnect(); - } -} - -final discordProvider = - AsyncNotifierProvider(() => DiscordNotifier()); diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart deleted file mode 100644 index 0ca99ec1..00000000 --- a/lib/provider/download_manager_provider.dart +++ /dev/null @@ -1,285 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:dio/dio.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:metadata_god/metadata_god.dart'; -import 'package:path/path.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide join; -import 'package:spotube/collections/routes.dart'; -import 'package:spotube/components/dialogs/replace_downloaded_dialog.dart'; -import 'package:spotube/extensions/dio.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/audio_source/quality_presets.dart'; -import 'package:spotube/provider/server/sourced_track_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:spotube/utils/service_utils.dart'; - -enum DownloadStatus { - queued, - downloading, - completed, - failed, - canceled, -} - -class DownloadTask { - final SpotubeFullTrackObject track; - final DownloadStatus status; - final CancelToken cancelToken; - final int? totalSizeBytes; - final StreamController _downloadedBytesStreamController; - - Stream get downloadedBytesStream => - _downloadedBytesStreamController.stream; - - DownloadTask({ - required this.track, - required this.status, - required this.cancelToken, - this.totalSizeBytes, - StreamController? downloadedBytesStreamController, - }) : _downloadedBytesStreamController = - downloadedBytesStreamController ?? StreamController.broadcast(); - - DownloadTask copyWith({ - SpotubeFullTrackObject? track, - DownloadStatus? status, - CancelToken? cancelToken, - int? totalSizeBytes, - StreamController? downloadedBytesStreamController, - }) { - return DownloadTask( - track: track ?? this.track, - status: status ?? this.status, - cancelToken: cancelToken ?? this.cancelToken, - totalSizeBytes: totalSizeBytes ?? this.totalSizeBytes, - downloadedBytesStreamController: - downloadedBytesStreamController ?? _downloadedBytesStreamController, - ); - } -} - -class DownloadManagerNotifier extends Notifier> { - final Dio dio; - DownloadManagerNotifier() - : dio = Dio(), - super(); - - @override - build() { - ref.onDispose(() { - for (final task in state) { - if (task.status == DownloadStatus.downloading) { - task.cancelToken.cancel(); - } - task._downloadedBytesStreamController.close(); - } - }); - - return []; - } - - DownloadTask? getTaskByTrackId(String trackId) { - return state.firstWhereOrNull((element) => element.track.id == trackId); - } - - void addToQueue(SpotubeFullTrackObject track) { - if (state.any((element) => element.track.id == track.id)) return; - state = [ - ...state, - DownloadTask( - track: track, - status: DownloadStatus.queued, - cancelToken: CancelToken(), - ), - ]; - - ref.read(sourcedTrackProvider(track)); - - _startDownloading(); // No await should be invoked to avoid stuck UI - } - - void addAllToQueue(List tracks) { - state = [ - ...state, - ...tracks.map((e) => DownloadTask( - track: e, - status: DownloadStatus.queued, - cancelToken: CancelToken(), - )), - ]; - - ref.read(sourcedTrackProvider(tracks.first)); - _startDownloading(); // No await should be invoked to avoid stuck UI - } - - void retry(SpotubeFullTrackObject track) { - if (state.firstWhereOrNull((e) => e.track.id == track.id)?.status - case DownloadStatus.canceled || DownloadStatus.failed) { - _setStatus(track, DownloadStatus.queued); - _startDownloading(); // No await should be invoked to avoid stuck UI - } - } - - void cancel(SpotubeFullTrackObject track) { - if (state.firstWhereOrNull((e) => e.track.id == track.id)?.status == - DownloadStatus.failed) { - return; - } - _setStatus(track, DownloadStatus.canceled); - } - - void clearAll() { - for (final task in state) { - if (task.status == DownloadStatus.downloading) { - task.cancelToken.cancel(); - } - } - state = []; - } - - void _setStatus(SpotubeFullTrackObject track, DownloadStatus status) { - state = state.map((e) { - if (e.track.id == track.id) { - if ((status == DownloadStatus.canceled) && e.cancelToken.isCancelled) { - e.cancelToken.cancel(); - } - - return e.copyWith(status: status); - } - return e; - }).toList(); - } - - bool _isShowingDialog = false; - - Future _shouldReplaceFileOnExist(DownloadTask task) async { - if (rootNavigatorKey.currentContext == null || _isShowingDialog) { - return false; - } - final replaceAll = ref.read(replaceDownloadedFileState); - if (replaceAll != null) return replaceAll; - _isShowingDialog = true; - try { - return await showDialog( - context: rootNavigatorKey.currentContext!, - builder: (context) => ReplaceDownloadedDialog( - track: task.track, - ), - ) ?? - false; - } finally { - _isShowingDialog = false; - } - } - - Future _downloadTrack(DownloadTask task) async { - try { - _setStatus(task.track, DownloadStatus.downloading); - final track = await ref.read(sourcedTrackProvider(task.track).future); - if (task.cancelToken.isCancelled) { - _setStatus(task.track, DownloadStatus.canceled); - } - final presets = ref.read(audioSourcePresetsProvider); - final container = - presets.presets[presets.selectedDownloadingContainerIndex]; - final downloadLocation = ref.read( - userPreferencesProvider.select((value) => value.downloadLocation)); - - final url = track.getUrlOfQuality( - container, - presets.selectedDownloadingQualityIndex, - ); - - if (url == null) { - throw Exception("No download URL found for selected codec"); - } - - final savePath = join( - downloadLocation, - ServiceUtils.sanitizeFilename( - "${track.query.name} - ${track.query.artists.map((e) => e.name).join(", ")}.${container.getFileExtension()}", - ), - ); - - final savePathFile = File(savePath); - if (await savePathFile.exists()) { - // dio automatically replaces the file if it exists so no deletion required - if (!await _shouldReplaceFileOnExist(task)) { - _setStatus(track.query, DownloadStatus.completed); - return; - } - } - - final response = await dio.chunkDownload( - url, - savePath, - cancelToken: task.cancelToken, - onReceiveProgress: (count, total) { - if (task.totalSizeBytes == null) { - state = state.map((e) { - if (e.track.id == track.query.id) { - return e.copyWith(totalSizeBytes: total); - } - return e; - }).toList(); - } - task._downloadedBytesStreamController.add(count); - }, - deleteOnError: true, - fileAccessMode: FileAccessMode.write, - ); - if (response.statusCode != null && response.statusCode! < 400) { - _setStatus(track.query, DownloadStatus.completed); - } else { - _setStatus(track.query, DownloadStatus.failed); - return; - } - - if (container.getFileExtension() == "weba") return; - - final imageBytes = await ServiceUtils.downloadImage( - (task.track.album.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - index: 1, - ), - ); - await MetadataGod.writeMetadata( - file: savePath, - metadata: task.track.toMetadata( - fileLength: await savePathFile.length(), - imageBytes: imageBytes, - ), - ); - } catch (e, stack) { - if (e is! DioException || e.type != DioExceptionType.cancel) { - _setStatus(task.track, DownloadStatus.failed); - AppLogger.reportError(e, stack); - } - } - } - - Future _startDownloading() async { - for (final task in state) { - if (task.status == DownloadStatus.downloading) return; - - if (task.status == DownloadStatus.queued) { - try { - await _downloadTrack(task); - } finally { - // After completion, check for more queued tasks - // Ignore errors of the prior task to allow next task to complete - await _startDownloading(); - } - } - } - } -} - -final downloadManagerProvider = - NotifierProvider>( - DownloadManagerNotifier.new, -); diff --git a/lib/provider/glance/glance.dart b/lib/provider/glance/glance.dart deleted file mode 100644 index 00b6bc38..00000000 --- a/lib/provider/glance/glance.dart +++ /dev/null @@ -1,174 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:home_widget/home_widget.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart'; -import 'package:logger/logger.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/server/server.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:spotube/utils/platform.dart'; - -@pragma("vm:entry-point") -Future glanceBackgroundCallback(Uri? data) async { - final logger = Logger(); - try { - if (data == null || - data.host != "playback" || - data.pathSegments.isEmpty || - data.queryParameters["serverAddress"] == null) { - return; - } - - final command = data.pathSegments.first; - final res = await get( - Uri.parse( - "http://${data.queryParameters["serverAddress"]}/playback/$command", - ), - ); - - if (res.statusCode != 200) { - throw Exception("Failed to execute command: $command\nBody: ${res.body}"); - } - } catch (e) { - logger.e("[GlanceBackgroundCallback] $e"); - } -} - -Future _saveWidgetData(String key, T? value) async { - try { - if (!kIsMobile) return null; - - return await HomeWidget.saveWidgetData(key, value); - } catch (e, stack) { - AppLogger.reportError(e, stack); - return null; - } -} - -Future _updateWidget() async { - try { - if (!kIsMobile) return; - - if (kIsAndroid) { - await HomeWidget.updateWidget( - androidName: 'HomePlayerWidgetReceiver', - qualifiedAndroidName: - 'oss.krtirtho.spotube.glance.HomePlayerWidgetReceiver', - ); - } - if (kIsIOS) { - await HomeWidget.updateWidget( - name: 'HomePlayerWidget', - iOSName: 'HomePlayerWidget', - ); - } - } on Exception catch (e, stack) { - AppLogger.reportError(e, stack); - } -} - -Future _sendActiveTrack(SpotubeTrackObject? track) async { - if (track == null) { - await _saveWidgetData("activeTrack", null); - await _updateWidget(); - return; - } - - final jsonTrack = track.toJson(); - - final image = track.album.images.firstOrNull; - final cachedImage = image == null - ? null - : image.url.startsWith("http") - ? (await DefaultCacheManager().getSingleFile(image.url)).path - : image.url; - final data = { - ...jsonTrack, - "album": { - ...jsonTrack["album"], - "images": [ - if (cachedImage != null && image != null) - { - ...image.toJson(), - "path": cachedImage, - } - ] - } - }; - - await _saveWidgetData("activeTrack", jsonEncode(data)); - - await _updateWidget(); -} - -final glanceProvider = Provider((ref) { - final server = ref.read(serverProvider); - final activeTrack = ref.read(audioPlayerProvider).activeTrack; - - server.whenData( - (value) async { - final (:server, :port) = value; - - await _saveWidgetData( - "playbackServerAddress", - "${server.address.host}:$port", - ); - await _updateWidget(); - }, - ); - - _sendActiveTrack(activeTrack); - - ref.listen(serverProvider, (prev, next) async { - next.whenData( - (value) async { - final (:server, :port) = value; - - await _saveWidgetData( - "playbackServerAddress", - "${server.address.host}:$port", - ); - await _updateWidget(); - }, - ); - }); - - ref.listen( - audioPlayerProvider, - (previous, next) async { - try { - if (previous?.activeTrack != next.activeTrack && - next.activeTrack != null) { - await _sendActiveTrack(next.activeTrack); - } - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }, - ); - - final subscriptions = [ - audioPlayer.playingStream.listen((playing) async { - await _saveWidgetData("isPlaying", playing); - await _updateWidget(); - }), - audioPlayer.positionStream.listen((position) async { - await _saveWidgetData("position", position.inSeconds); - await _updateWidget(); - }), - audioPlayer.durationStream.listen((duration) async { - await _saveWidgetData("duration", duration.inSeconds); - await _updateWidget(); - }), - ]; - - ref.onDispose(() { - for (final subscription in subscriptions) { - subscription.cancel(); - } - }); -}); diff --git a/lib/provider/history/history.dart b/lib/provider/history/history.dart deleted file mode 100644 index b83e5db1..00000000 --- a/lib/provider/history/history.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/database/database.dart'; - -class PlaybackHistoryActions { - final Ref ref; - AppDatabase get _db => ref.read(databaseProvider); - - PlaybackHistoryActions(this.ref); - - Future _batchInsertHistoryEntries( - List entries) async { - await _db.batch((batch) { - batch.insertAll(_db.historyTable, entries); - }); - } - - Future addPlaylists(List playlists) async { - await _batchInsertHistoryEntries([ - for (final playlist in playlists) - HistoryTableCompanion.insert( - type: HistoryEntryType.playlist, - itemId: playlist.id, - data: playlist.toJson(), - ), - ]); - } - - Future addAlbums(List albums) async { - await _batchInsertHistoryEntries([ - for (final albums in albums) - HistoryTableCompanion.insert( - type: HistoryEntryType.album, - itemId: albums.id, - data: albums.toJson(), - ), - ]); - } - - Future addTracks(List tracks) async { - assert( - tracks.every((t) => t.artists.every((a) => a.images != null)), - 'Track artists must have images', - ); - - await _batchInsertHistoryEntries([ - for (final track in tracks) - HistoryTableCompanion.insert( - type: HistoryEntryType.track, - itemId: track.id, - data: track.toJson(), - ), - ]); - } - - Future addTrack(SpotubeTrackObject track) async { - assert( - track.artists.every((a) => a.images != null), - 'Track artists must have images', - ); - - await _db.into(_db.historyTable).insert( - HistoryTableCompanion.insert( - type: HistoryEntryType.track, - itemId: track.id, - data: track.toJson(), - ), - ); - } - - Future clear() async { - _db.delete(_db.historyTable).go(); - } -} - -final playbackHistoryActionsProvider = - Provider((ref) => PlaybackHistoryActions(ref)); diff --git a/lib/provider/history/recent.dart b/lib/provider/history/recent.dart deleted file mode 100644 index 1ee2a5d6..00000000 --- a/lib/provider/history/recent.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:convert'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/provider/database/database.dart'; - -class RecentlyPlayedItemNotifier extends AsyncNotifier> { - @override - build() async { - final database = ref.watch(databaseProvider); - - final query = database.customSelect( - """ - WITH RankedHistory AS ( - SELECT *, ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY created_at DESC) AS rn - FROM history_table - WHERE type in ('playlist', 'album') - ) - SELECT * - FROM RankedHistory - WHERE rn = 1 - ORDER BY created_at DESC - LIMIT 10 - """, - readsFrom: {database.historyTable}, - ).map((rows) async { - return await rows.map((row) { - final type = row.read('type'); - return HistoryTableData( - id: row.read('id'), - itemId: row.read('item_id'), - type: HistoryEntryType.values.firstWhere((e) => e.name == type), - createdAt: row.read('created_at'), - data: jsonDecode(row.read('data')) as Map, - ); - }); - }); - - final subscription = query.watch().listen((event) async { - state = AsyncData(await Future.wait(event)); - }); - - ref.onDispose(() => subscription.cancel()); - - final items = await Future.wait(await query.get()); - - return items; - } -} - -final recentlyPlayedItems = - AsyncNotifierProvider>( - () => RecentlyPlayedItemNotifier(), -); diff --git a/lib/provider/history/summary.dart b/lib/provider/history/summary.dart deleted file mode 100644 index 5ced7559..00000000 --- a/lib/provider/history/summary.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:drift/drift.dart'; -import 'package:drift/extensions/json1.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/provider/database/database.dart'; - -class PlaybackHistorySummary { - final Duration duration; - final int tracks; - final int artists; - final double fees; - final int albums; - final int playlists; - - const PlaybackHistorySummary({ - required this.duration, - required this.tracks, - required this.artists, - required this.fees, - required this.albums, - required this.playlists, - }); - - PlaybackHistorySummary copyWith({ - Duration? duration, - int? tracks, - int? artists, - double? fees, - int? albums, - int? playlists, - }) { - return PlaybackHistorySummary( - duration: duration ?? this.duration, - tracks: tracks ?? this.tracks, - artists: artists ?? this.artists, - fees: fees ?? this.fees, - albums: albums ?? this.albums, - playlists: playlists ?? this.playlists, - ); - } -} - -class PlaybackHistorySummaryNotifier - extends AsyncNotifier { - @override - build() async { - final database = ref.watch(databaseProvider); - - final uniqItemIdCountingCol = - database.historyTable.itemId.count(distinct: true); - final itemIdCountingCol = database.historyTable.itemId.count(); - final durationSumJsonColumn = - database.historyTable.data.jsonExtract(r"$.durationMs").sum(); - final artistCountingCol = - database.historyTable.data.jsonExtract(r"$.artists"); - - final totalTracksListenedQuery = (database.selectOnly(database.historyTable) - ..addColumns([uniqItemIdCountingCol]) - ..where( - database.historyTable.type.equals(HistoryEntryType.track.name))) - .map((row) => row.read(uniqItemIdCountingCol)); - - final totalDurationListenedQuery = (database - .selectOnly(database.historyTable) - ..addColumns([durationSumJsonColumn]) - ..where( - database.historyTable.type.equals(HistoryEntryType.track.name))) - .map( - (row) => Duration(milliseconds: row.read(durationSumJsonColumn) ?? 0), - ); - - final totalArtistsListenedQuery = - (database.selectOnly(database.historyTable) - ..addColumns([artistCountingCol]) - ..where( - database.historyTable.type.equals(HistoryEntryType.track.name), - )) - .map( - (row) { - final data = jsonDecode(row.read(artistCountingCol)!) as List; - return data.map((e) => e['id'] as String).cast().toList(); - }, - ); - - final totalAlbumsListenedQuery = (database.selectOnly(database.historyTable) - ..addColumns([uniqItemIdCountingCol]) - ..where( - database.historyTable.type.equals(HistoryEntryType.album.name))) - .map((row) => row.read(uniqItemIdCountingCol)); - - final totalPlaylistsListenedQuery = - (database.selectOnly(database.historyTable) - ..addColumns([uniqItemIdCountingCol]) - ..where( - database.historyTable.type - .equals(HistoryEntryType.playlist.name), - )) - .map((row) => row.read(uniqItemIdCountingCol)); - - final oldestDate = DateTime.now().copyWith(day: 1, hour: 0, minute: 0); - final newestDate = DateTime.now().copyWith(day: 30, hour: 23, minute: 59); - final totalTracksListenedThisMonthQuery = - (database.selectOnly(database.historyTable) - ..addColumns([itemIdCountingCol]) - ..where( - database.historyTable.type.equals( - HistoryEntryType.track.name, - ) & - database.historyTable.createdAt - .isBetweenValues(oldestDate, newestDate), - )) - .map((row) => row.read(itemIdCountingCol)); - - final subscriptions = [ - totalTracksListenedQuery.watchSingle().listen((event) { - if (event == null || state.asData == null) return; - state = AsyncData(state.asData!.value.copyWith( - tracks: event, - )); - }), - totalDurationListenedQuery.watchSingle().listen((event) { - if (state.asData == null) return; - state = AsyncData(state.asData!.value.copyWith( - duration: event, - )); - }), - totalArtistsListenedQuery.watch().listen((event) { - if (state.asData == null) return; - state = AsyncData(state.asData!.value.copyWith( - artists: event.expand((e) => e).toSet().length, - )); - }), - totalAlbumsListenedQuery.watchSingle().listen((event) { - if (event == null || state.asData == null) return; - state = AsyncData(state.asData!.value.copyWith( - albums: event, - )); - }), - totalPlaylistsListenedQuery.watchSingle().listen((event) { - if (event == null || state.asData == null) return; - state = AsyncData(state.asData!.value.copyWith( - playlists: event, - )); - }), - totalTracksListenedThisMonthQuery.watchSingle().listen((event) { - if (event == null || state.asData == null) return; - state = AsyncData(state.asData!.value.copyWith( - fees: event * 0.005, - )); - }), - ]; - - ref.onDispose(() { - for (final subscription in subscriptions) { - subscription.cancel(); - } - }); - - return database.transaction(() async { - final totalTracksListened = - await totalTracksListenedQuery.getSingle() ?? 0; - - final totalDurationListened = - await totalDurationListenedQuery.getSingle(); - - final totalArtistsListened = await totalArtistsListenedQuery - .get() - .then((value) => value.expand((e) => e).toSet().length); - - final totalAlbumsListened = - await totalAlbumsListenedQuery.getSingle() ?? 0; - - final totalPlaylistsListened = - await totalPlaylistsListenedQuery.getSingle() ?? 0; - - final totalTracksListenedThisMonth = - await totalTracksListenedThisMonthQuery.getSingle() ?? 0; - - return PlaybackHistorySummary( - duration: totalDurationListened, - tracks: totalTracksListened, - artists: totalArtistsListened, - fees: totalTracksListenedThisMonth * 0.005, - albums: totalAlbumsListened, - playlists: totalPlaylistsListened, - ); - }); - } -} - -final playbackHistorySummaryProvider = AsyncNotifierProvider< - PlaybackHistorySummaryNotifier, PlaybackHistorySummary>( - () => PlaybackHistorySummaryNotifier(), -); diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart deleted file mode 100644 index b52e65e2..00000000 --- a/lib/provider/history/top.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -enum HistoryDuration { - allTime(Duration(days: 365 * 2003)), - days7(Duration(days: 7)), - days30(Duration(days: 30)), - months6(Duration(days: 30 * 6)), - year(Duration(days: 365)), - years2(Duration(days: 365 * 2)); - - final Duration duration; - - const HistoryDuration(this.duration); -} - -final playbackHistoryTopDurationProvider = - StateProvider((ref) => HistoryDuration.days30); diff --git a/lib/provider/history/top/albums.dart b/lib/provider/history/top/albums.dart deleted file mode 100644 index 1caad5cd..00000000 --- a/lib/provider/history/top/albums.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'dart:convert'; - -import 'package:collection/collection.dart'; -import 'package:drift/drift.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/database/database.dart'; -import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/provider/metadata_plugin/utils/family_paginated.dart'; - -typedef PlaybackHistoryAlbum = ({int count, SpotubeSimpleAlbumObject album}); - -class HistoryTopAlbumsNotifier extends FamilyPaginatedAsyncNotifier< - PlaybackHistoryAlbum, HistoryDuration> { - HistoryTopAlbumsNotifier() : super(); - - Selectable createAlbumsQuery( - {int? limit, int? offset}) { - final database = ref.read(databaseProvider); - - final duration = switch (arg) { - HistoryDuration.allTime => '0', - HistoryDuration.days7 => "strftime('%s', 'now', 'weekday 0', '-7 days')", - HistoryDuration.days30 => "strftime('%s', 'now', 'start of month')", - HistoryDuration.months6 => - "strftime('%s', date('now', '-5 months', 'start of month'))", - HistoryDuration.year => "strftime('%s', date('now', 'start of year'))", - HistoryDuration.years2 => - "strftime('%s', date('now', '-1 years', 'start of year'))", - }; - - return database.customSelect( - """ - SELECT - history_table.created_at, - """ - r""" - json_extract(history_table.data, '$.album') as data, - json_extract(history_table.data, '$.album.id') as item_id, - 'album' as type - """ - """ - FROM history_table - WHERE type = 'track' AND - created_at >= $duration - UNION ALL - SELECT - history_table.created_at, - history_table.data, - history_table.item_id, - history_table.type - FROM history_table - WHERE type = 'album' AND - created_at >= $duration - ORDER BY created_at desc - ${limit != null && offset != null ? 'LIMIT $limit OFFSET $offset' : ''} - """, - readsFrom: {database.historyTable}, - ).map((row) { - final data = row.read('data'); - final album = SpotubeSimpleAlbumObject.fromJson(jsonDecode(data)); - return album; - }); - } - - @override - fetch(offset, limit) async { - final albumsQuery = createAlbumsQuery(limit: limit, offset: offset); - - final items = getAlbumsWithCount(await albumsQuery.get()); - - return SpotubePaginationResponseObject( - items: items, - limit: limit, - hasMore: items.length == limit, - nextOffset: (offset + limit).toInt(), - total: items.length, - ); - } - - @override - build(arg) async { - final subscription = createAlbumsQuery().watch().listen((event) { - if (state.asData == null) return; - state = AsyncData(state.asData!.value.copyWith( - items: getAlbumsWithCount(event), - hasMore: false, - )); - }); - - ref.onDispose(() { - subscription.cancel(); - }); - - return await fetch(0, 20); - } - - List getAlbumsWithCount( - List albumsWithTrackAlbums, - ) { - return groupBy(albumsWithTrackAlbums, (album) => album.id) - .entries - .map((entry) { - return (count: entry.value.length, album: entry.value.first); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); - } -} - -final historyTopAlbumsProvider = AsyncNotifierProviderFamily< - HistoryTopAlbumsNotifier, - SpotubePaginationResponseObject, - HistoryDuration>( - () => HistoryTopAlbumsNotifier(), -); diff --git a/lib/provider/history/top/playlists.dart b/lib/provider/history/top/playlists.dart deleted file mode 100644 index 1beabb80..00000000 --- a/lib/provider/history/top/playlists.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:drift/drift.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/database/database.dart'; -import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/provider/metadata_plugin/utils/family_paginated.dart'; - -typedef PlaybackHistoryPlaylist = ({ - int count, - SpotubeSimplePlaylistObject playlist -}); - -class HistoryTopPlaylistsNotifier extends FamilyPaginatedAsyncNotifier< - PlaybackHistoryPlaylist, HistoryDuration> { - HistoryTopPlaylistsNotifier() : super(); - - SimpleSelectStatement<$HistoryTableTable, HistoryTableData> - createPlaylistsQuery() { - final database = ref.read(databaseProvider); - - return database.select(database.historyTable) - ..where( - (tbl) => - tbl.type.equalsValue(HistoryEntryType.playlist) & - tbl.createdAt.isBiggerOrEqualValue( - DateTime.now().subtract(arg.duration), - ), - ); - } - - @override - fetch(offset, limit) async { - final playlistsQuery = createPlaylistsQuery()..limit(limit, offset: offset); - - final items = getPlaylistsWithCount(await playlistsQuery.get()); - - return SpotubePaginationResponseObject( - items: items, - nextOffset: offset + limit, - total: items.length, - limit: limit, - hasMore: items.length == limit, - ); - } - - @override - build(arg) async { - final subscription = createPlaylistsQuery().watch().listen((event) { - if (state.asData == null) return; - state = AsyncData(state.asData!.value.copyWith( - items: getPlaylistsWithCount(event), - hasMore: false, - )); - }); - - ref.onDispose(() { - subscription.cancel(); - }); - - return await fetch(0, 20); - } - - List getPlaylistsWithCount( - List playlists, - ) { - return groupBy(playlists, (playlist) => playlist.playlist!.id) - .entries - .map((entry) { - return ( - count: entry.value.length, - playlist: entry.value.first.playlist!, - ); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); - } -} - -final historyTopPlaylistsProvider = AsyncNotifierProviderFamily< - HistoryTopPlaylistsNotifier, - SpotubePaginationResponseObject, - HistoryDuration>( - () => HistoryTopPlaylistsNotifier(), -); diff --git a/lib/provider/history/top/tracks.dart b/lib/provider/history/top/tracks.dart deleted file mode 100644 index 5c1dbdbf..00000000 --- a/lib/provider/history/top/tracks.dart +++ /dev/null @@ -1,196 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:drift/drift.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/database/database.dart'; -import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/provider/metadata_plugin/artist/artist.dart'; -import 'package:spotube/provider/metadata_plugin/utils/family_paginated.dart'; - -typedef PlaybackHistoryTrack = ({int count, SpotubeTrackObject track}); -typedef PlaybackHistoryArtist = ({int count, SpotubeSimpleArtistObject artist}); - -class HistoryTopTracksNotifier extends FamilyPaginatedAsyncNotifier< - PlaybackHistoryTrack, HistoryDuration> { - HistoryTopTracksNotifier() : super(); - - SimpleSelectStatement<$HistoryTableTable, HistoryTableData> - createTracksQuery() { - final database = ref.read(databaseProvider); - - return database.select(database.historyTable) - ..where( - (tbl) => - tbl.type.equalsValue(HistoryEntryType.track) & - tbl.createdAt.isBiggerOrEqualValue(switch (arg) { - HistoryDuration.allTime => DateTime(1970), - // from start of the week - HistoryDuration.days7 => DateTime.now() - .subtract(Duration(days: DateTime.now().weekday - 1)), - // from start of the month - HistoryDuration.days30 => - DateTime.now().subtract(Duration(days: DateTime.now().day - 1)), - // from start of the 6th month - HistoryDuration.months6 => DateTime.now() - .subtract(Duration(days: DateTime.now().day - 1)) - .subtract(const Duration(days: 30 * 6)), - // from start of the year - HistoryDuration.year => DateTime.now() - .subtract(Duration(days: DateTime.now().day - 1)) - .subtract(const Duration(days: 30 * 12)), - HistoryDuration.years2 => DateTime.now() - .subtract(Duration(days: DateTime.now().day - 1)) - .subtract(const Duration(days: 30 * 12 * 2)), - }), - ); - } - - Future fixImageNotLoadingForArtistIssue( - List entries, - ) async { - final nonImageArtistTracks = - entries.where((e) => e.track!.artists.any((a) => a.images == null)); - - if (nonImageArtistTracks.isEmpty) return; - - final artistIds = nonImageArtistTracks - .map((e) => e.track!.artists.map((a) => a.id)) - .expand((e) => e) - .toSet() - .toList(); - - if (artistIds.isEmpty) return; - - final artists = await Future.wait([ - for (final id in artistIds) - ref.read(metadataPluginArtistProvider(id).future), - ]); - - final imagedArtistTracks = nonImageArtistTracks.map((e) { - var track = e.track!; - final includedArtists = track.artists - .map((a) { - final fullArtist = - artists.firstWhereOrNull((artist) => artist.id == a.id); - - return fullArtist != null - ? a.copyWith(images: fullArtist.images) - : a; - }) - .nonNulls - .toList(); - - track = track.copyWith(artists: includedArtists); - - return e.copyWith(data: track.toJson()); - }); - - assert( - imagedArtistTracks - .every((e) => e.track!.artists.every((a) => a.images != null)), - 'Tracks artists should have images', - ); - - final database = ref.read(databaseProvider); - await database.batch((batch) { - batch.insertAllOnConflictUpdate( - database.historyTable, - imagedArtistTracks, - ); - }); - } - - @override - fetch(offset, limit) async { - final tracksQuery = createTracksQuery()..limit(limit, offset: offset); - - final entries = await tracksQuery.get(); - - final items = getTracksWithCount(entries); - - return SpotubePaginationResponseObject( - items: items, - nextOffset: offset + limit, - total: items.length, - limit: limit, - hasMore: items.length == limit, - ); - } - - @override - build(arg) async { - final subscription = createTracksQuery().watch().listen((event) { - if (state.asData == null) return; - state = AsyncData(state.asData!.value.copyWith( - items: getTracksWithCount(event), - hasMore: false, - )); - }); - - ref.onDispose(() { - subscription.cancel(); - }); - - return await fetch(0, 20); - } - - List get artists { - return getArtistsWithCount( - state.asData?.value.items.expand((e) => e.track.artists) ?? [], - ); - } - - List getArtistsWithCount( - Iterable artists, - ) { - return groupBy(artists, (artist) => artist.id) - .entries - .map((entry) { - return ( - count: entry.value.length, - - /// Previously, due to a bug, artist images were not being saved. - /// Now it's fixed, but we need to handle the case where images are null. - /// So we take the first artist with images if available, otherwise the first one. - artist: entry.value.firstWhereOrNull((a) => a.images != null) ?? - entry.value.first, - ); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); - } - - List getTracksWithCount(List tracks) { - fixImageNotLoadingForArtistIssue(tracks); - - return groupBy( - tracks, - (track) => track.track!.id, - ) - .entries - .map((entry) { - return ( - count: entry.value.length, - - /// Previously, due to a bug, artist images were not being saved. - /// Now it's fixed, but we need to handle the case where images are null. - /// So we take the first artist with images if available, otherwise the first one. - track: entry.value - .firstWhereOrNull( - (t) => t.track!.artists.every((a) => a.images != null)) - ?.track! ?? - entry.value.first.track!, - ); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); - } -} - -final historyTopTracksProvider = AsyncNotifierProviderFamily< - HistoryTopTracksNotifier, - SpotubePaginationResponseObject, - HistoryDuration>( - () => HistoryTopTracksNotifier(), -); diff --git a/lib/provider/local_tracks/local_tracks_provider.dart b/lib/provider/local_tracks/local_tracks_provider.dart deleted file mode 100644 index 8d44b607..00000000 --- a/lib/provider/local_tracks/local_tracks_provider.dart +++ /dev/null @@ -1,148 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:flutter/foundation.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:metadata_god/metadata_god.dart'; -import 'package:mime/mime.dart'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; - -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -// ignore: depend_on_referenced_packages -import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FrbException; -import 'package:spotube/utils/service_utils.dart'; - -const supportedAudioTypes = [ - "audio/webm", - "audio/ogg", - "audio/mpeg", - "audio/mp4", - "audio/opus", - "audio/wav", - "audio/aac", - "audio/flac", - "audio/x-flac", - "audio/x-wav", -]; - -const imgMimeToExt = { - "image/png": ".png", - "image/jpeg": ".jpg", - "image/webp": ".webp", - "image/gif": ".gif", -}; - -typedef MetadataFile = ({ - Metadata? metadata, - File file, - String? art, -}); - -final localTracksProvider = - FutureProvider>>((ref) async { - try { - if (kIsWeb) return {}; - final Map> libraryToTracks = {}; - - final downloadLocation = ref.watch( - userPreferencesProvider.select((s) => s.downloadLocation), - ); - - if (downloadLocation.isEmpty) { - return {}; - } - - final downloadDir = Directory(downloadLocation); - final cacheDir = - Directory(await UserPreferencesNotifier.getMusicCacheDir()); - if (!await downloadDir.exists()) { - await downloadDir.create(recursive: true); - } - if (!await cacheDir.exists()) { - await cacheDir.create(recursive: true); - } - final localLibraryLocations = ref.watch( - userPreferencesProvider.select((s) => s.localLibraryLocation), - ); - - for (final location in [ - downloadLocation, - cacheDir.path, - ...localLibraryLocations - ]) { - if (location.isEmpty) continue; - final entities = []; - if (await Directory(location).exists()) { - try { - final dirEntities = - await Directory(location).list(recursive: true).toList(); - - entities.addAll( - dirEntities.where( - (e) { - final mime = lookupMimeType(e.path) ?? - (extension(e.path) == ".opus" ? "audio/opus" : null); - - return e is File && supportedAudioTypes.contains(mime); - }, - ).cast(), - ); - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - } - - final List filesWithMetadata = await Future.wait( - entities.map((file) async { - try { - final metadata = await MetadataGod.readMetadata(file: file.path); - - final imageFile = File( - join( - (await getTemporaryDirectory()).path, - "spotube", - ServiceUtils.sanitizeFilename( - basenameWithoutExtension(file.path)) + - imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, - ), - ); - if (!await imageFile.exists() && metadata.picture != null) { - await imageFile.create(recursive: true); - await imageFile.writeAsBytes( - metadata.picture?.data ?? [], - mode: FileMode.writeOnly, - ); - } - - return (metadata: metadata, file: file, art: imageFile.path); - } catch (e, stack) { - if (e case FrbException() || TimeoutException()) { - return (file: file, metadata: null, art: null); - } - AppLogger.reportError(e, stack); - return null; - } - }), - ).then((value) => value.nonNulls.toList()); - - final tracksFromMetadata = filesWithMetadata - .map( - (fileWithMetadata) => SpotubeTrackObject.localTrackFromFile( - fileWithMetadata.file, - metadata: fileWithMetadata.metadata, - art: fileWithMetadata.art, - ) as SpotubeLocalTrackObject, - ) - .toList(); - - libraryToTracks[location] = tracksFromMetadata; - } - return libraryToTracks; - } catch (e, stack) { - AppLogger.reportError(e, stack); - return {}; - } -}); diff --git a/lib/provider/logs/logs_provider.dart b/lib/provider/logs/logs_provider.dart deleted file mode 100644 index d39059ac..00000000 --- a/lib/provider/logs/logs_provider.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'dart:convert'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/services/logger/logger.dart'; - -final logsProvider = StreamProvider.autoDispose((ref) async* { - final file = await AppLogger.getLogsPath(); - // Check if file is empty or non-existent - - if (await file.length() == 0) { - throw StateError("Logs file is empty or non-existent"); - } - - final stream = file.openRead().transform(utf8.decoder); - - await for (final line in stream) { - yield line; - } -}); diff --git a/lib/provider/lyrics/synced.dart b/lib/provider/lyrics/synced.dart deleted file mode 100644 index 2c33a736..00000000 --- a/lib/provider/lyrics/synced.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'dart:async'; - -import 'package:dio/dio.dart'; -import 'package:drift/drift.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lrc/lrc.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/lyrics.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/database/database.dart'; -import 'package:spotube/services/dio/dio.dart'; -import 'package:spotube/services/logger/logger.dart'; - -class SyncedLyricsNotifier - extends FamilyAsyncNotifier { - SpotubeTrackObject get _track => arg!; - - /// Lyrics credits: [lrclib.net](https://lrclib.net) and their contributors - /// Thanks for their generous public API - Future getLRCLibLyrics() async { - final packageInfo = await PackageInfo.fromPlatform(); - - final res = await globalDio.getUri( - Uri( - scheme: "https", - host: "lrclib.net", - path: "/api/get", - queryParameters: { - "artist_name": _track.artists.first.name, - "track_name": _track.name, - "album_name": _track.album.name, - if (_track.durationMs > 0) - "duration": (_track.durationMs / 1000).toInt().toString(), - }, - ), - options: Options( - headers: { - "User-Agent": - "Spotube v${packageInfo.version} (https://github.com/KRTirtho/spotube)" - }, - responseType: ResponseType.json, - ), - ); - - if (res.statusCode != 200) { - return SubtitleSimple( - lyrics: [], - name: _track.name, - uri: res.realUri, - rating: 0, - provider: "LRCLib", - ); - } - - final json = res.data as Map; - - final syncedLyricsRaw = json["syncedLyrics"] as String?; - final syncedLyrics = syncedLyricsRaw?.isNotEmpty == true - ? Lrc.parse(syncedLyricsRaw!) - .lyrics - .map(LyricSlice.fromLrcLine) - .toList() - : null; - - if (syncedLyrics?.isNotEmpty == true) { - return SubtitleSimple( - lyrics: syncedLyrics!, - name: _track.name, - uri: res.realUri, - rating: 100, - provider: "LRCLib", - ); - } - - final plainLyrics = (json["plainLyrics"] as String) - .split("\n") - .map((line) => LyricSlice(text: line, time: Duration.zero)) - .toList(); - - return SubtitleSimple( - lyrics: plainLyrics, - name: _track.name, - uri: res.realUri, - rating: 0, - provider: "LRCLib", - ); - } - - @override - FutureOr build(track) async { - try { - final database = ref.watch(databaseProvider); - - if (track == null) { - throw "No track currently"; - } - - final cachedLyrics = await (database.select(database.lyricsTable) - ..where((tbl) => tbl.trackId.equals(track.id))) - .map((row) => row.data) - .getSingleOrNull(); - - SubtitleSimple? lyrics = cachedLyrics; - - if (lyrics == null || - lyrics.lyrics.isEmpty || - lyrics.lyrics.length <= 5) { - lyrics = await getLRCLibLyrics(); - } - - if (lyrics.lyrics.isEmpty) { - throw Exception("Unable to find lyrics"); - } - - if (cachedLyrics == null || cachedLyrics.lyrics.isEmpty) { - await database.into(database.lyricsTable).insert( - LyricsTableCompanion.insert( - trackId: track.id, - data: lyrics, - ), - mode: InsertMode.replace, - ); - } - - return lyrics; - } catch (e, stackTrace) { - AppLogger.reportError(e, stackTrace); - rethrow; - } - } -} - -final syncedLyricsDelayProvider = StateProvider((ref) => 0); - -final syncedLyricsProvider = AsyncNotifierProviderFamily( - () => SyncedLyricsNotifier(), -); - -final syncedLyricsMapProvider = - FutureProvider.family((ref, SpotubeTrackObject? track) async { - final syncedLyrics = await ref.watch(syncedLyricsProvider(track).future); - - final isStaticLyrics = - syncedLyrics.lyrics.every((l) => l.time == Duration.zero); - - final lyricsMap = syncedLyrics.lyrics - .map((lyric) => {lyric.time.inSeconds: lyric.text}) - .reduce((accumulator, lyricSlice) => {...accumulator, ...lyricSlice}); - - return (static: isStaticLyrics, lyricsMap: lyricsMap); -}); diff --git a/lib/provider/metadata_plugin/album/album.dart b/lib/provider/metadata_plugin/album/album.dart deleted file mode 100644 index 394f6eb0..00000000 --- a/lib/provider/metadata_plugin/album/album.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; - -final metadataPluginAlbumProvider = - FutureProvider.autoDispose.family( - (ref, id) async { - ref.cacheFor(); - - final metadataPlugin = await ref.watch(metadataPluginProvider.future); - - if (metadataPlugin == null) { - throw MetadataPluginException.noDefaultMetadataPlugin(); - } - - return metadataPlugin.album.getAlbum(id); - }, -); diff --git a/lib/provider/metadata_plugin/album/releases.dart b/lib/provider/metadata_plugin/album/releases.dart deleted file mode 100644 index e6e88baf..00000000 --- a/lib/provider/metadata_plugin/album/releases.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/utils/paginated.dart'; - -class MetadataPluginAlbumReleasesNotifier - extends PaginatedAsyncNotifier { - @override - Future> fetch( - int offset, - int limit, - ) async { - return await (await metadataPlugin) - .album - .releases(limit: limit, offset: offset); - } - - @override - build() async { - ref.watch(metadataPluginAuthenticatedProvider); - return await fetch(0, 20); - } -} - -final metadataPluginAlbumReleasesProvider = AsyncNotifierProvider< - MetadataPluginAlbumReleasesNotifier, - SpotubePaginationResponseObject>( - () => MetadataPluginAlbumReleasesNotifier(), -); diff --git a/lib/provider/metadata_plugin/artist/albums.dart b/lib/provider/metadata_plugin/artist/albums.dart deleted file mode 100644 index 0f582bf9..00000000 --- a/lib/provider/metadata_plugin/artist/albums.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/utils/family_paginated.dart'; - -class MetadataPluginArtistAlbumNotifier - extends FamilyPaginatedAsyncNotifier { - @override - Future> fetch( - int offset, - int limit, - ) async { - return await (await metadataPlugin).artist.albums( - arg, - limit: limit, - offset: offset, - ); - } - - @override - build(arg) async { - ref.watch(metadataPluginProvider); - return await fetch(0, 20); - } -} - -final metadataPluginArtistAlbumsProvider = AsyncNotifierProviderFamily< - MetadataPluginArtistAlbumNotifier, - SpotubePaginationResponseObject, - String>( - () => MetadataPluginArtistAlbumNotifier(), -); diff --git a/lib/provider/metadata_plugin/artist/artist.dart b/lib/provider/metadata_plugin/artist/artist.dart deleted file mode 100644 index e66309d4..00000000 --- a/lib/provider/metadata_plugin/artist/artist.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; - -final metadataPluginArtistProvider = - FutureProvider.autoDispose.family( - (ref, artistId) async { - ref.cacheFor(); - - final metadataPlugin = await ref.watch(metadataPluginProvider.future); - - if (metadataPlugin == null) { - throw MetadataPluginException.noDefaultMetadataPlugin(); - } - - return metadataPlugin.artist.getArtist(artistId); - }, -); diff --git a/lib/provider/metadata_plugin/artist/related.dart b/lib/provider/metadata_plugin/artist/related.dart deleted file mode 100644 index c6a80f75..00000000 --- a/lib/provider/metadata_plugin/artist/related.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/utils/family_paginated.dart'; - -class MetadataPluginArtistRelatedArtistsNotifier - extends FamilyPaginatedAsyncNotifier { - @override - Future> fetch( - int offset, - int limit, - ) async { - return await (await metadataPlugin).artist.related( - arg, - limit: limit, - offset: offset, - ); - } - - @override - build(arg) async { - ref.watch(metadataPluginProvider); - return await fetch(0, 20); - } -} - -final metadataPluginArtistRelatedArtistsProvider = AsyncNotifierProviderFamily< - MetadataPluginArtistRelatedArtistsNotifier, - SpotubePaginationResponseObject, - String>( - () => MetadataPluginArtistRelatedArtistsNotifier(), -); diff --git a/lib/provider/metadata_plugin/artist/top_tracks.dart b/lib/provider/metadata_plugin/artist/top_tracks.dart deleted file mode 100644 index c622a738..00000000 --- a/lib/provider/metadata_plugin/artist/top_tracks.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/utils/family_paginated.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; - -class MetadataPluginArtistTopTracksNotifier - extends AutoDisposeFamilyPaginatedAsyncNotifier { - MetadataPluginArtistTopTracksNotifier() : super(); - - @override - fetch(offset, limit) async { - final tracks = await (await metadataPlugin).artist.topTracks( - arg, - offset: offset, - limit: limit, - ); - - return tracks; - } - - @override - build(arg) async { - ref.cacheFor(); - - ref.watch(metadataPluginProvider); - return await fetch(0, 20); - } -} - -final metadataPluginArtistTopTracksProvider = - AutoDisposeAsyncNotifierProviderFamily< - MetadataPluginArtistTopTracksNotifier, - SpotubePaginationResponseObject, - String>( - () => MetadataPluginArtistTopTracksNotifier(), -); diff --git a/lib/provider/metadata_plugin/artist/wikipedia.dart b/lib/provider/metadata_plugin/artist/wikipedia.dart deleted file mode 100644 index 81fcc77c..00000000 --- a/lib/provider/metadata_plugin/artist/wikipedia.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/services/wikipedia/wikipedia.dart'; -import 'package:wikipedia_api/wikipedia_api.dart'; - -final artistWikipediaSummaryProvider = - FutureProvider.autoDispose.family( - (ref, artist) async { - final query = artist.name.replaceAll(" ", "_"); - final res = await wikipedia.pageContent.pageSummaryTitleGet(query); - - if (res?.type != "standard") { - return await wikipedia.pageContent - .pageSummaryTitleGet("${query}_(singer)"); - } - return res; - }, -); diff --git a/lib/provider/metadata_plugin/audio_source/quality_label.dart b/lib/provider/metadata_plugin/audio_source/quality_label.dart deleted file mode 100644 index 113ed54e..00000000 --- a/lib/provider/metadata_plugin/audio_source/quality_label.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:riverpod/riverpod.dart'; -import 'package:spotube/provider/metadata_plugin/audio_source/quality_presets.dart'; - -final audioSourceQualityLabelProvider = Provider((ref) { - final sourceQuality = ref.watch(audioSourcePresetsProvider); - final sourceContainer = sourceQuality.presets - .elementAtOrNull(sourceQuality.selectedStreamingContainerIndex); - final quality = sourceContainer?.qualities - .elementAtOrNull(sourceQuality.selectedStreamingQualityIndex); - - return "${sourceContainer?.name ?? "Unknown"} • ${quality?.toString() ?? "Unknown"}"; -}); diff --git a/lib/provider/metadata_plugin/audio_source/quality_presets.dart b/lib/provider/metadata_plugin/audio_source/quality_presets.dart deleted file mode 100644 index ba88fed6..00000000 --- a/lib/provider/metadata_plugin/audio_source/quality_presets.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'dart:convert'; - -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; -import 'package:spotube/services/metadata/metadata.dart'; - -part 'quality_presets.g.dart'; -part 'quality_presets.freezed.dart'; - -@freezed -class AudioSourcePresetsState with _$AudioSourcePresetsState { - factory AudioSourcePresetsState({ - @Default([]) final List presets, - @Default(0) final int selectedStreamingQualityIndex, - @Default(0) final int selectedStreamingContainerIndex, - @Default(0) final int selectedDownloadingQualityIndex, - @Default(0) final int selectedDownloadingContainerIndex, - }) = _AudioSourcePresetsState; - - factory AudioSourcePresetsState.fromJson(Map json) => - _$AudioSourcePresetsStateFromJson(json); -} - -class AudioSourceAvailableQualityPresetsNotifier - extends Notifier { - @override - build() { - final audioSourceSnapshot = ref.watch(audioSourcePluginProvider); - final audioSourceConfigSnapshot = ref.watch( - metadataPluginsProvider.select((data) => - data.whenData((value) => value.defaultAudioSourcePluginConfig)), - ); - - _initialize(audioSourceSnapshot, audioSourceConfigSnapshot); - - listenSelf((previous, next) { - final isNewLossless = - next.presets.elementAtOrNull(next.selectedStreamingContainerIndex) - is SpotubeAudioSourceContainerPresetLossless; - final isOldLossless = previous?.presets - .elementAtOrNull(previous.selectedStreamingContainerIndex) - is SpotubeAudioSourceContainerPresetLossless; - if (!isOldLossless && isNewLossless) { - audioPlayer.setDemuxerBufferSize(6 * 1024 * 1024); // 6MB - } else if (isOldLossless && !isNewLossless) { - audioPlayer.setDemuxerBufferSize(4 * 1024 * 1024); // 4MB - } - }); - - return AudioSourcePresetsState(); - } - - void _initialize( - AsyncValue audioSourceSnapshot, - AsyncValue audioSourceConfigSnapshot, - ) async { - audioSourceConfigSnapshot.whenData((audioSourceConfig) { - audioSourceSnapshot.whenData((audioSource) async { - if (audioSource == null || audioSourceConfig == null) { - throw MetadataPluginException.noDefaultAudioSourcePlugin(); - } - final preferences = await SharedPreferences.getInstance(); - final persistedStateStr = - preferences.getString("audioSourceState-${audioSourceConfig.slug}"); - - if (persistedStateStr != null) { - state = - AudioSourcePresetsState.fromJson(jsonDecode(persistedStateStr)) - .copyWith( - presets: audioSource.audioSource.supportedPresets, - ); - } else { - state = AudioSourcePresetsState( - presets: audioSource.audioSource.supportedPresets, - ); - } - }); - }); - } - - void setSelectedStreamingContainerIndex(int index) { - state = state.copyWith( - selectedStreamingContainerIndex: index, - selectedStreamingQualityIndex: - 0, // Resetting both because it's a different quality - ); - _updatePreferences(); - } - - void setSelectedStreamingQualityIndex(int index) { - state = state.copyWith(selectedStreamingQualityIndex: index); - _updatePreferences(); - } - - void setSelectedDownloadingContainerIndex(int index) { - state = state.copyWith( - selectedDownloadingContainerIndex: index, - selectedDownloadingQualityIndex: - 0, // Resetting both because it's a different quality - ); - _updatePreferences(); - } - - void setSelectedDownloadingQualityIndex(int index) { - state = state.copyWith(selectedDownloadingQualityIndex: index); - _updatePreferences(); - } - - void _updatePreferences() async { - final audioSourceConfig = await ref.read(metadataPluginsProvider - .selectAsync((data) => data.defaultAudioSourcePluginConfig)); - if (audioSourceConfig == null) { - throw MetadataPluginException.noDefaultAudioSourcePlugin(); - } - - final preferences = await SharedPreferences.getInstance(); - await preferences.setString( - "audioSourceState-${audioSourceConfig.slug}", - jsonEncode(state), - ); - } -} - -final audioSourcePresetsProvider = NotifierProvider< - AudioSourceAvailableQualityPresetsNotifier, AudioSourcePresetsState>( - () => AudioSourceAvailableQualityPresetsNotifier(), -); diff --git a/lib/provider/metadata_plugin/audio_source/quality_presets.freezed.dart b/lib/provider/metadata_plugin/audio_source/quality_presets.freezed.dart deleted file mode 100644 index a8e0c9f7..00000000 --- a/lib/provider/metadata_plugin/audio_source/quality_presets.freezed.dart +++ /dev/null @@ -1,289 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'quality_presets.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -AudioSourcePresetsState _$AudioSourcePresetsStateFromJson( - Map json) { - return _AudioSourcePresetsState.fromJson(json); -} - -/// @nodoc -mixin _$AudioSourcePresetsState { - List get presets => - throw _privateConstructorUsedError; - int get selectedStreamingQualityIndex => throw _privateConstructorUsedError; - int get selectedStreamingContainerIndex => throw _privateConstructorUsedError; - int get selectedDownloadingQualityIndex => throw _privateConstructorUsedError; - int get selectedDownloadingContainerIndex => - throw _privateConstructorUsedError; - - /// Serializes this AudioSourcePresetsState to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of AudioSourcePresetsState - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $AudioSourcePresetsStateCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $AudioSourcePresetsStateCopyWith<$Res> { - factory $AudioSourcePresetsStateCopyWith(AudioSourcePresetsState value, - $Res Function(AudioSourcePresetsState) then) = - _$AudioSourcePresetsStateCopyWithImpl<$Res, AudioSourcePresetsState>; - @useResult - $Res call( - {List presets, - int selectedStreamingQualityIndex, - int selectedStreamingContainerIndex, - int selectedDownloadingQualityIndex, - int selectedDownloadingContainerIndex}); -} - -/// @nodoc -class _$AudioSourcePresetsStateCopyWithImpl<$Res, - $Val extends AudioSourcePresetsState> - implements $AudioSourcePresetsStateCopyWith<$Res> { - _$AudioSourcePresetsStateCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of AudioSourcePresetsState - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? presets = null, - Object? selectedStreamingQualityIndex = null, - Object? selectedStreamingContainerIndex = null, - Object? selectedDownloadingQualityIndex = null, - Object? selectedDownloadingContainerIndex = null, - }) { - return _then(_value.copyWith( - presets: null == presets - ? _value.presets - : presets // ignore: cast_nullable_to_non_nullable - as List, - selectedStreamingQualityIndex: null == selectedStreamingQualityIndex - ? _value.selectedStreamingQualityIndex - : selectedStreamingQualityIndex // ignore: cast_nullable_to_non_nullable - as int, - selectedStreamingContainerIndex: null == selectedStreamingContainerIndex - ? _value.selectedStreamingContainerIndex - : selectedStreamingContainerIndex // ignore: cast_nullable_to_non_nullable - as int, - selectedDownloadingQualityIndex: null == selectedDownloadingQualityIndex - ? _value.selectedDownloadingQualityIndex - : selectedDownloadingQualityIndex // ignore: cast_nullable_to_non_nullable - as int, - selectedDownloadingContainerIndex: null == - selectedDownloadingContainerIndex - ? _value.selectedDownloadingContainerIndex - : selectedDownloadingContainerIndex // ignore: cast_nullable_to_non_nullable - as int, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$AudioSourcePresetsStateImplCopyWith<$Res> - implements $AudioSourcePresetsStateCopyWith<$Res> { - factory _$$AudioSourcePresetsStateImplCopyWith( - _$AudioSourcePresetsStateImpl value, - $Res Function(_$AudioSourcePresetsStateImpl) then) = - __$$AudioSourcePresetsStateImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {List presets, - int selectedStreamingQualityIndex, - int selectedStreamingContainerIndex, - int selectedDownloadingQualityIndex, - int selectedDownloadingContainerIndex}); -} - -/// @nodoc -class __$$AudioSourcePresetsStateImplCopyWithImpl<$Res> - extends _$AudioSourcePresetsStateCopyWithImpl<$Res, - _$AudioSourcePresetsStateImpl> - implements _$$AudioSourcePresetsStateImplCopyWith<$Res> { - __$$AudioSourcePresetsStateImplCopyWithImpl( - _$AudioSourcePresetsStateImpl _value, - $Res Function(_$AudioSourcePresetsStateImpl) _then) - : super(_value, _then); - - /// Create a copy of AudioSourcePresetsState - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? presets = null, - Object? selectedStreamingQualityIndex = null, - Object? selectedStreamingContainerIndex = null, - Object? selectedDownloadingQualityIndex = null, - Object? selectedDownloadingContainerIndex = null, - }) { - return _then(_$AudioSourcePresetsStateImpl( - presets: null == presets - ? _value._presets - : presets // ignore: cast_nullable_to_non_nullable - as List, - selectedStreamingQualityIndex: null == selectedStreamingQualityIndex - ? _value.selectedStreamingQualityIndex - : selectedStreamingQualityIndex // ignore: cast_nullable_to_non_nullable - as int, - selectedStreamingContainerIndex: null == selectedStreamingContainerIndex - ? _value.selectedStreamingContainerIndex - : selectedStreamingContainerIndex // ignore: cast_nullable_to_non_nullable - as int, - selectedDownloadingQualityIndex: null == selectedDownloadingQualityIndex - ? _value.selectedDownloadingQualityIndex - : selectedDownloadingQualityIndex // ignore: cast_nullable_to_non_nullable - as int, - selectedDownloadingContainerIndex: null == - selectedDownloadingContainerIndex - ? _value.selectedDownloadingContainerIndex - : selectedDownloadingContainerIndex // ignore: cast_nullable_to_non_nullable - as int, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$AudioSourcePresetsStateImpl implements _AudioSourcePresetsState { - _$AudioSourcePresetsStateImpl( - {final List presets = const [], - this.selectedStreamingQualityIndex = 0, - this.selectedStreamingContainerIndex = 0, - this.selectedDownloadingQualityIndex = 0, - this.selectedDownloadingContainerIndex = 0}) - : _presets = presets; - - factory _$AudioSourcePresetsStateImpl.fromJson(Map json) => - _$$AudioSourcePresetsStateImplFromJson(json); - - final List _presets; - @override - @JsonKey() - List get presets { - if (_presets is EqualUnmodifiableListView) return _presets; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_presets); - } - - @override - @JsonKey() - final int selectedStreamingQualityIndex; - @override - @JsonKey() - final int selectedStreamingContainerIndex; - @override - @JsonKey() - final int selectedDownloadingQualityIndex; - @override - @JsonKey() - final int selectedDownloadingContainerIndex; - - @override - String toString() { - return 'AudioSourcePresetsState(presets: $presets, selectedStreamingQualityIndex: $selectedStreamingQualityIndex, selectedStreamingContainerIndex: $selectedStreamingContainerIndex, selectedDownloadingQualityIndex: $selectedDownloadingQualityIndex, selectedDownloadingContainerIndex: $selectedDownloadingContainerIndex)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$AudioSourcePresetsStateImpl && - const DeepCollectionEquality().equals(other._presets, _presets) && - (identical(other.selectedStreamingQualityIndex, - selectedStreamingQualityIndex) || - other.selectedStreamingQualityIndex == - selectedStreamingQualityIndex) && - (identical(other.selectedStreamingContainerIndex, - selectedStreamingContainerIndex) || - other.selectedStreamingContainerIndex == - selectedStreamingContainerIndex) && - (identical(other.selectedDownloadingQualityIndex, - selectedDownloadingQualityIndex) || - other.selectedDownloadingQualityIndex == - selectedDownloadingQualityIndex) && - (identical(other.selectedDownloadingContainerIndex, - selectedDownloadingContainerIndex) || - other.selectedDownloadingContainerIndex == - selectedDownloadingContainerIndex)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash( - runtimeType, - const DeepCollectionEquality().hash(_presets), - selectedStreamingQualityIndex, - selectedStreamingContainerIndex, - selectedDownloadingQualityIndex, - selectedDownloadingContainerIndex); - - /// Create a copy of AudioSourcePresetsState - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$AudioSourcePresetsStateImplCopyWith<_$AudioSourcePresetsStateImpl> - get copyWith => __$$AudioSourcePresetsStateImplCopyWithImpl< - _$AudioSourcePresetsStateImpl>(this, _$identity); - - @override - Map toJson() { - return _$$AudioSourcePresetsStateImplToJson( - this, - ); - } -} - -abstract class _AudioSourcePresetsState implements AudioSourcePresetsState { - factory _AudioSourcePresetsState( - {final List presets, - final int selectedStreamingQualityIndex, - final int selectedStreamingContainerIndex, - final int selectedDownloadingQualityIndex, - final int selectedDownloadingContainerIndex}) = - _$AudioSourcePresetsStateImpl; - - factory _AudioSourcePresetsState.fromJson(Map json) = - _$AudioSourcePresetsStateImpl.fromJson; - - @override - List get presets; - @override - int get selectedStreamingQualityIndex; - @override - int get selectedStreamingContainerIndex; - @override - int get selectedDownloadingQualityIndex; - @override - int get selectedDownloadingContainerIndex; - - /// Create a copy of AudioSourcePresetsState - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$AudioSourcePresetsStateImplCopyWith<_$AudioSourcePresetsStateImpl> - get copyWith => throw _privateConstructorUsedError; -} diff --git a/lib/provider/metadata_plugin/audio_source/quality_presets.g.dart b/lib/provider/metadata_plugin/audio_source/quality_presets.g.dart deleted file mode 100644 index f3d8fd41..00000000 --- a/lib/provider/metadata_plugin/audio_source/quality_presets.g.dart +++ /dev/null @@ -1,38 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'quality_presets.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$AudioSourcePresetsStateImpl _$$AudioSourcePresetsStateImplFromJson( - Map json) => - _$AudioSourcePresetsStateImpl( - presets: (json['presets'] as List?) - ?.map((e) => SpotubeAudioSourceContainerPreset.fromJson( - Map.from(e as Map))) - .toList() ?? - const [], - selectedStreamingQualityIndex: - (json['selectedStreamingQualityIndex'] as num?)?.toInt() ?? 0, - selectedStreamingContainerIndex: - (json['selectedStreamingContainerIndex'] as num?)?.toInt() ?? 0, - selectedDownloadingQualityIndex: - (json['selectedDownloadingQualityIndex'] as num?)?.toInt() ?? 0, - selectedDownloadingContainerIndex: - (json['selectedDownloadingContainerIndex'] as num?)?.toInt() ?? 0, - ); - -Map _$$AudioSourcePresetsStateImplToJson( - _$AudioSourcePresetsStateImpl instance) => - { - 'presets': instance.presets.map((e) => e.toJson()).toList(), - 'selectedStreamingQualityIndex': instance.selectedStreamingQualityIndex, - 'selectedStreamingContainerIndex': - instance.selectedStreamingContainerIndex, - 'selectedDownloadingQualityIndex': - instance.selectedDownloadingQualityIndex, - 'selectedDownloadingContainerIndex': - instance.selectedDownloadingContainerIndex, - }; diff --git a/lib/provider/metadata_plugin/browse/section_items.dart b/lib/provider/metadata_plugin/browse/section_items.dart deleted file mode 100644 index 5c03ec2c..00000000 --- a/lib/provider/metadata_plugin/browse/section_items.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/utils/family_paginated.dart'; - -class MetadataPluginBrowseSectionItemsNotifier - extends FamilyPaginatedAsyncNotifier { - @override - Future> fetch( - int offset, - int limit, - ) async { - return await (await metadataPlugin).browse.sectionItems( - arg, - limit: limit, - offset: offset, - ); - } - - @override - build(arg) async { - ref.watch(metadataPluginAuthenticatedProvider); - return await fetch(0, 20); - } -} - -final metadataPluginBrowseSectionItemsProvider = AsyncNotifierProviderFamily< - MetadataPluginBrowseSectionItemsNotifier, - SpotubePaginationResponseObject, - String>( - () => MetadataPluginBrowseSectionItemsNotifier(), -); diff --git a/lib/provider/metadata_plugin/browse/sections.dart b/lib/provider/metadata_plugin/browse/sections.dart deleted file mode 100644 index 1f73e10c..00000000 --- a/lib/provider/metadata_plugin/browse/sections.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/utils/paginated.dart'; - -class MetadataPluginBrowseSectionsNotifier - extends PaginatedAsyncNotifier> { - @override - Future>> - fetch( - int offset, - int limit, - ) async { - return await (await metadataPlugin).browse.sections( - limit: limit, - offset: offset, - ); - } - - @override - build() async { - ref.watch(metadataPluginAuthenticatedProvider); - return await fetch(0, 20); - } -} - -final metadataPluginBrowseSectionsProvider = AsyncNotifierProvider< - MetadataPluginBrowseSectionsNotifier, - SpotubePaginationResponseObject>>( - () => MetadataPluginBrowseSectionsNotifier(), -); diff --git a/lib/provider/metadata_plugin/core/auth.dart b/lib/provider/metadata_plugin/core/auth.dart deleted file mode 100644 index dc5e7eb6..00000000 --- a/lib/provider/metadata_plugin/core/auth.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'dart:async'; - -import 'package:riverpod/riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; - -class MetadataPluginAuthenticatedNotifier extends AsyncNotifier { - @override - FutureOr build() async { - final defaultPluginConfig = ref.watch(metadataPluginsProvider); - if (defaultPluginConfig.asData?.value.defaultMetadataPluginConfig?.abilities - .contains(PluginAbilities.authentication) != - true) { - return false; - } - - final defaultPlugin = await ref.watch(metadataPluginProvider.future); - if (defaultPlugin == null) { - return false; - } - - final sub = defaultPlugin.auth.authStateStream.listen((event) { - state = AsyncData(defaultPlugin.auth.isAuthenticated()); - }); - - ref.onDispose(() { - sub.cancel(); - }); - - return defaultPlugin.auth.isAuthenticated(); - } -} - -final metadataPluginAuthenticatedProvider = - AsyncNotifierProvider( - MetadataPluginAuthenticatedNotifier.new, -); - -class AudioSourcePluginAuthenticatedNotifier extends AsyncNotifier { - @override - FutureOr build() async { - final defaultPluginConfig = ref.watch(metadataPluginsProvider); - if (defaultPluginConfig - .asData?.value.defaultAudioSourcePluginConfig?.abilities - .contains(PluginAbilities.authentication) != - true) { - return false; - } - - final defaultPlugin = await ref.watch(audioSourcePluginProvider.future); - if (defaultPlugin == null) { - return false; - } - - final sub = defaultPlugin.auth.authStateStream.listen((event) { - state = AsyncData(defaultPlugin.auth.isAuthenticated()); - }); - - ref.onDispose(() { - sub.cancel(); - }); - - return defaultPlugin.auth.isAuthenticated(); - } -} - -final audioSourcePluginAuthenticatedProvider = - AsyncNotifierProvider( - AudioSourcePluginAuthenticatedNotifier.new, -); diff --git a/lib/provider/metadata_plugin/core/repositories.dart b/lib/provider/metadata_plugin/core/repositories.dart deleted file mode 100644 index a78f63d9..00000000 --- a/lib/provider/metadata_plugin/core/repositories.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/utils/paginated.dart'; -import 'package:spotube/services/dio/dio.dart'; - -class MetadataPluginRepositoriesNotifier - extends PaginatedAsyncNotifier { - MetadataPluginRepositoriesNotifier() : super(); - - Map _hasMore = {}; - - @override - fetch(int offset, int limit) async { - final gitubSearch = globalDio.get( - "https://api.github.com/search/repositories", - queryParameters: { - "q": "topic:spotube-plugin", - "sort": "stars", - "order": "desc", - "page": offset, - "per_page": limit, - }, - ); - - final codebergSearch = globalDio.get( - "https://codeberg.org/api/v1/repos/search", - queryParameters: { - "q": "spotube-plugin", - "topic": "true", - "sort": "stars", - "order": "desc", - "page": offset, - "limit": limit, - }, - ); - - final responses = await Future.wait([ - if (_hasMore["github.com"] ?? true) gitubSearch, - if (_hasMore["codeberg.org"] ?? true) codebergSearch, - ]); - - final repos = responses - .expand( - (response) => response.data["data"] ?? response.data["items"] ?? [], - ) - .map((repo) { - return MetadataPluginRepository( - name: repo["name"] ?? "", - owner: repo["owner"]["login"] ?? "", - description: repo["description"] ?? "", - repoUrl: repo["html_url"] ?? "", - topics: repo["topics"].cast() ?? [], - ); - }).toList(); - - final hasMore = responses.any((response) { - final items = - (response.data["data"] ?? response.data["items"] ?? []) as List; - _hasMore[response.requestOptions.uri.host] = - items.length >= limit && items.isNotEmpty; - - return _hasMore[response.requestOptions.uri.host] ?? false; - }); - - return SpotubePaginationResponseObject( - items: repos, - total: responses.fold( - 0, - (previousValue, response) => previousValue + - (response.data["total_count"] ?? - int.tryParse(response.headers["x-total-count"]?[0] ?? "") ?? - 0) as int, - ), - hasMore: hasMore, - nextOffset: hasMore ? offset + 1 : null, - limit: limit, - ); - } - - @override - build() async { - return await fetch(0, 10); - } -} - -final metadataPluginRepositoriesProvider = AsyncNotifierProvider< - MetadataPluginRepositoriesNotifier, - SpotubePaginationResponseObject>( - () => MetadataPluginRepositoriesNotifier(), -); diff --git a/lib/provider/metadata_plugin/core/scrobble.dart b/lib/provider/metadata_plugin/core/scrobble.dart deleted file mode 100644 index 0f8fcc19..00000000 --- a/lib/provider/metadata_plugin/core/scrobble.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'dart:async'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/services/logger/logger.dart'; - -class MetadataPluginScrobbleNotifier - extends Notifier?> { - @override - build() { - final metadataPlugin = ref.watch(metadataPluginProvider); - final pluginConfig = ref - .watch(metadataPluginsProvider) - .valueOrNull - ?.defaultMetadataPluginConfig; - - if (metadataPlugin.valueOrNull == null || - pluginConfig == null || - !pluginConfig.abilities.contains(PluginAbilities.scrobbling)) { - return null; - } - - final controller = StreamController.broadcast(); - - final subscription = controller.stream.listen((event) async { - try { - await metadataPlugin.valueOrNull?.core.scrobble({ - "id": event.id, - "title": event.name, - "artists": event.artists - .map((artist) => { - "id": artist.id, - "name": artist.name, - }) - .toList(), - "album": { - "id": event.album.id, - "name": event.album.name, - }, - "timestamp": DateTime.now().millisecondsSinceEpoch ~/ 1000, - "duration_ms": event.durationMs, - "isrc": event is SpotubeFullTrackObject ? event.isrc : null, - }); - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }); - - ref.onDispose(() { - subscription.cancel(); - controller.close(); - }); - - return controller; - } - - void scrobble(SpotubeTrackObject track) { - state?.add(track); - } -} - -final metadataPluginScrobbleProvider = NotifierProvider< - MetadataPluginScrobbleNotifier, StreamController?>( - MetadataPluginScrobbleNotifier.new, -); diff --git a/lib/provider/metadata_plugin/core/support.dart b/lib/provider/metadata_plugin/core/support.dart deleted file mode 100644 index 8864f1b1..00000000 --- a/lib/provider/metadata_plugin/core/support.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; - -final metadataPluginSupportTextProvider = FutureProvider((ref) async { - final metadataPlugin = await ref.watch(metadataPluginProvider.future); - - if (metadataPlugin == null) { - throw 'No metadata plugin available'; - } - return await metadataPlugin.core.support; -}); - -final audioSourcePluginSupportTextProvider = - FutureProvider((ref) async { - final audioSourcePlugin = await ref.watch(audioSourcePluginProvider.future); - - if (audioSourcePlugin == null) { - throw 'No metadata plugin available'; - } - return await audioSourcePlugin.core.support; -}); diff --git a/lib/provider/metadata_plugin/core/user.dart b/lib/provider/metadata_plugin/core/user.dart deleted file mode 100644 index 3ad46d63..00000000 --- a/lib/provider/metadata_plugin/core/user.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; - -final metadataPluginUserProvider = FutureProvider( - (ref) async { - final metadataPlugin = await ref.watch(metadataPluginProvider.future); - final authenticated = - await ref.watch(metadataPluginAuthenticatedProvider.future); - - if (!authenticated || metadataPlugin == null) { - return null; - } - return metadataPlugin.user.me(); - }, -); diff --git a/lib/provider/metadata_plugin/library/albums.dart b/lib/provider/metadata_plugin/library/albums.dart deleted file mode 100644 index 10438025..00000000 --- a/lib/provider/metadata_plugin/library/albums.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:riverpod/riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/utils/paginated.dart'; - -class MetadataPluginSavedAlbumNotifier - extends PaginatedAsyncNotifier { - @override - Future> fetch( - int offset, - int limit, - ) async { - return await (await metadataPlugin).user.savedAlbums( - limit: limit, - offset: offset, - ); - } - - @override - build() async { - await ref.watch(metadataPluginAuthenticatedProvider.future); - return await fetch(0, 20); - } - - Future addFavorite(List albums) async { - if (albums.isEmpty || state.value == null) return; - final oldState = state.value; - - state = AsyncData( - state.value!.copyWith( - items: [ - ...albums, - ...state.value!.items, - ], - ), - ); - try { - await (await metadataPlugin).album.save(albums.map((e) => e.id).toList()); - } catch (e) { - state = AsyncData(oldState!); - rethrow; - } - } - - Future removeFavorite(List albums) async { - if (albums.isEmpty || state.value == null) return; - - final oldState = state.value; - - final albumIds = albums.map((e) => e.id).toList(); - state = AsyncData( - state.value!.copyWith( - items: state.value!.items - .where( - (e) => albumIds.contains((e).id) == false, - ) - .toList(), - ), - ); - try { - await (await metadataPlugin).album.unsave(albumIds); - } catch (e) { - state = AsyncData(oldState!); - rethrow; - } - } -} - -final metadataPluginSavedAlbumsProvider = AsyncNotifierProvider< - MetadataPluginSavedAlbumNotifier, - SpotubePaginationResponseObject>( - () => MetadataPluginSavedAlbumNotifier(), -); - -final metadataPluginIsSavedAlbumProvider = - FutureProvider.autoDispose.family( - (ref, albumId) async { - final savedAlbums = - await ref.watch(metadataPluginSavedAlbumsProvider.future); - final savedAlbumsNotifier = - ref.read(metadataPluginSavedAlbumsProvider.notifier); - final allSavedAlbums = savedAlbums.hasMore - ? await savedAlbumsNotifier.fetchAll() - : savedAlbums.items; - - return allSavedAlbums.any((element) => element.id == albumId); - }, -); diff --git a/lib/provider/metadata_plugin/library/artists.dart b/lib/provider/metadata_plugin/library/artists.dart deleted file mode 100644 index 31f976e0..00000000 --- a/lib/provider/metadata_plugin/library/artists.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:riverpod/riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/utils/paginated.dart'; - -class MetadataPluginSavedArtistNotifier - extends PaginatedAsyncNotifier { - @override - Future> fetch( - int offset, - int limit, - ) async { - final artists = await (await metadataPlugin).user.savedArtists( - limit: limit, - offset: offset, - ); - - return artists; - } - - @override - build() async { - await ref.watch(metadataPluginAuthenticatedProvider.future); - return await fetch(0, 20); - } - - Future addFavorite(List artists) async { - if (artists.isEmpty || state.value == null) return; - final oldState = state.value; - - state = AsyncData( - state.value!.copyWith( - items: [ - ...artists, - ...state.value!.items, - ], - ), - ); - try { - await (await metadataPlugin) - .artist - .save(artists.map((e) => e.id).toList()); - } catch (e) { - state = AsyncData(oldState!); - rethrow; - } - } - - Future removeFavorite(List artists) async { - if (artists.isEmpty || state.value == null) return; - - final oldState = state.value; - - final artistIds = artists.map((e) => e.id).toList(); - state = AsyncData( - state.value!.copyWith( - items: state.value!.items - .where( - (e) => artistIds.contains((e).id) == false, - ) - .toList(), - ), - ); - - try { - await (await metadataPlugin).artist.unsave(artistIds); - } catch (e) { - state = AsyncData(oldState!); - rethrow; - } - } -} - -final metadataPluginSavedArtistsProvider = AsyncNotifierProvider< - MetadataPluginSavedArtistNotifier, - SpotubePaginationResponseObject>( - () => MetadataPluginSavedArtistNotifier(), -); - -final metadataPluginIsSavedArtistProvider = - FutureProvider.autoDispose.family( - (ref, artistId) async { - final savedArtists = - await ref.watch(metadataPluginSavedArtistsProvider.future); - final savedArtistsNotifier = - ref.read(metadataPluginSavedArtistsProvider.notifier); - - final allSavedArtists = savedArtists.hasMore - ? await savedArtistsNotifier.fetchAll() - : savedArtists.items; - - return allSavedArtists.any((element) => element.id == artistId); - }, -); diff --git a/lib/provider/metadata_plugin/library/playlists.dart b/lib/provider/metadata_plugin/library/playlists.dart deleted file mode 100644 index 5793eb57..00000000 --- a/lib/provider/metadata_plugin/library/playlists.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/tracks/playlist.dart'; -import 'package:spotube/provider/metadata_plugin/utils/paginated.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; - -class MetadataPluginSavedPlaylistsNotifier - extends PaginatedAsyncNotifier { - MetadataPluginSavedPlaylistsNotifier() : super(); - - @override - fetch(int offset, int limit) async { - final playlists = await (await metadataPlugin) - .user - .savedPlaylists(limit: limit, offset: offset); - - return playlists; - } - - @override - build() async { - await ref.watch(metadataPluginAuthenticatedProvider.future); - - final playlists = await fetch(0, 20); - - return playlists; - } - - void updatePlaylist(SpotubeSimplePlaylistObject playlist) { - if (state.value == null) return; - - if (state.value!.items.none((e) => e.id == playlist.id)) return; - - state = AsyncData( - state.value!.copyWith( - items: state.value!.items - .map((element) => element.id == playlist.id ? playlist : element) - .toList(), - ), - ); - } - - Future addFavorite(SpotubeSimplePlaylistObject playlist) async { - if (state.value == null) return; - - final oldState = state.value; - - state = AsyncData( - state.value!.copyWith( - items: [ - playlist, - ...state.value!.items, - ], - ), - ); - - try { - await (await metadataPlugin).playlist.save(playlist.id); - } catch (e) { - state = AsyncData(oldState!); - rethrow; - } - } - - Future removeFavorite(SpotubeSimplePlaylistObject playlist) async { - if (state.value == null) return; - - final oldState = state.value; - state = AsyncData( - state.value!.copyWith( - items: state.value!.items.where((e) => (e).id != playlist.id).toList(), - ), - ); - - try { - await (await metadataPlugin).playlist.unsave(playlist.id); - } catch (e) { - state = AsyncData(oldState!); - rethrow; - } - } - - Future delete(String playlistId) async { - if (state.value == null) return; - final oldState = state; - try { - state = const AsyncLoading(); - await (await metadataPlugin).playlist.deletePlaylist(playlistId); - ref.invalidateSelf(); - ref.invalidate(metadataPluginIsSavedPlaylistProvider(playlistId)); - ref.invalidate(metadataPluginPlaylistTracksProvider(playlistId)); - } catch (e) { - state = oldState; - rethrow; - } - } - - Future addTracks(String playlistId, List trackIds) async { - if (state.value == null) return; - - await (await metadataPlugin) - .playlist - .addTracks(playlistId, trackIds: trackIds); - - ref.invalidate(metadataPluginPlaylistTracksProvider(playlistId)); - } - - Future removeTracks(String playlistId, List trackIds) async { - if (state.value == null) return; - - await (await metadataPlugin) - .playlist - .removeTracks(playlistId, trackIds: trackIds); - - ref.invalidate(metadataPluginPlaylistTracksProvider(playlistId)); - } -} - -final metadataPluginSavedPlaylistsProvider = AsyncNotifierProvider< - MetadataPluginSavedPlaylistsNotifier, - SpotubePaginationResponseObject>( - () => MetadataPluginSavedPlaylistsNotifier(), -); - -final metadataPluginIsSavedPlaylistProvider = - FutureProvider.family( - (ref, id) async { - final plugin = await ref.watch(metadataPluginProvider.future); - - if (plugin == null) { - throw MetadataPluginException.noDefaultMetadataPlugin(); - } - - final savedPlaylists = - await ref.watch(metadataPluginSavedPlaylistsProvider.future); - - final savedPlaylistsNotifier = - ref.read(metadataPluginSavedPlaylistsProvider.notifier); - - final allSavedPlaylists = savedPlaylists.hasMore - ? await savedPlaylistsNotifier.fetchAll() - : savedPlaylists.items; - - return allSavedPlaylists.any((element) => element.id == id); - }, -); diff --git a/lib/provider/metadata_plugin/library/tracks.dart b/lib/provider/metadata_plugin/library/tracks.dart deleted file mode 100644 index d19865dd..00000000 --- a/lib/provider/metadata_plugin/library/tracks.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:spotube/provider/metadata_plugin/utils/paginated.dart'; - -class MetadataPluginSavedTracksNotifier - extends AutoDisposePaginatedAsyncNotifier { - MetadataPluginSavedTracksNotifier() : super(); - - @override - fetch(offset, limit) async { - final tracks = await (await metadataPlugin).user.savedTracks( - offset: offset, - limit: limit, - ); - - return tracks; - } - - @override - build() async { - ref.cacheFor(); - - await ref.watch(metadataPluginAuthenticatedProvider.future); - return await fetch(0, 20); - } - - Future addFavorite(List tracks) async { - if (state.value == null) { - return; - } - - final oldState = state.value; - state = AsyncData( - state.value!.copyWith( - items: [ - ...tracks.whereType(), - ...state.value!.items - ], - ), - ); - - try { - await (await metadataPlugin).track.save(tracks.map((e) => e.id).toList()); - } catch (e) { - state = AsyncData(oldState!); - rethrow; - } - } - - Future removeFavorite(List tracks) async { - if (state.value == null) { - return; - } - - final oldState = state.value; - state = AsyncData( - state.value!.copyWith( - items: state.value!.items - .where( - (savedTrack) => !tracks.any((track) => track.id == savedTrack.id), - ) - .toList(), - ), - ); - - try { - await (await metadataPlugin) - .track - .unsave(tracks.map((e) => e.id).toList()); - } catch (e) { - state = AsyncData(oldState!); - rethrow; - } - } -} - -final metadataPluginSavedTracksProvider = AutoDisposeAsyncNotifierProvider< - MetadataPluginSavedTracksNotifier, - SpotubePaginationResponseObject>( - () => MetadataPluginSavedTracksNotifier(), -); - -final metadataPluginIsSavedTrackProvider = - FutureProvider.autoDispose.family( - (ref, trackId) async { - final savedTracks = - await ref.watch(metadataPluginSavedTracksProvider.future); - final allSavedTracks = savedTracks.hasMore - ? await ref.read(metadataPluginSavedTracksProvider.notifier).fetchAll() - : savedTracks.items; - - return allSavedTracks.any((track) => track.id == trackId); - }, -); diff --git a/lib/provider/metadata_plugin/metadata_plugin_provider.dart b/lib/provider/metadata_plugin/metadata_plugin_provider.dart deleted file mode 100644 index cdc96c41..00000000 --- a/lib/provider/metadata_plugin/metadata_plugin_provider.dart +++ /dev/null @@ -1,635 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:dio/dio.dart'; -import 'package:drift/drift.dart'; -import 'package:flutter/services.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/database/database.dart'; -import 'package:spotube/provider/youtube_engine/youtube_engine.dart'; -import 'package:spotube/services/dio/dio.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; -import 'package:spotube/services/metadata/metadata.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:archive/archive.dart'; -import 'package:pub_semver/pub_semver.dart'; - -final allowedDomainsRegex = RegExp( - r"^(https?:\/\/)?(www\.)?(github\.com|codeberg\.org)\/.+", -); - -class MetadataPluginState { - final List plugins; - final int defaultMetadataPlugin; - final int defaultAudioSourcePlugin; - - const MetadataPluginState({ - this.plugins = const [], - this.defaultMetadataPlugin = -1, - this.defaultAudioSourcePlugin = -1, - }); - - PluginConfiguration? get defaultMetadataPluginConfig { - if (defaultMetadataPlugin < 0 || defaultMetadataPlugin >= plugins.length) { - return null; - } - return plugins[defaultMetadataPlugin]; - } - - PluginConfiguration? get defaultAudioSourcePluginConfig { - if (defaultAudioSourcePlugin < 0 || - defaultAudioSourcePlugin >= plugins.length) { - return null; - } - return plugins[defaultAudioSourcePlugin]; - } - - factory MetadataPluginState.fromJson(Map json) { - return MetadataPluginState( - plugins: (json["plugins"] as List) - .map((e) => PluginConfiguration.fromJson(e)) - .toList(), - defaultMetadataPlugin: json["default_metadata_plugin"] ?? -1, - defaultAudioSourcePlugin: json['default_audio_source_plugin'], - ); - } - - Map toJson() { - return { - "plugins": plugins.map((e) => e.toJson()).toList(), - "default_metadata_plugin": defaultMetadataPlugin, - "default_audio_source_plugin": defaultAudioSourcePlugin - }; - } - - MetadataPluginState copyWith({ - List? plugins, - int? defaultMetadataPlugin, - int? defaultAudioSourcePlugin, - }) { - return MetadataPluginState( - plugins: plugins ?? this.plugins, - defaultMetadataPlugin: - defaultMetadataPlugin ?? this.defaultMetadataPlugin, - defaultAudioSourcePlugin: - defaultAudioSourcePlugin ?? this.defaultAudioSourcePlugin, - ); - } -} - -class MetadataPluginNotifier extends AsyncNotifier { - AppDatabase get database => ref.read(databaseProvider); - - @override - build() async { - final database = ref.watch(databaseProvider); - - final subscription = database.pluginsTable.select().watch().listen( - (event) async { - state = AsyncValue.data(await toStatePlugins(event)); - }, - ); - - ref.onDispose(() { - subscription.cancel(); - }); - - final plugins = await database.pluginsTable.select().get(); - - final pluginState = await toStatePlugins(plugins); - - await _loadDefaultPlugins(pluginState); - - return pluginState; - } - - Future toStatePlugins( - List plugins, - ) async { - int defaultMetadataPlugin = -1; - int defaultAudioSourcePlugin = -1; - final pluginConfigs = []; - - for (int i = 0; i < plugins.length; i++) { - final plugin = plugins[i]; - - final pluginConfig = PluginConfiguration( - name: plugin.name, - author: plugin.author, - description: plugin.description, - version: plugin.version, - entryPoint: plugin.entryPoint, - pluginApiVersion: plugin.pluginApiVersion, - repository: plugin.repository, - apis: plugin.apis - .map( - (e) => PluginApis.values.firstWhereOrNull( - (api) => api.name == e, - ), - ) - .nonNulls - .toList(), - abilities: plugin.abilities - .map( - (e) => PluginAbilities.values.firstWhereOrNull( - (ability) => ability.name == e, - ), - ) - .nonNulls - .toList(), - ); - - final pluginExtractionDir = await _getPluginExtractionDir(pluginConfig); - final pluginJsonFile = - File(join(pluginExtractionDir.path, "plugin.json")); - final pluginBinaryFile = - File(join(pluginExtractionDir.path, "plugin.out")); - - if (!await pluginExtractionDir.exists() || - !await pluginJsonFile.exists() || - !await pluginBinaryFile.exists()) { - // Delete the plugin entry from DB if the plugin files are not there. - await database.pluginsTable.deleteOne(plugin); - continue; - } - - pluginConfigs.add(pluginConfig); - - if (plugin.selectedForMetadata) { - defaultMetadataPlugin = pluginConfigs.length - 1; - } - if (plugin.selectedForAudioSource) { - defaultAudioSourcePlugin = pluginConfigs.length - 1; - } - } - - return MetadataPluginState( - plugins: pluginConfigs, - defaultMetadataPlugin: defaultMetadataPlugin, - defaultAudioSourcePlugin: defaultAudioSourcePlugin, - ); - } - - Future _loadDefaultPlugins(MetadataPluginState pluginState) async { - const plugins = [ - "spotube-plugin-musicbrainz-listenbrainz", - "spotube-plugin-youtube-audio", - ]; - - for (final plugin in plugins) { - final byteData = await rootBundle.load( - "assets/plugins/$plugin/plugin.smplug", - ); - final pluginConfig = - await extractPluginArchive(byteData.buffer.asUint8List()); - try { - await addPlugin(pluginConfig); - } on MetadataPluginException catch (e) { - if (e.errorCode == MetadataPluginErrorCode.duplicatePlugin && - await isPluginUpdate(pluginConfig)) { - final oldConfig = pluginState.plugins - .firstWhereOrNull((p) => p.slug == pluginConfig.slug); - if (oldConfig == null) continue; - final isDefaultMetadata = - oldConfig == pluginState.defaultMetadataPluginConfig; - final isDefaultAudioSource = - oldConfig == pluginState.defaultAudioSourcePluginConfig; - - await removePlugin(pluginConfig); - await addPlugin(pluginConfig); - - if (isDefaultMetadata) { - await setDefaultMetadataPlugin(pluginConfig); - } - if (isDefaultAudioSource) { - await setDefaultAudioSourcePlugin(pluginConfig); - } - } - } - } - } - - Uri _getGithubReleasesUrl(String repoUrl) { - final parsedUri = Uri.parse(repoUrl); - final uri = parsedUri.replace( - host: "api.github.com", - pathSegments: [ - "repos", - ...parsedUri.pathSegments, - "releases", - ], - queryParameters: { - "per_page": "1", - "page": "1", - }, - ); - - return uri; - } - - Uri _getCodebergeReleasesUrl(String repoUrl) { - final parsedUri = Uri.parse(repoUrl); - final uri = parsedUri.replace( - pathSegments: [ - "api", - "v1", - "repos", - ...parsedUri.pathSegments, - "releases", - ], - queryParameters: { - "limit": "1", - "page": "1", - }, - ); - - return uri; - } - - Future _getPluginDownloadUrl(Uri uri) async { - AppLogger.log.i("Getting plugin download URL from: $uri"); - final res = await globalDio.getUri( - uri, - options: Options(responseType: ResponseType.json), - ); - - if (res.statusCode != 200) { - throw MetadataPluginException.failedToGetRelease(); - } - final releases = res.data as List; - if (releases.isEmpty) { - throw MetadataPluginException.noReleasesFound(); - } - final latestRelease = releases.first; - final downloadUrl = (latestRelease["assets"] as List).firstWhere( - (asset) => (asset["name"] as String).endsWith(".smplug"), - )["browser_download_url"]; - if (downloadUrl == null) { - throw MetadataPluginException.assetUrlNotFound(); - } - return downloadUrl; - } - - /// Root directory where all metadata plugins are stored. - Future _getPluginRootDir() async => Directory( - join( - (await getApplicationSupportDirectory()).path, - "metadata-plugins", - ), - ); - - /// Directory where the plugin will be extracted. - /// This is a unique directory for each plugin version. - /// It is used to avoid conflicts when multiple versions of the same plugin are installed - Future _getPluginExtractionDir(PluginConfiguration plugin) async { - final pluginDir = await _getPluginRootDir(); - final pluginExtractionDirPath = join( - pluginDir.path, - "${ServiceUtils.sanitizeFilename(plugin.author)}-${ServiceUtils.sanitizeFilename(plugin.name)}-${plugin.version}", - ); - return Directory(pluginExtractionDirPath); - } - - Future extractPluginArchive(List bytes) async { - final archive = ZipDecoder().decodeBytes(bytes); - final pluginJson = archive - .firstWhereOrNull((file) => file.isFile && file.name == "plugin.json"); - - if (pluginJson == null) { - throw MetadataPluginException.pluginConfigJsonNotFound(); - } - final pluginConfig = PluginConfiguration.fromJson( - jsonDecode( - utf8.decode(pluginJson.content as List), - ) as Map, - ); - - final pluginDir = await _getPluginRootDir(); - await pluginDir.create(recursive: true); - - final pluginExtractionDir = await _getPluginExtractionDir(pluginConfig); - - for (final file in archive) { - if (file.isFile) { - final filename = file.name; - final data = file.content as List; - final extractedFile = File(join( - pluginExtractionDir.path, - filename, - )); - await extractedFile.create(recursive: true); - await extractedFile.writeAsBytes(data); - } - } - - return pluginConfig; - } - - /// Downloads, extracts & caches the plugin from the given URL and returns the plugin config. - /// If only a text/html URL is provided, it will try to get the latest release from - /// the URL for supported websites (github.com, codeberg.org). - Future downloadAndCachePlugin(String url) async { - final res = await globalDio.head(url); - final isSupportedWebsite = - (res.headers["Content-Type"]?.first)?.startsWith("text/html") == true && - allowedDomainsRegex.hasMatch(url); - String pluginDownloadUrl = url; - if (isSupportedWebsite) { - if (url.contains("github.com")) { - final uri = _getGithubReleasesUrl(url); - pluginDownloadUrl = await _getPluginDownloadUrl(uri); - } else if (url.contains("codeberg.org")) { - final uri = _getCodebergeReleasesUrl(url); - pluginDownloadUrl = await _getPluginDownloadUrl(uri); - } else { - throw MetadataPluginException.unsupportedPluginDownloadWebsite(); - } - } - - // Now let's download, extract and cache the plugin - final pluginDir = await _getPluginRootDir(); - await pluginDir.create(recursive: true); - - final pluginRes = await globalDio.get( - pluginDownloadUrl, - options: Options( - responseType: ResponseType.bytes, - followRedirects: true, - receiveTimeout: const Duration(seconds: 30), - ), - ); - - if ((pluginRes.statusCode ?? 500) > 299) { - throw MetadataPluginException.pluginDownloadFailed(); - } - - return await extractPluginArchive(pluginRes.data); - } - - bool validatePluginApiCompatibility(PluginConfiguration plugin) { - final configPluginApiVersion = Version.parse(plugin.pluginApiVersion); - final appPluginApiVersion = MetadataPlugin.pluginApiVersion; - - // Plugin API's major version must match the app's major version - if (configPluginApiVersion.major != appPluginApiVersion.major) { - return false; - } - return configPluginApiVersion >= appPluginApiVersion; - } - - void _assertPluginApiCompatibility(PluginConfiguration plugin) { - if (!validatePluginApiCompatibility(plugin)) { - throw MetadataPluginException.pluginApiVersionMismatch(); - } - } - - Future addPlugin(PluginConfiguration plugin) async { - _assertPluginApiCompatibility(plugin); - - final pluginRes = await (database.pluginsTable.select() - ..where( - (tbl) => - tbl.name.equals(plugin.name) & tbl.author.equals(plugin.author), - ) - ..limit(1)) - .get(); - - if (pluginRes.isNotEmpty) { - throw MetadataPluginException.duplicatePlugin(); - } - - await database.pluginsTable.insertOne( - PluginsTableCompanion.insert( - name: plugin.name, - author: plugin.author, - description: plugin.description, - version: plugin.version, - entryPoint: plugin.entryPoint, - apis: plugin.apis.map((e) => e.name).toList(), - abilities: plugin.abilities.map((e) => e.name).toList(), - pluginApiVersion: Value(plugin.pluginApiVersion), - repository: Value(plugin.repository), - // Setting the very first plugin as the default plugin - selectedForMetadata: Value( - (state.valueOrNull?.plugins - .where( - (d) => d.abilities.contains(PluginAbilities.metadata)) - .isEmpty ?? - true) && - plugin.abilities.contains(PluginAbilities.metadata), - ), - selectedForAudioSource: Value( - (state.valueOrNull?.plugins - .where((d) => - d.abilities.contains(PluginAbilities.audioSource)) - .isEmpty ?? - true) && - plugin.abilities.contains(PluginAbilities.audioSource), - ), - ), - ); - } - - Future removePlugin(PluginConfiguration plugin) async { - final pluginExtractionDir = await _getPluginExtractionDir(plugin); - - if (pluginExtractionDir.existsSync()) { - await pluginExtractionDir.delete(recursive: true); - } - await database.pluginsTable.deleteWhere((tbl) => - tbl.name.equals(plugin.name) & tbl.author.equals(plugin.author)); - - // Same here, if the removed plugin is the default plugin - // set the first available plugin as the default plugin - // only when there is 1 remaining plugin - if (state.valueOrNull?.defaultMetadataPluginConfig == plugin) { - final remainingPlugins = state.valueOrNull?.plugins.where( - (p) => - p != plugin && p.abilities.contains(PluginAbilities.metadata), - ) ?? - []; - if (remainingPlugins.length == 1) { - await setDefaultMetadataPlugin(remainingPlugins.first); - } - } - - if (state.valueOrNull?.defaultAudioSourcePluginConfig == plugin) { - final remainingPlugins = state.valueOrNull?.plugins.where( - (p) => - p != plugin && - p.abilities.contains(PluginAbilities.audioSource), - ) ?? - []; - if (remainingPlugins.length == 1) { - await setDefaultAudioSourcePlugin(remainingPlugins.first); - } - } - } - - Future isPluginUpdate(PluginConfiguration newPlugin) async { - final pluginRes = await (database.pluginsTable.select() - ..where( - (tbl) => - tbl.name.equals(newPlugin.name) & - tbl.author.equals(newPlugin.author), - ) - ..limit(1)) - .get(); - - if (pluginRes.isEmpty) { - return false; - } - - final oldPlugin = pluginRes.first; - final oldPluginApiVersion = Version.parse(oldPlugin.pluginApiVersion); - final newPluginApiVersion = Version.parse(newPlugin.pluginApiVersion); - - return newPluginApiVersion > oldPluginApiVersion; - } - - Future updatePlugin( - PluginConfiguration plugin, - PluginUpdateAvailable update, - ) async { - final isDefaultMetadata = - plugin == state.valueOrNull?.defaultMetadataPluginConfig; - final isDefaultAudioSource = - plugin == state.valueOrNull?.defaultAudioSourcePluginConfig; - final pluginUpdatedConfig = - await downloadAndCachePlugin(update.downloadUrl); - - if (pluginUpdatedConfig.name != plugin.name && - pluginUpdatedConfig.author != plugin.author) { - throw MetadataPluginException.invalidPluginConfiguration(); - } - _assertPluginApiCompatibility(pluginUpdatedConfig); - - await removePlugin(plugin); - await addPlugin(pluginUpdatedConfig); - - if (isDefaultMetadata) { - await setDefaultMetadataPlugin(pluginUpdatedConfig); - } - if (isDefaultAudioSource) { - await setDefaultAudioSourcePlugin(pluginUpdatedConfig); - } - } - - Future setDefaultMetadataPlugin(PluginConfiguration plugin) async { - assert( - plugin.abilities.contains(PluginAbilities.metadata), - "Must be a metadata plugin", - ); - - await database.pluginsTable - .update() - .write(const PluginsTableCompanion(selectedForMetadata: Value(false))); - - await (database.pluginsTable.update() - ..where((tbl) => - tbl.name.equals(plugin.name) & tbl.author.equals(plugin.author))) - .write( - const PluginsTableCompanion(selectedForMetadata: Value(true)), - ); - } - - Future setDefaultAudioSourcePlugin(PluginConfiguration plugin) async { - assert( - plugin.abilities.contains(PluginAbilities.audioSource), - "Must be an audio-source plugin", - ); - - await database.pluginsTable.update().write( - const PluginsTableCompanion(selectedForAudioSource: Value(false))); - - await (database.pluginsTable.update() - ..where((tbl) => - tbl.name.equals(plugin.name) & tbl.author.equals(plugin.author))) - .write( - const PluginsTableCompanion(selectedForAudioSource: Value(true)), - ); - } - - Future getPluginByteCode(PluginConfiguration plugin) async { - final pluginExtractionDirPath = await _getPluginExtractionDir(plugin); - - final libraryFile = File(join(pluginExtractionDirPath.path, "plugin.out")); - - if (!libraryFile.existsSync()) { - throw MetadataPluginException.pluginByteCodeFileNotFound(); - } - - return await libraryFile.readAsBytes(); - } - - Future getLogoPath(PluginConfiguration plugin) async { - final pluginExtractionDirPath = await _getPluginExtractionDir(plugin); - - final logoFile = File(join(pluginExtractionDirPath.path, "logo.png")); - - if (!logoFile.existsSync()) { - return null; - } - - return logoFile; - } -} - -final metadataPluginsProvider = - AsyncNotifierProvider( - MetadataPluginNotifier.new, -); - -final metadataPluginProvider = FutureProvider( - (ref) async { - final defaultPlugin = await ref.watch( - metadataPluginsProvider - .selectAsync((data) => data.defaultMetadataPluginConfig), - ); - final youtubeEngine = ref.read(youtubeEngineProvider); - - if (defaultPlugin == null) { - return null; - } - - final pluginsNotifier = ref.read(metadataPluginsProvider.notifier); - final pluginByteCode = - await pluginsNotifier.getPluginByteCode(defaultPlugin); - - return await MetadataPlugin.create( - youtubeEngine, - defaultPlugin, - pluginByteCode, - ); - }, -); - -final audioSourcePluginProvider = FutureProvider( - (ref) async { - final defaultPlugin = await ref.watch( - metadataPluginsProvider - .selectAsync((data) => data.defaultAudioSourcePluginConfig), - ); - final youtubeEngine = ref.watch(youtubeEngineProvider); - - if (defaultPlugin == null) { - return null; - } - - final pluginsNotifier = ref.read(metadataPluginsProvider.notifier); - final pluginByteCode = - await pluginsNotifier.getPluginByteCode(defaultPlugin); - - return await MetadataPlugin.create( - youtubeEngine, - defaultPlugin, - pluginByteCode, - ); - }, -); diff --git a/lib/provider/metadata_plugin/playlist/playlist.dart b/lib/provider/metadata_plugin/playlist/playlist.dart deleted file mode 100644 index 9a41340d..00000000 --- a/lib/provider/metadata_plugin/playlist/playlist.dart +++ /dev/null @@ -1,131 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/library/playlists.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/core/user.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; -import 'package:spotube/services/metadata/metadata.dart'; - -class MetadataPluginPlaylistNotifier - extends AutoDisposeFamilyAsyncNotifier { - Future get metadataPlugin async { - final metadataPlugin = await ref.read(metadataPluginProvider.future); - - if (metadataPlugin == null) { - throw MetadataPluginException.noDefaultMetadataPlugin(); - } - - return metadataPlugin; - } - - @override - build(playlistId) async { - ref.cacheFor(); - - return (await metadataPlugin).playlist.getPlaylist(playlistId); - } - - Future create({ - required String name, - String? description, - bool? public, - bool? collaborative, - void Function(dynamic error)? onError, - }) async { - final userId = await ref - .read(metadataPluginUserProvider.selectAsync((data) => data?.id)); - if (userId == null) { - throw Exception('User ID is not available. Please log in first.'); - } - state = const AsyncValue.loading(); - try { - final playlist = await (await metadataPlugin).playlist.create( - userId, - name: name, - description: description, - public: public, - collaborative: collaborative, - ); - if (playlist != null) { - state = AsyncValue.data(playlist); - } - ref.invalidate(metadataPluginSavedPlaylistsProvider); - } catch (e) { - onError?.call(e); - rethrow; - } - } - - Future modify({ - String? name, - String? description, - bool? public, - bool? collaborative, - void Function(dynamic error)? onError, - }) async { - try { - if (name == null && - description == null && - public == null && - collaborative == null) { - throw Exception('No modifications provided.'); - } - await (await metadataPlugin).playlist.update( - arg, - name: name, - description: description, - public: public, - collaborative: collaborative, - ); - ref.invalidateSelf(); - } on Exception catch (e) { - onError?.call(e); - rethrow; - } - } - - Future addTracks(List trackIds, - [void Function(dynamic error)? onError]) async { - if (state.value == null) return; - - try { - await ref - .read(metadataPluginSavedPlaylistsProvider.notifier) - .addTracks(arg, trackIds); - } catch (e) { - onError?.call(e); - rethrow; - } - } - - Future removeTracks(List trackIds, - [void Function(dynamic error)? onError]) async { - try { - if (state.value == null) return; - - await ref - .read(metadataPluginSavedPlaylistsProvider.notifier) - .removeTracks(arg, trackIds); - } catch (e) { - onError?.call(e); - rethrow; - } - } - - Future delete() async { - if (state.value == null) return; - final userId = await ref - .read(metadataPluginUserProvider.selectAsync((data) => data?.id)); - if (userId == null || userId != state.value!.owner.id) { - throw Exception('You can only delete your own playlists.'); - } - - await ref.read(metadataPluginSavedPlaylistsProvider.notifier).delete(arg); - } -} - -final metadataPluginPlaylistProvider = AutoDisposeAsyncNotifierProviderFamily< - MetadataPluginPlaylistNotifier, SpotubeFullPlaylistObject, String>( - () => MetadataPluginPlaylistNotifier(), -); diff --git a/lib/provider/metadata_plugin/search/albums.dart b/lib/provider/metadata_plugin/search/albums.dart deleted file mode 100644 index 40bb62e6..00000000 --- a/lib/provider/metadata_plugin/search/albums.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:spotube/provider/metadata_plugin/utils/family_paginated.dart'; - -class MetadataPluginSearchAlbumsNotifier - extends AutoDisposeFamilyPaginatedAsyncNotifier { - MetadataPluginSearchAlbumsNotifier() : super(); - - @override - fetch(offset, limit) async { - if (arg.isEmpty) { - return SpotubePaginationResponseObject( - limit: limit, - nextOffset: null, - total: 0, - items: [], - hasMore: false, - ); - } - - final res = await (await metadataPlugin).search.albums( - arg, - offset: offset, - limit: limit, - ); - - return res; - } - - @override - build(arg) async { - ref.cacheFor(); - - ref.watch(metadataPluginProvider); - return await fetch(0, 20); - } -} - -final metadataPluginSearchAlbumsProvider = - AutoDisposeAsyncNotifierProviderFamily, String>( - () => MetadataPluginSearchAlbumsNotifier(), -); diff --git a/lib/provider/metadata_plugin/search/all.dart b/lib/provider/metadata_plugin/search/all.dart deleted file mode 100644 index 4b051e58..00000000 --- a/lib/provider/metadata_plugin/search/all.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; - -final metadataPluginSearchAllProvider = - FutureProvider.autoDispose.family( - (ref, query) async { - final metadataPlugin = await ref.watch(metadataPluginProvider.future); - - if (metadataPlugin == null) { - throw MetadataPluginException.noDefaultMetadataPlugin(); - } - - return metadataPlugin.search.all(query); - }, -); - -final metadataPluginSearchChipsProvider = FutureProvider((ref) async { - final metadataPlugin = await ref.watch(metadataPluginProvider.future); - - if (metadataPlugin == null) { - throw MetadataPluginException.noDefaultMetadataPlugin(); - } - return metadataPlugin.search.chips; -}); diff --git a/lib/provider/metadata_plugin/search/artists.dart b/lib/provider/metadata_plugin/search/artists.dart deleted file mode 100644 index b4d619f7..00000000 --- a/lib/provider/metadata_plugin/search/artists.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:spotube/provider/metadata_plugin/utils/family_paginated.dart'; - -class MetadataPluginSearchArtistsNotifier - extends AutoDisposeFamilyPaginatedAsyncNotifier { - MetadataPluginSearchArtistsNotifier() : super(); - - @override - fetch(offset, limit) async { - if (arg.isEmpty) { - return SpotubePaginationResponseObject( - limit: limit, - nextOffset: null, - total: 0, - items: [], - hasMore: false, - ); - } - - final res = await (await metadataPlugin).search.artists( - arg, - offset: offset, - limit: limit, - ); - - return res; - } - - @override - build(arg) async { - ref.cacheFor(); - - ref.watch(metadataPluginProvider); - return await fetch(0, 20); - } -} - -final metadataPluginSearchArtistsProvider = - AutoDisposeAsyncNotifierProviderFamily, String>( - () => MetadataPluginSearchArtistsNotifier(), -); diff --git a/lib/provider/metadata_plugin/search/playlists.dart b/lib/provider/metadata_plugin/search/playlists.dart deleted file mode 100644 index dbf54250..00000000 --- a/lib/provider/metadata_plugin/search/playlists.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:spotube/provider/metadata_plugin/utils/family_paginated.dart'; - -class MetadataPluginSearchPlaylistsNotifier - extends AutoDisposeFamilyPaginatedAsyncNotifier { - MetadataPluginSearchPlaylistsNotifier() : super(); - - @override - fetch(offset, limit) async { - if (arg.isEmpty) { - return SpotubePaginationResponseObject( - limit: limit, - nextOffset: null, - total: 0, - items: [], - hasMore: false, - ); - } - - final res = await (await metadataPlugin).search.playlists( - arg, - offset: offset, - limit: limit, - ); - - return res; - } - - @override - build(arg) async { - ref.cacheFor(); - - ref.watch(metadataPluginProvider); - return await fetch(0, 20); - } -} - -final metadataPluginSearchPlaylistsProvider = - AutoDisposeAsyncNotifierProviderFamily< - MetadataPluginSearchPlaylistsNotifier, - SpotubePaginationResponseObject, - String>( - () => MetadataPluginSearchPlaylistsNotifier(), -); diff --git a/lib/provider/metadata_plugin/search/tracks.dart b/lib/provider/metadata_plugin/search/tracks.dart deleted file mode 100644 index 0b6ac141..00000000 --- a/lib/provider/metadata_plugin/search/tracks.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:spotube/provider/metadata_plugin/utils/family_paginated.dart'; - -class MetadataPluginSearchTracksNotifier - extends AutoDisposeFamilyPaginatedAsyncNotifier { - MetadataPluginSearchTracksNotifier() : super(); - - @override - fetch(offset, limit) async { - if (arg.isEmpty) { - return SpotubePaginationResponseObject( - limit: limit, - nextOffset: null, - total: 0, - items: [], - hasMore: false, - ); - } - - final tracks = await (await metadataPlugin).search.tracks( - arg, - offset: offset, - limit: limit, - ); - - return tracks; - } - - @override - build(arg) async { - ref.cacheFor(); - - ref.watch(metadataPluginProvider); - return await fetch(0, 20); - } -} - -final metadataPluginSearchTracksProvider = - AutoDisposeAsyncNotifierProviderFamily, String>( - () => MetadataPluginSearchTracksNotifier(), -); diff --git a/lib/provider/metadata_plugin/tracks/album.dart b/lib/provider/metadata_plugin/tracks/album.dart deleted file mode 100644 index 5491bdd0..00000000 --- a/lib/provider/metadata_plugin/tracks/album.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/utils/family_paginated.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; - -class MetadataPluginAlbumTracksNotifier - extends AutoDisposeFamilyPaginatedAsyncNotifier { - MetadataPluginAlbumTracksNotifier() : super(); - - @override - fetch(offset, limit) async { - final tracks = await (await metadataPlugin).album.tracks( - arg, - offset: offset, - limit: limit, - ); - - return tracks; - } - - @override - build(arg) async { - ref.cacheFor(); - - ref.watch(metadataPluginProvider); - return await fetch(0, 20); - } -} - -final metadataPluginAlbumTracksProvider = - AutoDisposeAsyncNotifierProviderFamily, String>( - () => MetadataPluginAlbumTracksNotifier(), -); diff --git a/lib/provider/metadata_plugin/tracks/playlist.dart b/lib/provider/metadata_plugin/tracks/playlist.dart deleted file mode 100644 index 7fdd47db..00000000 --- a/lib/provider/metadata_plugin/tracks/playlist.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/utils/family_paginated.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; - -class MetadataPluginPlaylistTracksNotifier - extends AutoDisposeFamilyPaginatedAsyncNotifier { - MetadataPluginPlaylistTracksNotifier() : super(); - - @override - fetch(offset, limit) async { - final tracks = await (await metadataPlugin).playlist.tracks( - arg, - offset: offset, - limit: limit, - ); - - return tracks; - } - - @override - build(arg) async { - ref.cacheFor(); - - ref.watch(metadataPluginProvider); - return await fetch(0, 20); - } -} - -final metadataPluginPlaylistTracksProvider = - AutoDisposeAsyncNotifierProviderFamily, String>( - () => MetadataPluginPlaylistTracksNotifier(), -); diff --git a/lib/provider/metadata_plugin/tracks/track.dart b/lib/provider/metadata_plugin/tracks/track.dart deleted file mode 100644 index 1beac43a..00000000 --- a/lib/provider/metadata_plugin/tracks/track.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; - -final metadataPluginTrackProvider = - FutureProvider.family((ref, trackId) async { - final metadataPlugin = await ref.watch(metadataPluginProvider.future); - - if (metadataPlugin == null) { - throw MetadataPluginException.noDefaultMetadataPlugin(); - } - - return metadataPlugin.track.getTrack(trackId); -}); diff --git a/lib/provider/metadata_plugin/updater/update_checker.dart b/lib/provider/metadata_plugin/updater/update_checker.dart deleted file mode 100644 index 6a7dc589..00000000 --- a/lib/provider/metadata_plugin/updater/update_checker.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; - -final metadataPluginUpdateCheckerProvider = - FutureProvider((ref) async { - final metadataPluginConfigs = await ref.watch(metadataPluginsProvider.future); - final metadataPlugin = await ref.watch(metadataPluginProvider.future); - - if (metadataPlugin == null || - metadataPluginConfigs.defaultMetadataPluginConfig == null) { - return null; - } - - return metadataPlugin.core - .checkUpdate(metadataPluginConfigs.defaultMetadataPluginConfig!); -}); - -final audioSourcePluginUpdateCheckerProvider = - FutureProvider((ref) async { - final audioSourcePluginConfigs = - await ref.watch(metadataPluginsProvider.future); - final audioSourcePlugin = await ref.watch(audioSourcePluginProvider.future); - - if (audioSourcePlugin == null || - audioSourcePluginConfigs.defaultAudioSourcePluginConfig == null) { - return null; - } - - return audioSourcePlugin.core - .checkUpdate(audioSourcePluginConfigs.defaultAudioSourcePluginConfig!); -}); diff --git a/lib/provider/metadata_plugin/utils/common.dart b/lib/provider/metadata_plugin/utils/common.dart deleted file mode 100644 index dc56e494..00000000 --- a/lib/provider/metadata_plugin/utils/common.dart +++ /dev/null @@ -1,56 +0,0 @@ -// ignore: implementation_imports -import 'package:riverpod/src/async_notifier.dart'; -import 'dart:async'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; -import 'package:spotube/services/metadata/metadata.dart'; - -extension PaginationExtension on AsyncValue { - bool get isLoadingNextPage => this is AsyncData && this is AsyncLoadingNext; -} - -mixin MetadataPluginMixin -// ignore: invalid_use_of_internal_member - on AsyncNotifierBase> { - Future get metadataPlugin async { - final plugin = await ref.read(metadataPluginProvider.future); - - if (plugin == null) { - throw MetadataPluginException.noDefaultMetadataPlugin(); - } - - return plugin; - } -} - -extension AutoDisposeAsyncNotifierCacheFor -// ignore: deprecated_member_use - on AutoDisposeAsyncNotifierProviderRef { - // When invoked keeps your provider alive for [duration] - // ignore: unused_element - void cacheFor([Duration duration = const Duration(minutes: 5)]) { - final link = keepAlive(); - final timer = Timer(duration, () => link.close()); - onDispose(() => timer.cancel()); - } -} - -// ignore: deprecated_member_use -extension AutoDisposeCacheFor on AutoDisposeRef { - // When invoked keeps your provider alive for [duration] - // ignore: unused_element - void cacheFor([Duration duration = const Duration(minutes: 5)]) { - final link = keepAlive(); - final timer = Timer(duration, () => link.close()); - onDispose(() => timer.cancel()); - } -} - -// ignore: subtype_of_sealed_class -class AsyncLoadingNext extends AsyncData { - const AsyncLoadingNext(super.value); -} diff --git a/lib/provider/metadata_plugin/utils/family_paginated.dart b/lib/provider/metadata_plugin/utils/family_paginated.dart deleted file mode 100644 index b798dc8e..00000000 --- a/lib/provider/metadata_plugin/utils/family_paginated.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:spotube/services/logger/logger.dart'; - -abstract class FamilyPaginatedAsyncNotifier - extends FamilyAsyncNotifier, A> - with MetadataPluginMixin { - Future> fetch(int offset, int limit); - - Future fetchMore() async { - if (state.value == null || !state.value!.hasMore) return; - - final oldState = state.value; - - try { - state = AsyncLoadingNext(state.asData!.value); - - final newState = await fetch( - state.value!.nextOffset!, - state.value!.limit, - ); - - final oldItems = - state.value!.items.isEmpty ? [] : state.value!.items.cast(); - final items = newState.items.isEmpty ? [] : newState.items.cast(); - - state = AsyncData(newState.copyWith(items: [...oldItems, ...items])); - } catch (e, stack) { - AppLogger.reportError(e, stack); - state = AsyncData(oldState!); - } - } - - Future> fetchAll() async { - if (state.value == null) return []; - if (!state.value!.hasMore) return state.value!.items.cast(); - - bool hasMore = true; - while (hasMore) { - final newState = await fetch( - state.value!.nextOffset!, - max(state.value!.limit, 100), - ) - .catchError( - (e) => fetch(state.value!.nextOffset!, max(state.value!.limit, 50)), - ) - .catchError( - (e) => fetch(state.value!.nextOffset!, state.value!.limit), - ) - .catchError( - (e) async { - await Future.delayed(const Duration(milliseconds: 500)); - return fetch(state.value!.nextOffset!, state.value!.limit); - }, - ); - - hasMore = newState.hasMore; - - final oldItems = - state.value!.items.isEmpty ? [] : state.value!.items.cast(); - final items = newState.items.isEmpty ? [] : newState.items.cast(); - - state = AsyncData( - newState.copyWith(items: [...oldItems, ...items]), - ); - } - - return state.value!.items.cast(); - } -} - -abstract class AutoDisposeFamilyPaginatedAsyncNotifier - extends AutoDisposeFamilyAsyncNotifier, - A> with MetadataPluginMixin { - Future> fetch(int offset, int limit); - - Future fetchMore() async { - if (state.value == null || !state.value!.hasMore) return; - final oldState = state.value; - - try { - state = AsyncLoadingNext(state.value!); - - final newState = await fetch( - state.value!.nextOffset!, - state.value!.limit, - ); - - state = AsyncData( - newState.copyWith(items: [ - ...state.value!.items.cast(), - ...newState.items.cast(), - ]), - ); - } catch (e, stack) { - AppLogger.reportError(e, stack); - state = AsyncData(oldState!); - } - } - - Future> fetchAll() async { - if (state.value == null) return []; - if (!state.value!.hasMore) return state.value!.items.cast(); - - bool hasMore = true; - while (hasMore) { - final newState = await fetch( - state.value!.nextOffset!, - max(state.value!.limit, 100), - ) - .catchError( - (e) => fetch(state.value!.nextOffset!, max(state.value!.limit, 50)), - ) - .catchError( - (e) => fetch(state.value!.nextOffset!, state.value!.limit), - ) - .catchError( - (e) async { - await Future.delayed(const Duration(milliseconds: 500)); - return fetch(state.value!.nextOffset!, state.value!.limit); - }, - ); - - hasMore = newState.hasMore; - - final oldItems = - state.value!.items.isEmpty ? [] : state.value!.items.cast(); - final items = newState.items.isEmpty ? [] : newState.items.cast(); - - state = AsyncData( - newState.copyWith(items: [...oldItems, ...items]), - ); - } - - return state.value!.items.cast(); - } -} diff --git a/lib/provider/metadata_plugin/utils/paginated.dart b/lib/provider/metadata_plugin/utils/paginated.dart deleted file mode 100644 index 4c77441a..00000000 --- a/lib/provider/metadata_plugin/utils/paginated.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -// ignore: implementation_imports -import 'package:riverpod/src/async_notifier.dart'; -import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:spotube/services/logger/logger.dart'; - -mixin PaginatedAsyncNotifierMixin - // ignore: invalid_use_of_internal_member - on AsyncNotifierBase> { - Future> fetch(int offset, int limit); - - Future fetchMore() async { - if (state.value == null || !state.value!.hasMore) return; - - final oldState = state.value; - try { - state = AsyncLoadingNext(state.asData!.value); - - final newState = await fetch( - state.value!.nextOffset!, - state.value!.limit, - ); - - final oldItems = - state.value!.items.isEmpty ? [] : state.value!.items.cast(); - final items = newState.items.isEmpty ? [] : newState.items.cast(); - - state = AsyncData(newState.copyWith(items: [...oldItems, ...items])); - } catch (e, stack) { - AppLogger.reportError(e, stack); - state = AsyncData(oldState!); - } - } - - Future> fetchAll() async { - if (state.value == null) return []; - if (!state.value!.hasMore) return state.value!.items.cast(); - - bool hasMore = true; - while (hasMore) { - final newState = await fetch( - state.value!.nextOffset!, - max(state.value!.limit, 100), - ) - .catchError( - (e) => fetch(state.value!.nextOffset!, max(state.value!.limit, 50)), - ) - .catchError( - (e) => fetch(state.value!.nextOffset!, state.value!.limit), - ) - .catchError( - (e) async { - await Future.delayed(const Duration(milliseconds: 500)); - return fetch(state.value!.nextOffset!, state.value!.limit); - }, - ); - - hasMore = newState.hasMore; - - final oldItems = - state.value!.items.isEmpty ? [] : state.value!.items.cast(); - final items = newState.items.isEmpty ? [] : newState.items.cast(); - - state = AsyncData( - newState.copyWith(items: [...oldItems, ...items]), - ); - } - - return state.value!.items.cast(); - } -} - -abstract class PaginatedAsyncNotifier - extends AsyncNotifier> - with PaginatedAsyncNotifierMixin, MetadataPluginMixin {} - -abstract class AutoDisposePaginatedAsyncNotifier - extends AutoDisposeAsyncNotifier> - with PaginatedAsyncNotifierMixin, MetadataPluginMixin {} diff --git a/lib/provider/scrobbler/scrobbler.dart b/lib/provider/scrobbler/scrobbler.dart deleted file mode 100644 index f5e5556d..00000000 --- a/lib/provider/scrobbler/scrobbler.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'dart:async'; - -import 'package:drift/drift.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:scrobblenaut/scrobblenaut.dart'; -import 'package:spotube/collections/env.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/database/database.dart'; -import 'package:spotube/services/logger/logger.dart'; - -class ScrobblerNotifier extends AsyncNotifier { - final StreamController _scrobbleController = - StreamController.broadcast(); - @override - build() async { - final database = ref.watch(databaseProvider); - - final loginInfo = await (database.select(database.scrobblerTable) - ..where((t) => t.id.equals(0))) - .getSingleOrNull(); - - final subscription = - database.select(database.scrobblerTable).watch().listen((event) async { - try { - if (event.isNotEmpty) { - state = await AsyncValue.guard( - () async => Scrobblenaut( - lastFM: await LastFM.authenticateWithPasswordHash( - apiKey: Env.lastFmApiKey, - apiSecret: Env.lastFmApiSecret, - username: event.first.username, - passwordHash: event.first.passwordHash.value, - ), - ), - ); - } else { - state = const AsyncValue.data(null); - } - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }); - - final scrobblerSubscription = - _scrobbleController.stream.listen((track) async { - try { - await state.asData?.value?.track.scrobble( - artist: track.artists.first.name, - track: track.name, - album: track.album.name, - chosenByUser: true, - duration: Duration(milliseconds: track.durationMs), - timestamp: DateTime.now().toUtc(), - ); - } catch (e, stackTrace) { - AppLogger.reportError(e, stackTrace); - } - }); - - ref.onDispose(() { - subscription.cancel(); - scrobblerSubscription.cancel(); - }); - - if (loginInfo == null) { - return null; - } - - return Scrobblenaut( - lastFM: await LastFM.authenticateWithPasswordHash( - apiKey: Env.lastFmApiKey, - apiSecret: Env.lastFmApiSecret, - username: loginInfo.username, - passwordHash: loginInfo.passwordHash.value, - ), - ); - } - - Future login( - String username, - String password, - ) async { - final database = ref.read(databaseProvider); - - final lastFm = await LastFM.authenticate( - apiKey: Env.lastFmApiKey, - apiSecret: Env.lastFmApiSecret, - username: username, - password: password, - ); - - if (!lastFm.isAuth) throw Exception("Invalid credentials"); - - await database.into(database.scrobblerTable).insert( - ScrobblerTableCompanion.insert( - id: const Value(0), - username: username, - passwordHash: DecryptedText(lastFm.passwordHash!), - ), - ); - } - - Future logout() async { - state = const AsyncValue.data(null); - final database = ref.read(databaseProvider); - await database.delete(database.scrobblerTable).go(); - } - - void scrobble(SpotubeTrackObject track) { - _scrobbleController.add(track); - } - - Future love(SpotubeTrackObject track) async { - await state.asData?.value?.track.love( - artist: track.artists.asString(), - track: track.name, - ); - } - - Future unlove(SpotubeTrackObject track) async { - await state.asData?.value?.track.unLove( - artist: track.artists.asString(), - track: track.name, - ); - } -} - -final scrobblerProvider = - AsyncNotifierProvider( - () => ScrobblerNotifier(), -); diff --git a/lib/provider/server/active_track_sources.dart b/lib/provider/server/active_track_sources.dart deleted file mode 100644 index 603ca0e4..00000000 --- a/lib/provider/server/active_track_sources.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/server/sourced_track_provider.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; - -final activeTrackSourcesProvider = FutureProvider< - ({ - SourcedTrack? source, - SourcedTrackNotifier? notifier, - SpotubeTrackObject track, - })?>((ref) async { - final audioPlayerState = ref.watch(audioPlayerProvider); - - if (audioPlayerState.activeTrack == null) { - return null; - } - - if (audioPlayerState.activeTrack is SpotubeLocalTrackObject) { - return ( - source: null, - notifier: null, - track: audioPlayerState.activeTrack!, - ); - } - - final sourcedTrack = await ref.watch( - sourcedTrackProvider( - audioPlayerState.activeTrack! as SpotubeFullTrackObject, - ).future, - ); - final sourcedTrackNotifier = ref.watch( - sourcedTrackProvider( - audioPlayerState.activeTrack! as SpotubeFullTrackObject, - ).notifier, - ); - - return ( - source: sourcedTrack, - track: audioPlayerState.activeTrack!, - notifier: sourcedTrackNotifier, - ); -}); diff --git a/lib/provider/server/bonsoir.dart b/lib/provider/server/bonsoir.dart deleted file mode 100644 index fcc40e54..00000000 --- a/lib/provider/server/bonsoir.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:bonsoir/bonsoir.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/connect/clients.dart'; -import 'package:spotube/provider/server/server.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/device_info/device_info.dart'; -import 'package:spotube/utils/primitive_utils.dart'; - -final bonsoirProvider = FutureProvider((ref) async { - final enabled = ref.watch( - userPreferencesProvider.select((s) => s.enableConnect), - ); - final resolvedService = await ref.watch( - connectClientsProvider.selectAsync((s) => s.resolvedService), - ); - - if (!enabled || resolvedService != null) { - return null; - } - - final (server: _, :port) = await ref.watch(serverProvider.future); - - final service = BonsoirService( - name: await DeviceInfoService.instance.computerName(), - type: '_spotube._tcp', - port: port, - attributes: { - "id": PrimitiveUtils.uuid.v4(), - "deviceId": await DeviceInfoService.instance.deviceId(), - }, - ); - - final broadcast = BonsoirBroadcast(service: service); - - await broadcast.ready; - await broadcast.start(); - - ref.onDispose(() async { - await broadcast.stop(); - }); -}); diff --git a/lib/provider/server/pipeline.dart b/lib/provider/server/pipeline.dart deleted file mode 100644 index 8f97ce89..00000000 --- a/lib/provider/server/pipeline.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shelf/shelf.dart'; - -final pipelineProvider = Provider((ref) { - const pipeline = Pipeline(); - if (kDebugMode) { - pipeline.addMiddleware(logRequests()); - } - return pipeline; -}); diff --git a/lib/provider/server/router.dart b/lib/provider/server/router.dart deleted file mode 100644 index f103ea8c..00000000 --- a/lib/provider/server/router.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shelf/shelf.dart'; -import 'package:shelf_router/shelf_router.dart'; -import 'package:spotube/provider/server/routes/connect.dart'; -import 'package:spotube/provider/server/routes/playback.dart'; - -final serverRouterProvider = Provider((ref) { - final playbackRoutes = ref.watch(serverPlaybackRoutesProvider); - final connectRoutes = ref.watch(serverConnectRoutesProvider); - - final router = Router(); - - router.get("/ping", (Request request) => Response.ok("pong")); - - router.head("/stream/", playbackRoutes.headStreamTrackId); - router.get("/stream/", playbackRoutes.getStreamTrackId); - - router.get("/playback/toggle-playback", playbackRoutes.togglePlayback); - router.get("/playback/previous", playbackRoutes.previousTrack); - router.get("/playback/next", playbackRoutes.nextTrack); - - router.all("/ws", connectRoutes.websocket); - - return router; -}); diff --git a/lib/provider/server/routes/connect.dart b/lib/provider/server/routes/connect.dart deleted file mode 100644 index 257c4cb4..00000000 --- a/lib/provider/server/routes/connect.dart +++ /dev/null @@ -1,249 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shelf/shelf.dart'; -import 'package:shelf_web_socket/shelf_web_socket.dart'; -import 'package:spotube/collections/routes.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/volume_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -extension _WebsocketSinkExts on WebSocketSink { - void addEvent(WebSocketEvent event) { - add(event.toJson()); - } -} - -class ServerConnectRoutes { - final Ref ref; - final StreamController _connectClientStreamController; - final List subscriptions; - ServerConnectRoutes(this.ref) - : _connectClientStreamController = StreamController.broadcast(), - subscriptions = [] { - ref.onDispose(() { - _connectClientStreamController.close(); - for (final subscription in subscriptions) { - subscription.cancel(); - } - }); - } - - AudioPlayerNotifier get audioPlayerNotifier => - ref.read(audioPlayerProvider.notifier); - PlaybackHistoryActions get historyNotifier => - ref.read(playbackHistoryActionsProvider); - Stream get connectClientStream => - _connectClientStreamController.stream; - - final List _allowedConnections = []; - - FutureOr websocket(Request req) { - return webSocketHandler( - ( - WebSocketChannel channel, - String? protocol, - ) async { - final context = - (req.context["shelf.io.connection_info"] as HttpConnectionInfo?); - final origin = "${context?.remoteAddress.host}:${context?.remotePort}"; - _connectClientStreamController.add(origin); - - // Confirm whether user allows to connect - if (rootNavigatorKey.currentContext?.mounted == true && - _allowedConnections.contains(origin) == false) { - final confirmed = await showDialog( - context: rootNavigatorKey.currentContext!, - builder: (context) { - return AlertDialog( - title: Text(context.l10n.connect), - content: Text( - context.l10n.connect_request(origin), - ), - actions: [ - Button.secondary( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text(context.l10n.decline), - ), - Button.primary( - onPressed: () { - Navigator.of(context).pop(true); - }, - child: Text(context.l10n.accept), - ), - ], - ); - }, - ) ?? - false; - - if (confirmed) { - _allowedConnections.add(origin); - } else { - channel.sink.addEvent( - WebSocketErrorEvent("Connection denied"), - ); - await channel.sink.close(); - return; - } - } - - ref.listen( - audioPlayerProvider, - (previous, next) { - channel.sink.addEvent(WebSocketQueueEvent(next)); - }, - fireImmediately: true, - ); - - // because audioPlayer events doesn't fireImmediately - channel.sink.addEvent(WebSocketPlayingEvent(audioPlayer.isPlaying)); - channel.sink.addEvent( - WebSocketPositionEvent(audioPlayer.position), - ); - channel.sink.addEvent( - WebSocketDurationEvent(audioPlayer.duration), - ); - channel.sink.addEvent(WebSocketShuffleEvent(audioPlayer.isShuffled)); - channel.sink.addEvent(WebSocketLoopEvent(audioPlayer.loopMode)); - channel.sink.addEvent(WebSocketVolumeEvent(audioPlayer.volume)); - - subscriptions.addAll([ - audioPlayer.positionStream.listen( - (position) { - channel.sink.addEvent(WebSocketPositionEvent(position)); - }, - ), - audioPlayer.playingStream.listen( - (playing) { - channel.sink.addEvent(WebSocketPlayingEvent(playing)); - }, - ), - audioPlayer.durationStream.listen( - (duration) { - channel.sink.addEvent(WebSocketDurationEvent(duration)); - }, - ), - audioPlayer.shuffledStream.listen( - (shuffled) { - channel.sink.addEvent(WebSocketShuffleEvent(shuffled)); - }, - ), - audioPlayer.loopModeStream.listen( - (loopMode) { - channel.sink.addEvent(WebSocketLoopEvent(loopMode)); - }, - ), - audioPlayer.volumeStream.listen( - (volume) { - channel.sink.addEvent(WebSocketVolumeEvent(volume)); - }, - ), - channel.stream.listen( - (message) async { - try { - final event = WebSocketEvent.fromJson( - jsonDecode(message), - (data) => data, - ); - - event.onLoad((event) async { - await audioPlayerNotifier.load( - event.data.tracks.cast().toList(), - autoPlay: true, - initialIndex: event.data.initialIndex ?? 0, - ); - - if (event.data.collectionId == null) return; - audioPlayerNotifier.addCollection(event.data.collectionId!); - if (event.data.collection is SpotubeSimpleAlbumObject) { - historyNotifier.addAlbums( - [event.data.collection as SpotubeSimpleAlbumObject]); - } else { - historyNotifier.addPlaylists( - [event.data.collection as SpotubeSimplePlaylistObject]); - } - }); - - event.onPause((event) async { - await audioPlayer.pause(); - }); - - event.onResume((event) async { - await audioPlayer.resume(); - }); - - event.onStop((event) async { - await ref.read(audioPlayerProvider.notifier).stop(); - }); - - event.onNext((event) async { - await audioPlayer.skipToNext(); - }); - - event.onPrevious((event) async { - await audioPlayer.skipToPrevious(); - }); - - event.onJump((event) async { - await audioPlayer.jumpTo(event.data); - }); - - event.onSeek((event) async { - await audioPlayer.seek(event.data); - }); - - event.onShuffle((event) async { - await audioPlayer.setShuffle(event.data); - }); - - event.onLoop((event) async { - await audioPlayer.setLoopMode(event.data); - }); - - event.onAddTrack((event) async { - await audioPlayerNotifier.addTrack(event.data); - }); - - event.onRemoveTrack((event) async { - await audioPlayerNotifier.removeTrack(event.data); - }); - - event.onReorder((event) async { - await audioPlayerNotifier.moveTrack( - event.data.oldIndex, - event.data.newIndex, - ); - }); - - event.onVolume((event) async { - ref.read(volumeProvider.notifier).setVolume(event.data); - }); - } catch (e, stackTrace) { - AppLogger.reportError(e, stackTrace); - channel.sink.addEvent(WebSocketErrorEvent(e.toString())); - } - }, - onDone: () { - AppLogger.log.i('Connection closed'); - }, - ), - ]); - }, - )(req); - } -} - -final serverConnectRoutesProvider = Provider((ref) => ServerConnectRoutes(ref)); diff --git a/lib/provider/server/routes/playback.dart b/lib/provider/server/routes/playback.dart deleted file mode 100644 index db6bf8f5..00000000 --- a/lib/provider/server/routes/playback.dart +++ /dev/null @@ -1,358 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:math'; - -import 'package:dio/dio.dart' hide Response; -import 'package:dio/dio.dart' as dio_lib; -import 'package:flutter/foundation.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:metadata_god/metadata_god.dart'; -import 'package:path/path.dart'; -import 'package:shelf/shelf.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/models/parser/range_headers.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/audio_player/state.dart'; - -import 'package:spotube/provider/server/active_track_sources.dart'; -import 'package:spotube/provider/server/sourced_track_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; - -final _deviceClients = Set.unmodifiable({ - YoutubeApiClient.ios, - YoutubeApiClient.android, - YoutubeApiClient.mweb, - YoutubeApiClient.safari, -}); - -String? get _randomUserAgent => _deviceClients - .elementAt( - Random().nextInt(_deviceClients.length), - ) - .payload["context"]["client"]["userAgent"]; - -class ServerPlaybackRoutes { - final Ref ref; - UserPreferences get userPreferences => ref.read(userPreferencesProvider); - AudioPlayerState get playlist => ref.read(audioPlayerProvider); - final Dio dio; - - ServerPlaybackRoutes(this.ref) : dio = Dio(); - - Future _getTrackCacheFilePath(SourcedTrack track) async { - return join( - await UserPreferencesNotifier.getMusicCacheDir(), - ServiceUtils.sanitizeFilename( - '${track.query.name} - ${track.query.artists.map((d) => d.name).join(",")} (${track.info.id}).${track.qualityPreset!.getFileExtension()}', - ), - ); - } - - Future _getSourcedTrack( - Request request, - String trackId, - ) async { - final track = - playlist.tracks.firstWhere((element) => element.id == trackId); - - final activeSourcedTrack = - await ref.read(activeTrackSourcesProvider.future); - - final media = audioPlayer.playlist.medias - .firstWhere((e) => e.uri == request.requestedUri.toString()); - final spotubeMedia = - media is SpotubeMedia ? media : SpotubeMedia.media(media); - final sourcedTrack = activeSourcedTrack?.track.id == track.id - ? activeSourcedTrack?.source - : await ref.read( - sourcedTrackProvider(spotubeMedia.track as SpotubeFullTrackObject) - .future, - ); - - return sourcedTrack; - } - - Future streamTrackInformation( - Request request, - SourcedTrack track, - ) async { - AppLogger.log.i( - "HEAD request for track: ${track.query.name}\n" - "Headers: ${request.headers}", - ); - - final trackCacheFile = File(await _getTrackCacheFilePath(track)); - - if (await trackCacheFile.exists() && userPreferences.cacheMusic) { - final fileLength = await trackCacheFile.length(); - - return dio_lib.Response( - statusCode: 200, - headers: Headers.fromMap({ - "content-type": ["audio/${track.qualityPreset!.name}"], - "content-length": ["$fileLength"], - "accept-ranges": ["bytes"], - "content-range": ["bytes 0-$fileLength/$fileLength"], - }), - requestOptions: RequestOptions(path: request.requestedUri.toString()), - ); - } - - String url = track.url ?? - await ref - .read(sourcedTrackProvider(track.query).notifier) - .swapWithNextSibling() - .then((track) => track.url!); - - final options = Options( - headers: { - "user-agent": _randomUserAgent, - "Cache-Control": "max-age=3600", - "Connection": "keep-alive", - "host": Uri.parse(url).host, - }, - validateStatus: (status) => status! < 400, - ); - - final res = await dio.head(url, options: options); - - return res; - } - - Future streamTrack( - Request request, - SourcedTrack track, - Map headers, - ) async { - AppLogger.log.i( - "GET request for track: ${track.query.name}\n" - "Headers: ${request.headers}", - ); - - final trackCacheFile = File(await _getTrackCacheFilePath(track)); - - if (await trackCacheFile.exists() && userPreferences.cacheMusic) { - final bytes = await trackCacheFile.readAsBytes(); - final cachedFileLength = bytes.length; - - return dio_lib.Response( - statusCode: 200, - headers: Headers.fromMap({ - "content-type": ["audio/${track.qualityPreset!.name}"], - "content-length": ["${cachedFileLength - 1}"], - "accept-ranges": ["bytes"], - "content-range": [ - "bytes 0-${cachedFileLength - 1}/$cachedFileLength" - ], - "connection": ["close"], - }), - requestOptions: RequestOptions(path: request.requestedUri.toString()), - data: bytes, - ); - } - - String url = track.url ?? - await ref - .read(sourcedTrackProvider(track.query).notifier) - .swapWithNextSibling() - .then((track) => track.url!); - - final options = Options( - headers: { - ...headers, - "user-agent": _randomUserAgent, - "Cache-Control": "max-age=3600", - "Connection": "keep-alive", - "host": Uri.parse(url).host, - }, - responseType: ResponseType.stream, - validateStatus: (status) => status! < 400, - ); - - final contentLengthRes = await Future.value( - dio.head( - url, - options: options.copyWith(responseType: ResponseType.bytes), - ), - ).catchError((e, stack) async { - AppLogger.reportError(e, stack); - - final sourcedTrack = await ref - .read(sourcedTrackProvider(track.query).notifier) - .refreshStreamingUrl(); - - url = sourcedTrack.url!; - - return dio.head(url, options: options); - }); - - // Redirect to m3u8 link directly as it handles range requests internally - if (contentLengthRes?.headers.value("content-type") == - "application/vnd.apple.mpegurl") { - return dio_lib.Response( - statusCode: 301, - statusMessage: "M3U8 Redirect", - headers: Headers.fromMap({ - "location": [url], - "content-type": ["application/vnd.apple.mpegurl"], - }), - requestOptions: RequestOptions(path: request.requestedUri.toString()), - isRedirect: true, - ); - } - - final res = await dio.get(url, options: options); - - AppLogger.log.i( - "Response for track: ${track.query.name}\n" - "Status Code: ${res.statusCode}\n" - "Headers: ${res.headers.map}", - ); - - if (!userPreferences.cacheMusic) { - return res; - } - - final resStream = res.data!.stream.asBroadcastStream(); - - final trackPartialCacheFile = File("${trackCacheFile.path}.part"); - if (!await trackPartialCacheFile.exists()) { - await trackPartialCacheFile.create(recursive: true); - } - - // Write the stream to the file based on the range - final partialCacheFileSink = - trackPartialCacheFile.openWrite(mode: FileMode.writeOnlyAppend); - final contentRange = res.headers.value("content-range") != null - ? ContentRangeHeader.parse(res.headers.value("content-range") ?? "") - : ContentRangeHeader(0, 0, 0); - - resStream.listen( - (data) { - partialCacheFileSink.add(data); - }, - onError: (e, stack) { - partialCacheFileSink.close(); - }, - onDone: () async { - await partialCacheFileSink.close(); - - final fileLength = await trackPartialCacheFile.length(); - if (fileLength != contentRange.total) return; - - await trackPartialCacheFile.rename(trackCacheFile.path); - - if (track.qualityPreset!.getFileExtension() == "weba") return; - - final imageBytes = await ServiceUtils.downloadImage( - track.query.album.images.asUrlString( - placeholder: ImagePlaceholder.albumArt, - index: 1, - ), - ); - - await MetadataGod.writeMetadata( - file: trackCacheFile.path, - metadata: track.query.toMetadata( - imageBytes: imageBytes, - fileLength: fileLength, - ), - ).catchError((e, stackTrace) { - AppLogger.reportError(e, stackTrace); - }); - }, - cancelOnError: true, - ); - - res.data?.stream = - resStream; // To avoid Stream has been already listened to exception - return res; - } - - /// @head('/stream/') - Future headStreamTrackId(Request request, String trackId) async { - try { - final sourcedTrack = await _getSourcedTrack(request, trackId); - - if (sourcedTrack == null) { - return Response.notFound("Track not found in the current queue"); - } - - final res = await streamTrackInformation( - request, - sourcedTrack, - ); - - return Response( - res.statusCode!, - headers: res.headers.map, - ); - } catch (e, stack) { - AppLogger.reportError(e, stack); - return Response.internalServerError(); - } - } - - /// @get('/stream/') - Future getStreamTrackId(Request request, String trackId) async { - try { - final sourcedTrack = await _getSourcedTrack(request, trackId); - - if (sourcedTrack == null) { - return Response.notFound("Track not found in the current queue"); - } - - final res = await streamTrack( - request, - sourcedTrack, - request.headers, - ); - - if (res.data is ResponseBody) { - return Response( - res.statusCode!, - body: (res.data as ResponseBody).stream, - headers: res.headers.map, - ); - } - - return Response( - res.statusCode!, - body: res.data, - headers: res.headers.map, - ); - } catch (e, stack) { - AppLogger.reportError(e, stack); - return Response.internalServerError(); - } - } - - /// @get('/playback/toggle-playback') - Future togglePlayback(Request request) async { - audioPlayer.isPlaying - ? await audioPlayer.pause() - : await audioPlayer.resume(); - - return Response.ok("Playback toggled"); - } - - /// @get('/playback/previous') - Future previousTrack(Request request) async { - await audioPlayer.skipToPrevious(); - return Response.ok("Previous track"); - } - - /// @get('/playback/next') - Future nextTrack(Request request) async { - await audioPlayer.skipToNext(); - return Response.ok("Next track"); - } -} - -final serverPlaybackRoutesProvider = - Provider((ref) => ServerPlaybackRoutes(ref)); diff --git a/lib/provider/server/server.dart b/lib/provider/server/server.dart deleted file mode 100644 index d10815bf..00000000 --- a/lib/provider/server/server.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:io'; -import 'dart:math'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shelf/shelf_io.dart'; -import 'package:spotube/provider/server/pipeline.dart'; -import 'package:spotube/provider/server/router.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/logger/logger.dart'; - -final serverProvider = FutureProvider( - (ref) async { - final enabledRemoteConnect = ref.watch( - userPreferencesProvider.select((value) => value.enableConnect), - ); - final connectPort = ref.watch( - userPreferencesProvider.select((value) => value.connectPort), - ); - final pipeline = ref.watch(pipelineProvider); - final router = ref.watch(serverRouterProvider); - - // When connect port is -1, we need to generate a random port - // but we shouldn't reset it if it's already been set (caused by a state change) - if (connectPort == -1) { - if (SpotubeMedia.serverPort == 0) { - final port = Random().nextInt(17500) + 5000; - SpotubeMedia.serverPort = port; - } - } else { - SpotubeMedia.serverPort = connectPort; - } - - final server = await serve( - pipeline.addHandler(router.call), - enabledRemoteConnect - ? InternetAddress.anyIPv4 - : InternetAddress.loopbackIPv4, - SpotubeMedia.serverPort, - ); - - AppLogger.log.t( - 'Playback server at http://${server.address.host}:${server.port}', - ); - - ref.onDispose(() { - server.close(); - }); - - return ( - server: server, - port: SpotubeMedia.serverPort, - ); - }, -); diff --git a/lib/provider/server/sourced_track_provider.dart b/lib/provider/server/sourced_track_provider.dart deleted file mode 100644 index 7934ecc7..00000000 --- a/lib/provider/server/sourced_track_provider.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'dart:async'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/audio_source/quality_presets.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; - -class SourcedTrackNotifier - extends FamilyAsyncNotifier { - @override - FutureOr build(query) { - ref.watch(audioSourcePluginProvider); - ref.watch(audioSourcePresetsProvider); - - return SourcedTrack.fetchFromTrack(query: query, ref: ref); - } - - Future refreshStreamingUrl() async { - return await update((prev) async { - return await prev.refreshStream(); - }); - } - - Future copyWithSibling() async { - return await update((prev) async { - return prev.copyWithSibling(); - }); - } - - Future swapWithSibling( - SpotubeAudioSourceMatchObject sibling, - ) async { - return await update((prev) async { - return await prev.swapWithSibling(sibling) ?? prev; - }); - } - - Future swapWithNextSibling() async { - return await update((prev) async { - return await prev.swapWithSibling(prev.siblings.first) as SourcedTrack; - }); - } -} - -final sourcedTrackProvider = AsyncNotifierProviderFamily( - () => SourcedTrackNotifier(), -); diff --git a/lib/provider/skip_segments/skip_segments.dart b/lib/provider/skip_segments/skip_segments.dart deleted file mode 100644 index dc06f326..00000000 --- a/lib/provider/skip_segments/skip_segments.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/database/database.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:dio/dio.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/server/active_track_sources.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -import 'package:spotube/services/dio/dio.dart'; - -class SourcedSegments { - final String source; - final List segments; - - SourcedSegments({required this.source, required this.segments}); -} - -Future> getAndCacheSkipSegments( - String id, Ref ref) async { - final database = ref.read(databaseProvider); - try { - final cached = await (database.select(database.skipSegmentTable) - ..where((s) => s.trackId.equals(id))) - .get(); - - if (cached.isNotEmpty) { - return cached; - } - - final res = await globalDio.getUri( - Uri( - scheme: "https", - host: "sponsor.ajay.app", - path: "/api/skipSegments", - queryParameters: { - "videoID": id, - "category": [ - 'sponsor', - 'selfpromo', - 'interaction', - 'intro', - 'outro', - 'music_offtopic' - ], - "actionType": 'skip' - }, - ), - options: Options( - responseType: ResponseType.json, - validateStatus: (status) => (status ?? 0) < 500, - ), - ); - - if (res.data == "Not Found") { - return List.castFrom([]); - } - - final data = res.data as List; - final segments = data.map((obj) { - final start = obj["segment"].first.toInt(); - final end = obj["segment"].last.toInt(); - return SkipSegmentTableCompanion.insert( - trackId: id, - start: start, - end: end, - ); - }).toList(); - - await database.batch((b) { - b.insertAll(database.skipSegmentTable, segments); - }); - - return await (database.select(database.skipSegmentTable) - ..where((s) => s.trackId.equals(id))) - .get(); - } catch (e, stack) { - AppLogger.reportError(e, stack); - return List.castFrom([]); - } -} - -final segmentProvider = FutureProvider( - (ref) async { - final snapshot = await ref.watch(activeTrackSourcesProvider.future); - if (snapshot == null) return null; - final (:track, :source, :notifier) = snapshot; - if (track is SpotubeLocalTrackObject) return null; - if (!source!.source.toLowerCase().contains("youtube")) return null; - - final skipNonMusic = - ref.watch(userPreferencesProvider.select((s) => s.skipNonMusic)); - - if (!skipNonMusic) { - return SourcedSegments(segments: [], source: source.info.id); - } - - final segments = await getAndCacheSkipSegments(source.info.id, ref); - - return SourcedSegments( - source: source.info.id, - segments: segments, - ); - }, -); diff --git a/lib/provider/sleep_timer_provider.dart b/lib/provider/sleep_timer_provider.dart deleted file mode 100644 index 53386e49..00000000 --- a/lib/provider/sleep_timer_provider.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class SleepTimerNotifier extends StateNotifier { - SleepTimerNotifier() : super(null); - - Timer? _timer; - - void setSleepTimer(Duration duration) { - state = duration; - - _timer = Timer(duration, () { - //! This can be a reason for app termination in iOS AppStore - exit(0); - }); - } - - void cancelSleepTimer() { - state = null; - _timer?.cancel(); - } -} - -final sleepTimerProvider = StateNotifierProvider( - (ref) => SleepTimerNotifier(), -); diff --git a/lib/provider/track_options/track_options_provider.dart b/lib/provider/track_options/track_options_provider.dart deleted file mode 100644 index 5aebf39c..00000000 --- a/lib/provider/track_options/track_options_provider.dart +++ /dev/null @@ -1,306 +0,0 @@ -import 'dart:io'; - -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/services.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/collections/routes.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; -import 'package:spotube/components/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/dialogs/track_details_dialog.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; -import 'package:spotube/provider/metadata_plugin/core/auth.dart'; -import 'package:spotube/provider/metadata_plugin/library/playlists.dart'; -import 'package:spotube/provider/metadata_plugin/library/tracks.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; - -enum TrackOptionValue { - album, - share, - addToPlaylist, - addToQueue, - removeFromPlaylist, - removeFromQueue, - blacklist, - delete, - playNext, - favorite, - details, - download, - startRadio, -} - -class TrackOptionsActions { - final Ref ref; - final SpotubeTrackObject track; - - TrackOptionsActions(this.ref, this.track); - - AudioPlayerNotifier get playback => ref.read(audioPlayerProvider.notifier); - MetadataPluginSavedTracksNotifier get favoriteTracks => - ref.read(metadataPluginSavedTracksProvider.notifier); - MetadataPluginSavedPlaylistsNotifier get favoritePlaylistsNotifier => - ref.read(metadataPluginSavedPlaylistsProvider.notifier); - DownloadManagerNotifier get downloadManager => - ref.read(downloadManagerProvider.notifier); - BlackListNotifier get blacklist => ref.read(blacklistProvider.notifier); - - void actionShare(BuildContext context) { - Clipboard.setData(ClipboardData(text: track.externalUri)).then((_) { - if (context.mounted) { - showToast( - context: rootNavigatorKey.currentContext!, - location: ToastLocation.topRight, - builder: (context, overlay) { - return SurfaceCard( - child: Text( - context.l10n.copied_to_clipboard(track.externalUri), - textAlign: TextAlign.center, - ), - ); - }, - ); - } - }); - } - - Future actionAddToPlaylist( - BuildContext context, - String? playlistId, - ) async { - /// showDialog doesn't work for some reason. So we have to - /// manually push a Dialog Route in the Navigator to get it working - await showDialog( - context: context, - builder: (context) { - return PlaylistAddTrackDialog( - tracks: [track], - openFromPlaylist: playlistId, - ); - }, - ); - } - - Future actionStartRadio(BuildContext context) async { - final playback = ref.read(audioPlayerProvider.notifier); - final playlist = ref.read(audioPlayerProvider); - final metadataPlugin = await ref.read(metadataPluginProvider.future); - - if (metadataPlugin == null) { - throw MetadataPluginException.noDefaultMetadataPlugin(); - } - - final tracks = await metadataPlugin.track.radio(track.id); - - bool replaceQueue = false; - - if (context.mounted && playlist.tracks.isNotEmpty) { - replaceQueue = await showPromptDialog( - context: context, - title: context.l10n.how_to_start_radio, - message: context.l10n.replace_queue_question, - okText: context.l10n.replace, - cancelText: context.l10n.add_to_queue, - ); - } - - if (replaceQueue || playlist.tracks.isEmpty) { - await playback.stop(); - await playback.load([track], autoPlay: true); - - // we don't have to add those tracks as useEndlessPlayback will do it for us - return; - } else { - await playback.addTrack(track); - } - - await playback.addTracks( - tracks.toList() - ..removeWhere((e) { - final isDuplicate = playlist.tracks.any((t) => t.id == e.id); - return e.id == track.id || isDuplicate; - }), - ); - } - - Future action( - BuildContext context, - TrackOptionValue value, - String? playlistId, - ) async { - switch (value) { - case TrackOptionValue.album: - await context.navigateTo( - AlbumRoute(id: track.album.id, album: track.album), - ); - break; - case TrackOptionValue.delete: - await File((track as SpotubeLocalTrackObject).path).delete(); - ref.invalidate(localTracksProvider); - break; - case TrackOptionValue.addToQueue: - await playback.addTrack(track); - if (context.mounted) { - showToast( - context: context, - location: ToastLocation.topRight, - builder: (context, overlay) { - return SurfaceCard( - child: Text( - context.l10n.added_track_to_queue(track.name), - textAlign: TextAlign.center, - ), - ); - }, - ); - } - break; - case TrackOptionValue.playNext: - await playback.addTracksAtFirst([track]); - - if (context.mounted) { - showToast( - context: context, - location: ToastLocation.topRight, - builder: (context, overlay) { - return SurfaceCard( - child: Text( - context.l10n.track_will_play_next(track.name), - textAlign: TextAlign.center, - ), - ); - }, - ); - } - break; - case TrackOptionValue.removeFromQueue: - playback.removeTrack(track.id); - - if (context.mounted) { - showToast( - context: context, - location: ToastLocation.topRight, - builder: (context, overlay) { - return SurfaceCard( - child: Text( - context.l10n.removed_track_from_queue( - track.name, - ), - textAlign: TextAlign.center, - ), - ); - }, - ); - } - break; - case TrackOptionValue.favorite: - final isLikedTrack = await ref.read( - metadataPluginIsSavedTrackProvider(track.id).future, - ); - - if (isLikedTrack) { - await favoriteTracks.removeFavorite([track]); - } else { - await favoriteTracks.addFavorite([track]); - } - break; - case TrackOptionValue.addToPlaylist: - actionAddToPlaylist(context, playlistId); - break; - case TrackOptionValue.removeFromPlaylist: - favoritePlaylistsNotifier.removeTracks(playlistId ?? "", [track.id]); - break; - case TrackOptionValue.blacklist: - final isBlacklisted = blacklist.contains(track); - if (isBlacklisted == true) { - await ref.read(blacklistProvider.notifier).remove(track.id); - } else { - await ref.read(blacklistProvider.notifier).add( - BlacklistTableCompanion.insert( - name: track.name, - elementId: track.id, - elementType: BlacklistedType.track, - ), - ); - } - break; - case TrackOptionValue.share: - actionShare(context); - break; - case TrackOptionValue.details: - if (track is! SpotubeFullTrackObject) break; - showDialog( - context: context, - builder: (context) => ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: TrackDetailsDialog(track: track as SpotubeFullTrackObject), - ), - ); - break; - case TrackOptionValue.download: - if (track is SpotubeLocalTrackObject) break; - downloadManager.addToQueue(track as SpotubeFullTrackObject); - break; - case TrackOptionValue.startRadio: - actionStartRadio(context); - break; - } - } -} - -typedef TrackOptionFlags = ({ - bool isInQueue, - bool isBlacklisted, - bool isInDownloadQueue, - bool isActiveTrack, - bool isAuthenticated, - bool isLiked, - DownloadTask? downloadTask, -}); - -final trackOptionActionsProvider = - Provider.family( - (ref, track) => TrackOptionsActions(ref, track), -); - -final trackOptionsStateProvider = - Provider.family((ref, track) { - ref.watch(downloadManagerProvider); - ref.watch(blacklistProvider); - - final playlist = ref.watch(audioPlayerProvider); - final authenticated = ref.watch(metadataPluginAuthenticatedProvider); - final downloadManager = ref.watch(downloadManagerProvider.notifier); - final blacklist = ref.watch(blacklistProvider.notifier); - final isBlacklisted = blacklist.contains(track); - final isSavedTrack = ref.watch(metadataPluginIsSavedTrackProvider(track.id)); - - final downloadTask = playlist.activeTrack?.id == null - ? null - : downloadManager.getTaskByTrackId(playlist.activeTrack!.id); - final isInDownloadQueue = playlist.activeTrack == null || - playlist.activeTrack! is SpotubeLocalTrackObject - ? false - : const [ - DownloadStatus.queued, - DownloadStatus.downloading, - ].contains(downloadTask?.status); - - return ( - isInQueue: playlist.containsTrack(track), - isBlacklisted: isBlacklisted, - isInDownloadQueue: isInDownloadQueue, - isActiveTrack: playlist.activeTrack?.id == track.id, - isAuthenticated: authenticated.asData?.value ?? false, - isLiked: isSavedTrack.asData?.value ?? false, - downloadTask: downloadTask, - ); -}); diff --git a/lib/provider/tray_manager/tray_manager.dart b/lib/provider/tray_manager/tray_manager.dart deleted file mode 100644 index a976b09b..00000000 --- a/lib/provider/tray_manager/tray_manager.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/tray_manager/tray_menu.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:tray_manager/tray_manager.dart'; -import 'package:window_manager/window_manager.dart'; - -class SystemTrayManager with TrayListener { - final Ref ref; - final bool enabled; - - SystemTrayManager( - this.ref, { - required this.enabled, - }) { - initialize(); - } - - Future initialize() async { - if (!kIsDesktop) return; - - if (enabled) { - await trayManager.setIcon( - kIsWindows - ? 'assets/branding/spotube-logo.ico' - : kIsFlatpak - ? 'com.github.KRTirtho.Spotube' - : 'assets/branding/spotube-logo.png', - ); - trayManager.addListener(this); - } else { - await trayManager.destroy(); - } - } - - void dispose() { - trayManager.removeListener(this); - } - - @override - onTrayIconMouseDown() { - if (kIsWindows) { - windowManager.show(); - } else { - trayManager.popUpContextMenu(); - } - } - - @override - onTrayIconRightMouseDown() { - if (!kIsWindows) { - windowManager.show(); - } else { - trayManager.popUpContextMenu(); - } - } -} - -final trayManagerProvider = Provider( - (ref) { - final enabled = ref.watch( - userPreferencesProvider.select((s) => s.showSystemTrayIcon), - ); - - ref.listen(trayMenuProvider, (_, menu) { - if (!enabled || !kIsDesktop) return; - trayManager.setContextMenu(menu); - }); - - final manager = SystemTrayManager( - ref, - enabled: enabled, - ); - - ref.onDispose(manager.dispose); - - return manager; - }, -); diff --git a/lib/provider/tray_manager/tray_menu.dart b/lib/provider/tray_manager/tray_menu.dart deleted file mode 100644 index 42a3f948..00000000 --- a/lib/provider/tray_manager/tray_menu.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'dart:io'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:media_kit/media_kit.dart' hide Track; -import 'package:tray_manager/tray_manager.dart'; -import 'package:window_manager/window_manager.dart'; - -final audioPlayerLoopMode = StreamProvider((ref) { - return audioPlayer.loopModeStream; -}); - -final audioPlayerShuffleMode = StreamProvider((ref) { - return audioPlayer.shuffledStream; -}); -final audioPlayerPlaying = StreamProvider((ref) { - return audioPlayer.playingStream; -}); - -final trayMenuProvider = Provider((ref) { - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final isPlaybackPlaying = - ref.watch(audioPlayerProvider.select((s) => s.activeTrack != null)); - final isLoopOne = - ref.watch(audioPlayerLoopMode).asData?.value == PlaylistMode.single; - final isShuffled = ref.watch(audioPlayerShuffleMode).asData?.value ?? false; - final isPlaying = ref.watch(audioPlayerPlaying).asData?.value ?? false; - - return Menu( - items: [ - MenuItem( - label: "Show/Hide Window", - onClick: (menuItem) async { - if (await windowManager.isVisible()) { - await windowManager.hide(); - } else { - await windowManager.focus(); - await windowManager.show(); - } - }, - ), - MenuItem.separator(), - MenuItem( - label: isPlaying ? "Pause" : "Play", - disabled: !isPlaybackPlaying, - onClick: (menuItem) async { - if (audioPlayer.isPlaying) { - await audioPlayer.pause(); - } else { - await audioPlayer.resume(); - } - }, - ), - MenuItem( - label: "Next", - disabled: !isPlaybackPlaying, - onClick: (menuItem) { - audioPlayer.skipToNext(); - }, - ), - MenuItem( - label: "Previous", - disabled: !isPlaybackPlaying, - onClick: (menuItem) { - audioPlayer.skipToPrevious(); - }, - ), - MenuItem.submenu( - label: "Playback", - submenu: Menu( - items: [ - MenuItem( - label: "Repeat", - checked: isLoopOne, - onClick: (menuItem) { - audioPlayer.setLoopMode( - isLoopOne ? PlaylistMode.none : PlaylistMode.single, - ); - }, - ), - MenuItem( - label: "Shuffle", - checked: isShuffled, - onClick: (menuItem) { - audioPlayer.setShuffle(!isShuffled); - }, - ), - MenuItem.separator(), - MenuItem( - label: "Stop", - onClick: (menuItem) { - playlistNotifier.stop(); - }, - ), - ], - ), - ), - MenuItem.separator(), - MenuItem( - label: "Quit", - onClick: (menuItem) { - exit(0); - }, - ), - ], - ); -}); diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart deleted file mode 100644 index 0b43d043..00000000 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ /dev/null @@ -1,234 +0,0 @@ -import 'package:drift/drift.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart' as paths; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide join; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/metadata/market.dart'; -import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; -import 'package:spotube/provider/database/database.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:window_manager/window_manager.dart'; -import 'package:open_file/open_file.dart'; - -typedef UserPreferences = PreferencesTableData; - -class UserPreferencesNotifier extends Notifier { - @override - build() { - final db = ref.watch(databaseProvider); - - (db.select(db.preferencesTable)..where((tbl) => tbl.id.equals(0))) - .getSingleOrNull() - .then((result) async { - if (result == null) { - await db.into(db.preferencesTable).insert( - PreferencesTableCompanion.insert( - id: const Value(0), - downloadLocation: Value(await _getDefaultDownloadDirectory()), - ), - ); - } - - state = await (db.select(db.preferencesTable) - ..where((tbl) => tbl.id.equals(0))) - .getSingle(); - - final subscription = (db.select(db.preferencesTable) - ..where((tbl) => tbl.id.equals(0))) - .watchSingle() - .listen((event) async { - try { - state = event; - - if (kIsDesktop) { - await windowManager.setTitleBarStyle( - state.systemTitleBar - ? TitleBarStyle.normal - : TitleBarStyle.hidden, - ); - } - - await audioPlayer.setAudioNormalization(state.normalizeAudio); - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }); - - ref.onDispose(() { - subscription.cancel(); - }); - }); - - return PreferencesTable.defaults(); - } - - Future _getDefaultDownloadDirectory() async { - if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; - - if (kIsMacOS) { - return join((await paths.getLibraryDirectory()).path, "Caches"); - } - - return paths.getDownloadsDirectory().then((dir) { - return join(dir!.path, "Spotube"); - }); - } - - Future setData(PreferencesTableCompanion data) async { - final db = ref.read(databaseProvider); - - final query = db.update(db.preferencesTable)..where((t) => t.id.equals(0)); - - await query.write(data); - } - - Future reset() async { - final db = ref.read(databaseProvider); - - final query = db.update(db.preferencesTable); - - await query.replace(PreferencesTableCompanion.insert(id: const Value(0))); - } - - static Future getMusicCacheDir() async { - if (kIsAndroid) { - final dir = - await paths.getExternalCacheDirectories().then((dirs) => dirs!.first); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - return join(dir.path, 'Cached Tracks'); - } - - final dir = await paths.getApplicationCacheDirectory(); - return join(dir.path, 'cached_tracks'); - } - - Future openCacheFolder() async { - try { - final filePath = await getMusicCacheDir(); - - await OpenFile.open(filePath); - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - } - - void setThemeMode(ThemeMode mode) { - setData(PreferencesTableCompanion(themeMode: Value(mode))); - } - - void setRecommendationMarket(Market country) { - setData(PreferencesTableCompanion(market: Value(country))); - } - - void setAccentColorScheme(SpotubeColor color) { - setData(PreferencesTableCompanion(accentColorScheme: Value(color))); - } - - void setAlbumColorSync(bool sync) { - setData(PreferencesTableCompanion(albumColorSync: Value(sync))); - - // if (!sync) { - // ref.read(paletteProvider.notifier).state = null; - // } else { - // ref.read(audioPlayerStreamListenersProvider).updatePalette(); - // } - } - - void setCheckUpdate(bool check) { - setData(PreferencesTableCompanion(checkUpdate: Value(check))); - } - - void setDownloadLocation(String downloadDir) { - if (downloadDir.isEmpty) return; - setData(PreferencesTableCompanion(downloadLocation: Value(downloadDir))); - } - - void setLocalLibraryLocation(List localLibraryDirs) { - //if (localLibraryDir.isEmpty) return; - setData( - PreferencesTableCompanion( - localLibraryLocation: Value(localLibraryDirs), - ), - ); - } - - void setLayoutMode(LayoutMode mode) { - setData(PreferencesTableCompanion(layoutMode: Value(mode))); - } - - void setCloseBehavior(CloseBehavior behavior) { - setData(PreferencesTableCompanion(closeBehavior: Value(behavior))); - } - - void setShowSystemTrayIcon(bool show) { - setData(PreferencesTableCompanion(showSystemTrayIcon: Value(show))); - } - - void setLocale(Locale locale) { - setData(PreferencesTableCompanion(locale: Value(locale))); - } - - void setSearchMode(SearchMode mode) { - setData(PreferencesTableCompanion(searchMode: Value(mode))); - } - - void setSkipNonMusic(bool skip) { - setData(PreferencesTableCompanion(skipNonMusic: Value(skip))); - } - - void setYoutubeClientEngine(YoutubeClientEngine engine) { - setData(PreferencesTableCompanion(youtubeClientEngine: Value(engine))); - } - - void setSystemTitleBar(bool isSystemTitleBar) { - setData( - PreferencesTableCompanion( - systemTitleBar: Value(isSystemTitleBar), - ), - ); - } - - void setDiscordPresence(bool discordPresence) { - setData(PreferencesTableCompanion(discordPresence: Value(discordPresence))); - } - - void setAmoledDarkTheme(bool isAmoled) { - setData(PreferencesTableCompanion(amoledDarkTheme: Value(isAmoled))); - } - - void setNormalizeAudio(bool normalize) { - setData(PreferencesTableCompanion(normalizeAudio: Value(normalize))); - audioPlayer.setAudioNormalization(normalize); - } - - void setEndlessPlayback(bool endless) { - setData(PreferencesTableCompanion(endlessPlayback: Value(endless))); - } - - void setEnableConnect(bool enable) { - setData(PreferencesTableCompanion(enableConnect: Value(enable))); - } - - void setConnectPort(int port) { - assert( - port >= -1 && port <= 65535, - "Port must be between -1 and 65535, got $port", - ); - setData(PreferencesTableCompanion(connectPort: Value(port))); - } - - void setCacheMusic(bool cache) { - setData(PreferencesTableCompanion(cacheMusic: Value(cache))); - } -} - -final userPreferencesProvider = - NotifierProvider( - () => UserPreferencesNotifier(), -); diff --git a/lib/provider/volume_provider.dart b/lib/provider/volume_provider.dart deleted file mode 100644 index 64bcfe1a..00000000 --- a/lib/provider/volume_provider.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'dart:async'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/kv_store/kv_store.dart'; - -class VolumeProvider extends Notifier { - VolumeProvider(); - - @override - build() { - audioPlayer.setVolume(KVStoreService.volume); - return KVStoreService.volume; - } - - Future setVolume(double volume) async { - state = volume; - await audioPlayer.setVolume(volume); - KVStoreService.setVolume(volume); - } -} - -final volumeProvider = - NotifierProvider(() => VolumeProvider()); diff --git a/lib/provider/youtube_engine/youtube_engine.dart b/lib/provider/youtube_engine/youtube_engine.dart deleted file mode 100644 index 0aa37db5..00000000 --- a/lib/provider/youtube_engine/youtube_engine.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/youtube_engine/newpipe_engine.dart'; -import 'package:spotube/services/youtube_engine/youtube_explode_engine.dart'; -import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart'; - -final youtubeEngineProvider = Provider((ref) { - final engineMode = ref.watch( - userPreferencesProvider.select((value) => value.youtubeClientEngine), - ); - - if (engineMode == YoutubeClientEngine.newPipe && - NewPipeEngine.isAvailableForPlatform) { - return NewPipeEngine(); - } else if (engineMode == YoutubeClientEngine.ytDlp && - YtDlpEngine.isAvailableForPlatform) { - return YtDlpEngine(); - } else { - return YouTubeExplodeEngine(); - } -}); diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart deleted file mode 100644 index 2693f13a..00000000 --- a/lib/services/audio_player/audio_player.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'dart:io'; - -import 'package:media_kit/media_kit.dart' hide Track; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:flutter/foundation.dart'; -import 'package:spotube/services/audio_player/custom_player.dart'; -import 'dart:async'; - -import 'package:media_kit/media_kit.dart' as mk; - -import 'package:spotube/services/audio_player/playback_state.dart'; -import 'package:spotube/utils/platform.dart'; - -part 'audio_players_streams_mixin.dart'; -part 'audio_player_impl.dart'; - -class SpotubeMedia extends mk.Media { - static int serverPort = 0; - - static String get _host => - kIsWindows ? "localhost" : InternetAddress.anyIPv4.address; - - final SpotubeTrackObject track; - SpotubeMedia(this.track) - : assert( - track is SpotubeLocalTrackObject || track is SpotubeFullTrackObject, - "Track must be a either a local track or a full track object with ISRC", - ), - // If the track is a local track, use its path, otherwise use the server URL - super( - track is SpotubeLocalTrackObject - ? track.path - : "http://$_host:$serverPort/stream/${track.id}", - extras: track.toJson(), - ); - - factory SpotubeMedia.media(Media media) { - assert(media.extras != null, "[Media] must have extra metadata set"); - return SpotubeMedia(SpotubeTrackObject.fromJson(media.extras!)); - } -} - -abstract class AudioPlayerInterface { - final CustomPlayer _mkPlayer; - - AudioPlayerInterface() - : _mkPlayer = CustomPlayer( - configuration: const mk.PlayerConfiguration( - title: "Spotube", - logLevel: kDebugMode ? mk.MPVLogLevel.info : mk.MPVLogLevel.error, - async: true, - ), - ) { - _mkPlayer.stream.error.listen((event) { - AppLogger.reportError(event, StackTrace.current); - }); - } - - /// Whether the current platform supports the audioplayers plugin - static const bool _mkSupportedPlatform = true; - - bool get mkSupportedPlatform => _mkSupportedPlatform; - - Duration get duration { - return _mkPlayer.state.duration; - } - - Playlist get playlist { - return _mkPlayer.state.playlist; - } - - Duration get position { - return _mkPlayer.state.position; - } - - Duration get bufferedPosition { - return _mkPlayer.state.buffer; - } - - Future get selectedDevice async { - return _mkPlayer.state.audioDevice; - } - - Future> get devices async { - return _mkPlayer.state.audioDevices; - } - - bool get hasSource { - return _mkPlayer.state.playlist.medias.isNotEmpty; - } - - // states - bool get isPlaying { - return _mkPlayer.state.playing; - } - - bool get isPaused { - return !_mkPlayer.state.playing; - } - - bool get isStopped { - return !hasSource; - } - - Future get isCompleted async { - return _mkPlayer.state.completed; - } - - bool get isShuffled { - return _mkPlayer.shuffled; - } - - PlaylistMode get loopMode { - return _mkPlayer.state.playlistMode; - } - - /// Returns the current volume of the player, between 0 and 1 - double get volume { - return _mkPlayer.state.volume / 100; - } - - bool get isBuffering { - return _mkPlayer.state.buffering; - } -} diff --git a/lib/services/audio_player/audio_player_impl.dart b/lib/services/audio_player/audio_player_impl.dart deleted file mode 100644 index afd209a3..00000000 --- a/lib/services/audio_player/audio_player_impl.dart +++ /dev/null @@ -1,138 +0,0 @@ -part of 'audio_player.dart'; - -final audioPlayer = SpotubeAudioPlayer(); - -class SpotubeAudioPlayer extends AudioPlayerInterface - with SpotubeAudioPlayersStreams { - Future pause() async { - await _mkPlayer.pause(); - } - - Future resume() async { - await _mkPlayer.play(); - } - - Future stop() async { - await _mkPlayer.stop(); - } - - Future seek(Duration position) async { - await _mkPlayer.seek(position); - } - - /// Volume is between 0 and 1 - Future setVolume(double volume) async { - assert(volume >= 0 && volume <= 1); - await _mkPlayer.setVolume(volume * 100); - } - - Future setSpeed(double speed) async { - await _mkPlayer.setRate(speed); - } - - Future setAudioDevice(mk.AudioDevice device) async { - await _mkPlayer.setAudioDevice(device); - } - - Future dispose() async { - await _mkPlayer.dispose(); - } - - // Playlist related - - Future openPlaylist( - List tracks, { - bool autoPlay = true, - int initialIndex = 0, - }) async { - assert(tracks.isNotEmpty); - assert(initialIndex <= tracks.length - 1); - await _mkPlayer.open( - mk.Playlist(tracks, index: initialIndex), - play: autoPlay, - ); - } - - List get sources { - return _mkPlayer.state.playlist.medias.map((e) => e.uri).toList(); - } - - String? get currentSource { - if (_mkPlayer.state.playlist.index == -1) return null; - return _mkPlayer.state.playlist.medias - .elementAtOrNull(_mkPlayer.state.playlist.index) - ?.uri; - } - - String? get nextSource { - if (loopMode == PlaylistMode.loop && - _mkPlayer.state.playlist.index == - _mkPlayer.state.playlist.medias.length - 1) { - return sources.first; - } - - return _mkPlayer.state.playlist.medias - .elementAtOrNull(_mkPlayer.state.playlist.index + 1) - ?.uri; - } - - String? get previousSource { - if (loopMode == PlaylistMode.loop && _mkPlayer.state.playlist.index == 0) { - return sources.last; - } - - return _mkPlayer.state.playlist.medias - .elementAtOrNull(_mkPlayer.state.playlist.index - 1) - ?.uri; - } - - int get currentIndex => _mkPlayer.state.playlist.index; - - Future skipToNext() async { - await _mkPlayer.next(); - } - - Future skipToPrevious() async { - await _mkPlayer.previous(); - } - - Future jumpTo(int index) async { - await _mkPlayer.jump(index); - } - - Future addTrack(mk.Media media) async { - await _mkPlayer.add(media); - } - - Future addTrackAt(mk.Media media, int index) async { - await _mkPlayer.insert(index, media); - } - - Future removeTrack(int index) async { - await _mkPlayer.remove(index); - } - - Future moveTrack(int from, int to) async { - await _mkPlayer.move(from, to); - } - - Future clearPlaylist() async { - _mkPlayer.stop(); - } - - Future setShuffle(bool shuffle) async { - await _mkPlayer.setShuffle(shuffle); - } - - Future setLoopMode(PlaylistMode loop) async { - await _mkPlayer.setPlaylistMode(loop); - } - - Future setAudioNormalization(bool normalize) async { - await _mkPlayer.setAudioNormalization(normalize); - } - - Future setDemuxerBufferSize(int sizeInBytes) async { - await _mkPlayer.setDemuxerBufferSize(sizeInBytes); - } -} diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart deleted file mode 100644 index aeb8f1e3..00000000 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ /dev/null @@ -1,150 +0,0 @@ -part of 'audio_player.dart'; - -mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { - // stream getters - Stream get durationStream { - // if (mkSupportedPlatform) { - return _mkPlayer.stream.duration; - // } else { - // return _justAudio!.durationStream - // .where((event) => event != null) - // .map((event) => event!) - // ; - // } - } - - Stream get positionStream { - // if (mkSupportedPlatform) { - return _mkPlayer.stream.position; - // } else { - // return _justAudio!.positionStream; - // } - } - - Stream get bufferedPositionStream { - // if (mkSupportedPlatform) { - // audioplayers doesn't have the capability to get buffered position - return _mkPlayer.stream.buffer; - // } else { - // return _justAudio!.bufferedPositionStream; - // } - } - - Stream get completedStream { - // if (mkSupportedPlatform) { - return _mkPlayer.stream.completed; - // } else { - // return _justAudio!.playerStateStream - // .where( - // (event) => event.processingState == ja.ProcessingState.completed) - // ; - // } - } - - /// Stream that emits when the player is almost (%) complete - Stream percentCompletedStream(double percent) { - return positionStream - .asyncMap( - (position) async => duration == Duration.zero - ? 0 - : (position.inSeconds / duration.inSeconds * 100).toInt(), - ) - .where((event) => event >= percent); - } - - Stream get playingStream { - // if (mkSupportedPlatform) { - return _mkPlayer.stream.playing; - // } else { - // return _justAudio!.playingStream; - // } - } - - Stream get shuffledStream { - // if (mkSupportedPlatform) { - return _mkPlayer.shuffleStream; - // } else { - // return _justAudio!.shuffleModeEnabledStream; - // } - } - - Stream get loopModeStream { - // if (mkSupportedPlatform) { - return _mkPlayer.stream.playlistMode; - // } else { - // return _justAudio!.loopModeStream - // .map(PlaylistMode.fromLoopMode) - // ; - // } - } - - Stream get volumeStream { - // if (mkSupportedPlatform) { - return _mkPlayer.stream.volume.map((event) => event / 100); - // } else { - // return _justAudio!.volumeStream; - // } - } - - Stream get bufferingStream { - // if (mkSupportedPlatform) { - return Stream.value(false); - // } else { - // return _justAudio!.playerStateStream - // .map( - // (event) => - // event.processingState == ja.ProcessingState.buffering || - // event.processingState == ja.ProcessingState.loading, - // ) - // ; - // } - } - - Stream get playerStateStream { - // if (mkSupportedPlatform) { - return _mkPlayer.playerStateStream; - // } else { - // return _justAudio!.playerStateStream - // .map(AudioPlaybackState.fromJaPlayerState) - // ; - // } - } - - Stream get currentIndexChangedStream { - // if (mkSupportedPlatform) { - return _mkPlayer.indexChangeStream; - // } else { - // return _justAudio!.sequenceStateStream - // .map((event) => event?.currentIndex ?? -1) - // ; - // } - } - - Stream get activeSourceChangedStream { - // if (mkSupportedPlatform) { - return _mkPlayer.indexChangeStream - .map((event) { - return _mkPlayer.state.playlist.medias.elementAtOrNull(event)?.uri; - }) - .where((event) => event != null) - .cast(); - // } else { - // return _justAudio!.sequenceStateStream - // .map((event) { - // return (event?.currentSource as ja.UriAudioSource?)?.uri.toString(); - // }) - // .where((event) => event != null) - // .cast(); - // } - } - - Stream> get devicesStream => - _mkPlayer.stream.audioDevices.asBroadcastStream(); - - Stream get selectedDeviceStream => - _mkPlayer.stream.audioDevice.asBroadcastStream(); - - Stream get errorStream => _mkPlayer.stream.error; - - Stream get playlistStream => _mkPlayer.stream.playlist; -} diff --git a/lib/services/audio_player/custom_player.dart b/lib/services/audio_player/custom_player.dart deleted file mode 100644 index 7cbd51a5..00000000 --- a/lib/services/audio_player/custom_player.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'dart:async'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:flutter_broadcasts/flutter_broadcasts.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:audio_session/audio_session.dart'; -// ignore: implementation_imports -import 'package:spotube/services/audio_player/playback_state.dart'; -import 'package:spotube/utils/platform.dart'; - -/// MediaKit [Player] by default doesn't have a state stream. -/// This class adds a state stream to the [Player] class. -class CustomPlayer extends Player { - final StreamController _playerStateStream; - - late final List _subscriptions; - - int _androidAudioSessionId = 0; - String _packageName = ""; - AndroidAudioManager? _androidAudioManager; - - CustomPlayer({super.configuration}) - : _playerStateStream = StreamController.broadcast() { - nativePlayer.setProperty("network-timeout", "120"); - - _subscriptions = [ - stream.buffering.listen((event) { - _playerStateStream.add(AudioPlaybackState.buffering); - }), - stream.playing.listen((playing) { - if (playing) { - _playerStateStream.add(AudioPlaybackState.playing); - } else { - _playerStateStream.add(AudioPlaybackState.paused); - } - }), - stream.completed.listen((isCompleted) async { - if (!isCompleted) return; - _playerStateStream.add(AudioPlaybackState.completed); - }), - stream.playlist.listen((event) { - if (event.medias.isEmpty) { - _playerStateStream.add(AudioPlaybackState.stopped); - } - }), - stream.error.listen((event) { - AppLogger.reportError('[MediaKitError] \n$event', StackTrace.current); - }), - ]; - PackageInfo.fromPlatform().then((packageInfo) { - _packageName = packageInfo.packageName; - }); - if (kIsAndroid) { - _androidAudioManager = AndroidAudioManager(); - AudioSession.instance.then((s) async { - _androidAudioSessionId = - await _androidAudioManager!.generateAudioSessionId(); - notifyAudioSessionUpdate(true); - - await nativePlayer.setProperty( - "audiotrack-session-id", - _androidAudioSessionId.toString(), - ); - await nativePlayer.setProperty("ao", "audiotrack,opensles,"); - }); - } - } - - Future notifyAudioSessionUpdate(bool active) async { - if (kIsAndroid) { - sendBroadcast( - BroadcastMessage( - name: active - ? "android.media.action.OPEN_AUDIO_EFFECT_CONTROL_SESSION" - : "android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION", - data: { - "android.media.extra.AUDIO_SESSION": _androidAudioSessionId, - "android.media.extra.PACKAGE_NAME": _packageName - }, - ), - ); - } - } - - bool get shuffled => state.shuffle; - - Stream get playerStateStream => _playerStateStream.stream; - Stream get shuffleStream => stream.shuffle; - Stream get indexChangeStream { - int oldIndex = state.playlist.index; - return stream.playlist.map((event) => event.index).where((newIndex) { - if (newIndex != oldIndex) { - oldIndex = newIndex; - return true; - } - return false; - }); - } - - @override - Future setShuffle(bool shuffle) async { - await super.setShuffle(shuffle); - } - - @override - Future stop() async { - await super.stop(); - - _playerStateStream.add(AudioPlaybackState.stopped); - } - - @override - Future dispose() async { - for (var element in _subscriptions) { - element.cancel(); - } - await notifyAudioSessionUpdate(false); - return super.dispose(); - } - - NativePlayer get nativePlayer => platform as NativePlayer; - - Future insert(int index, Media media) async { - final addedMediaCompleter = Completer(); - final playlistStream = stream.playlist.listen( - (event) { - final mediaAddedIndex = - event.medias.indexWhere((m) => m.uri == media.uri); - if (mediaAddedIndex != -1 && !addedMediaCompleter.isCompleted) { - addedMediaCompleter.complete(mediaAddedIndex); - } - }, - ); - try { - await add(media); - final mediaAddedIndex = await addedMediaCompleter.future; - await move(mediaAddedIndex, index); - } finally { - playlistStream.cancel(); - } - } - - Future setAudioNormalization(bool normalize) async { - if (normalize) { - await nativePlayer.setProperty('af', 'dynaudnorm=g=5:f=250:r=0.9:p=0.5'); - } else { - await nativePlayer.setProperty('af', ''); - } - } - - Future setDemuxerBufferSize(int sizeInBytes) async { - await nativePlayer.setProperty('demuxer-max-bytes', sizeInBytes.toString()); - await nativePlayer.setProperty( - 'demuxer-max-back-bytes', - sizeInBytes.toString(), - ); - } -} diff --git a/lib/services/audio_player/playback_state.dart b/lib/services/audio_player/playback_state.dart deleted file mode 100644 index a4743a48..00000000 --- a/lib/services/audio_player/playback_state.dart +++ /dev/null @@ -1,28 +0,0 @@ -// import 'package:just_audio/just_audio.dart'; - -/// An unified playback state enum -enum AudioPlaybackState { - playing, - paused, - completed, - buffering, - stopped; - - // static AudioPlaybackState fromJaPlayerState(PlayerState state) { - // if (state.playing) { - // return AudioPlaybackState.playing; - // } - - // switch (state.processingState) { - // case ProcessingState.idle: - // return AudioPlaybackState.stopped; - // case ProcessingState.ready: - // return AudioPlaybackState.paused; - // case ProcessingState.completed: - // return AudioPlaybackState.completed; - // case ProcessingState.loading: - // case ProcessingState.buffering: - // return AudioPlaybackState.buffering; - // } - // } -} diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart deleted file mode 100644 index c511da61..00000000 --- a/lib/services/audio_services/audio_services.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:audio_service/audio_service.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/collections/env.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_services/mobile_audio_service.dart'; -import 'package:spotube/services/audio_services/windows_audio_service.dart'; -import 'package:spotube/utils/platform.dart'; - -class AudioServices with WidgetsBindingObserver { - final MobileAudioService? mobile; - final WindowsAudioService? smtc; - - AudioServices(this.mobile, this.smtc) { - WidgetsBinding.instance.addObserver(this); - } - - static Future create( - Ref ref, - AudioPlayerNotifier playback, - ) async { - final mobile = kIsMobile || kIsMacOS || kIsLinux - ? await AudioService.init( - builder: () => MobileAudioService(playback), - config: AudioServiceConfig( - androidNotificationChannelId: switch (( - kIsLinux, - Env.releaseChannel - )) { - (true, _) => "spotube", - (_, ReleaseChannel.stable) => "oss.krtirtho.spotube", - (_, ReleaseChannel.nightly) => "oss.krtirtho.spotube.nightly", - }, - androidNotificationChannelName: 'Spotube', - androidNotificationOngoing: false, - androidStopForegroundOnPause: false, - androidNotificationChannelDescription: "Spotube Media Controls", - ), - ) - : null; - final smtc = kIsWindows ? WindowsAudioService(ref, playback) : null; - - return AudioServices(mobile, smtc); - } - - Future addTrack(SpotubeTrackObject track) async { - await smtc?.addTrack(track); - mobile?.addItem(MediaItem( - id: track.id, - album: track.album.name, - title: track.name, - artist: track.artists.asString(), - duration: Duration(milliseconds: track.durationMs), - artUri: (track.album.images).asUri( - placeholder: ImagePlaceholder.albumArt, - ), - playable: true, - )); - } - - void activateSession() { - mobile?.session?.setActive(true); - } - - void deactivateSession() { - mobile?.session?.setActive(false); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - switch (state) { - case AppLifecycleState.detached: - deactivateSession(); - audioPlayer.pause(); - break; - default: - break; - } - } - - void dispose() { - smtc?.dispose(); - WidgetsBinding.instance.removeObserver(this); - } -} diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart deleted file mode 100644 index 16a3618e..00000000 --- a/lib/services/audio_services/mobile_audio_service.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:audio_service/audio_service.dart'; -import 'package:audio_session/audio_session.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/audio_player/state.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:media_kit/media_kit.dart' hide Track; -import 'package:spotube/services/audio_player/playback_state.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:spotube/utils/platform.dart'; - -class MobileAudioService extends BaseAudioHandler { - AudioSession? session; - final AudioPlayerNotifier audioPlayerNotifier; - - // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member - AudioPlayerState get playlist => audioPlayerNotifier.state; - - MobileAudioService(this.audioPlayerNotifier) { - AudioSession.instance.then((s) { - session = s; - session?.configure(const AudioSessionConfiguration.music()); - - bool wasPausedByBeginEvent = false; - - s.interruptionEventStream.listen((event) async { - if (event.begin) { - switch (event.type) { - case AudioInterruptionType.duck: - await audioPlayer.setVolume(0.5); - break; - case AudioInterruptionType.pause: - case AudioInterruptionType.unknown: - { - wasPausedByBeginEvent = audioPlayer.isPlaying; - await audioPlayer.pause(); - break; - } - } - } else { - switch (event.type) { - case AudioInterruptionType.duck: - await audioPlayer.setVolume(1.0); - break; - case AudioInterruptionType.pause when wasPausedByBeginEvent: - case AudioInterruptionType.unknown when wasPausedByBeginEvent: - await audioPlayer.resume(); - wasPausedByBeginEvent = false; - break; - default: - break; - } - } - }); - - s.becomingNoisyEventStream.listen((_) { - audioPlayer.pause(); - }); - }); - audioPlayer.playerStateStream.listen((state) async { - if (state == AudioPlaybackState.playing) { - await session?.setActive(true); - } - playbackState.add(await _transformEvent()); - }); - - audioPlayer.positionStream.listen((pos) async { - playbackState.add(await _transformEvent()); - }); - audioPlayer.bufferedPositionStream.listen((pos) async { - playbackState.add(await _transformEvent()); - }); - } - - void addItem(MediaItem item) { - session?.setActive(true); - mediaItem.add(item); - } - - @override - Future play() => audioPlayer.resume(); - - @override - Future pause() => audioPlayer.pause(); - - @override - Future seek(Duration position) => audioPlayer.seek(position); - - @override - Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { - await super.setShuffleMode(shuffleMode); - - audioPlayer.setShuffle(shuffleMode == AudioServiceShuffleMode.all); - } - - @override - Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { - super.setRepeatMode(repeatMode); - audioPlayer.setLoopMode(switch (repeatMode) { - AudioServiceRepeatMode.all || - AudioServiceRepeatMode.group => - PlaylistMode.loop, - AudioServiceRepeatMode.one => PlaylistMode.single, - _ => PlaylistMode.none, - }); - } - - @override - Future stop() async { - await audioPlayerNotifier.stop(); - } - - @override - Future skipToNext() async { - await audioPlayer.skipToNext(); - await super.skipToNext(); - } - - @override - Future skipToPrevious() async { - await audioPlayer.skipToPrevious(); - await super.skipToPrevious(); - } - - @override - Future onTaskRemoved() async { - await audioPlayer.pause(); - if (kIsAndroid) exit(0); - } - - Future _transformEvent() async { - try { - return PlaybackState( - controls: [ - MediaControl.skipToPrevious, - audioPlayer.isPlaying ? MediaControl.pause : MediaControl.play, - MediaControl.skipToNext, - MediaControl.stop, - ], - systemActions: { - MediaAction.seek, - }, - androidCompactActionIndices: const [0, 1, 2], - playing: audioPlayer.isPlaying, - updatePosition: audioPlayer.position, - bufferedPosition: audioPlayer.bufferedPosition, - shuffleMode: audioPlayer.isShuffled == true - ? AudioServiceShuffleMode.all - : AudioServiceShuffleMode.none, - repeatMode: switch (audioPlayer.loopMode) { - PlaylistMode.loop => AudioServiceRepeatMode.all, - PlaylistMode.single => AudioServiceRepeatMode.one, - _ => AudioServiceRepeatMode.none, - }, - processingState: audioPlayer.isBuffering - ? AudioProcessingState.loading - : AudioProcessingState.ready, - ); - } catch (e, stack) { - AppLogger.reportError(e, stack); - rethrow; - } - } -} diff --git a/lib/services/audio_services/windows_audio_service.dart b/lib/services/audio_services/windows_audio_service.dart deleted file mode 100644 index 6cf101ab..00000000 --- a/lib/services/audio_services/windows_audio_service.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'dart:async'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:smtc_windows/smtc_windows.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_player/playback_state.dart'; - -class WindowsAudioService { - final SMTCWindows smtc; - final Ref ref; - final AudioPlayerNotifier audioPlayerNotifier; - - final subscriptions = []; - - WindowsAudioService(this.ref, this.audioPlayerNotifier) - : smtc = SMTCWindows(enabled: false) { - smtc.setPlaybackStatus(PlaybackStatus.stopped); - final buttonStream = smtc.buttonPressStream.listen((event) { - switch (event) { - case PressedButton.play: - audioPlayer.resume(); - break; - case PressedButton.pause: - audioPlayer.pause(); - break; - case PressedButton.next: - audioPlayer.skipToNext(); - break; - case PressedButton.previous: - audioPlayer.skipToPrevious(); - break; - case PressedButton.stop: - audioPlayerNotifier.stop(); - break; - default: - break; - } - }); - - final playerStateStream = - audioPlayer.playerStateStream.listen((state) async { - switch (state) { - case AudioPlaybackState.playing: - await smtc.setPlaybackStatus(PlaybackStatus.playing); - break; - case AudioPlaybackState.paused: - await smtc.setPlaybackStatus(PlaybackStatus.paused); - break; - case AudioPlaybackState.stopped: - await smtc.setPlaybackStatus(PlaybackStatus.stopped); - break; - case AudioPlaybackState.completed: - await smtc.setPlaybackStatus(PlaybackStatus.changing); - break; - default: - break; - } - }); - - final positionStream = audioPlayer.positionStream.listen((pos) async { - await smtc.setPosition(pos); - }); - - final durationStream = audioPlayer.durationStream.listen((duration) async { - await smtc.setEndTime(duration); - }); - - subscriptions.addAll([ - buttonStream, - playerStateStream, - positionStream, - durationStream, - ]); - } - - Future addTrack(SpotubeTrackObject track) async { - if (!smtc.enabled) { - await smtc.enableSmtc(); - } - await smtc.updateMetadata( - MusicMetadata( - title: track.name, - albumArtist: track.artists.firstOrNull?.name ?? "Unknown", - artist: track.artists.asString(), - album: track.album?.name ?? "Unknown", - thumbnail: (track.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - ), - ); - } - - void dispose() { - smtc.disableSmtc(); - smtc.dispose(); - for (var element in subscriptions) { - element.cancel(); - } - } -} diff --git a/lib/services/cli/cli.dart b/lib/services/cli/cli.dart deleted file mode 100644 index 985c0e72..00000000 --- a/lib/services/cli/cli.dart +++ /dev/null @@ -1,40 +0,0 @@ -// ignore_for_file: avoid_print - -import 'dart:io'; - -import 'package:args/args.dart'; -import 'package:flutter/foundation.dart'; -import 'package:package_info_plus/package_info_plus.dart'; - -Future startCLI(List args) async { - final parser = ArgParser(); - - parser.addFlag( - 'verbose', - abbr: 'v', - help: 'Verbose mode', - defaultsTo: !kReleaseMode, - ); - parser.addFlag( - "version", - help: "Print version and exit", - negatable: false, - ); - - parser.addFlag("help", abbr: "h", negatable: false); - - final arguments = parser.parse(args); - - if (arguments["help"] == true) { - print(parser.usage); - exit(0); - } - - if (arguments["version"] == true) { - final package = await PackageInfo.fromPlatform(); - print("Spotube v${package.version}"); - exit(0); - } - - return arguments; -} diff --git a/lib/services/connectivity_adapter.dart b/lib/services/connectivity_adapter.dart deleted file mode 100644 index f6b760c8..00000000 --- a/lib/services/connectivity_adapter.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:dio/dio.dart'; -import 'package:flutter/widgets.dart'; -import 'package:spotube/services/logger/logger.dart'; - -class ConnectionCheckerService with WidgetsBindingObserver { - final _connectionStreamController = StreamController.broadcast(); - final Dio dio; - - static final _instance = ConnectionCheckerService._(); - - static ConnectionCheckerService get instance => _instance; - - ConnectionCheckerService._() : dio = Dio() { - Timer? timer; - - onConnectivityChanged.listen((connected) { - try { - if (!connected && timer == null) { - // check every 30 seconds if we are connected when we are not connected - timer = Timer.periodic(const Duration(seconds: 30), (timer) async { - if (WidgetsBinding.instance.lifecycleState == - AppLifecycleState.paused) { - return; - } - await isConnected; - }); - } else { - timer?.cancel(); - timer = null; - } - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }); - - Connectivity().onConnectivityChanged.listen((event) async { - await isConnected; - }); - } - - @override - didChangeAppLifecycleState(AppLifecycleState state) async { - if (state == AppLifecycleState.resumed) { - await isConnected; - } - } - - final vpnNames = [ - 'tun', - 'tap', - 'ppp', - 'pptp', - 'l2tp', - 'ipsec', - 'vpn', - 'wireguard', - 'openvpn', - 'softether', - 'proton', - 'strongswan', - 'cisco', - 'forticlient', - 'fortinet', - 'hideme', - 'hidemy', - 'hideman', - 'hidester', - 'lightway', - ]; - - Future isVpnActive() async { - final interfaces = await NetworkInterface.list( - includeLoopback: false, - type: InternetAddressType.any, - ); - - if (interfaces.isEmpty) { - return false; - } - - return interfaces.any( - (interface) => vpnNames.any( - (name) => interface.name.toLowerCase().contains(name), - ), - ); - } - - Future doesConnectTo(String address) async { - try { - final result = await InternetAddress.lookup(address); - if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) { - return true; - } - return false; - } on SocketException catch (_) { - try { - final response = await dio.head('https://$address'); - return (response.statusCode ?? 500) <= 400; - } on DioException catch (_) { - return false; - } - } - } - - Future _isConnected() async { - return await doesConnectTo('google.com') || - await doesConnectTo('www.baidu.com') || // for China - await isVpnActive(); // when VPN is active that means we are connected - } - - bool isConnectedSync = true; - - Future get isConnected async { - final connected = await _isConnected(); - if (connected != isConnectedSync /*previous value*/) { - _connectionStreamController.add(connected); - } - isConnectedSync = connected; - return connected; - } - - Stream get onConnectivityChanged => _connectionStreamController.stream; -} diff --git a/lib/services/device_info/device_info.dart b/lib/services/device_info/device_info.dart deleted file mode 100644 index 87ddd6eb..00000000 --- a/lib/services/device_info/device_info.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:device_info_plus/device_info_plus.dart'; - -class DeviceInfoService { - final DeviceInfoPlugin deviceInfo; - DeviceInfoService._() : deviceInfo = DeviceInfoPlugin(); - - static final instance = DeviceInfoService._(); - - Future deviceId() async { - final info = await deviceInfo.deviceInfo; - - return switch (info) { - AndroidDeviceInfo() => info.id, - IosDeviceInfo() => info.identifierForVendor ?? info.model, - MacOsDeviceInfo() => info.systemGUID ?? info.model, - WindowsDeviceInfo() => info.deviceId, - LinuxDeviceInfo() => info.machineId ?? info.id, - _ => 'Unknown', - }; - } - - Future computerName() async { - final info = await deviceInfo.deviceInfo; - - return switch (info) { - AndroidDeviceInfo() => info.model, - IosDeviceInfo() => info.localizedModel, - MacOsDeviceInfo() => info.computerName, - WindowsDeviceInfo() => info.computerName, - LinuxDeviceInfo() => info.name, - _ => 'Unknown', - }; - } -} diff --git a/lib/services/dio/dio.dart b/lib/services/dio/dio.dart deleted file mode 100644 index cddf1979..00000000 --- a/lib/services/dio/dio.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:dio/dio.dart'; - -final globalDio = Dio(); diff --git a/lib/services/kv_store/encrypted_kv_store.dart b/lib/services/kv_store/encrypted_kv_store.dart deleted file mode 100644 index 4eca0007..00000000 --- a/lib/services/kv_store/encrypted_kv_store.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:uuid/uuid.dart'; -import 'package:spotube/utils/platform.dart'; - -abstract class EncryptedKvStoreService { - static const _storage = FlutterSecureStorage( - aOptions: AndroidOptions( - encryptedSharedPreferences: true, - ), - ); - - static FlutterSecureStorage get storage => _storage; - - static String? _encryptionKeySync; - - static Future initialize() async { - _encryptionKeySync = await encryptionKey; - } - - static String get encryptionKeySync => _encryptionKeySync!; - - static bool get isUnsupportedPlatform => - kIsMacOS || kIsIOS || (kIsLinux && !kIsFlatpak); - - static Future get encryptionKey async { - if (isUnsupportedPlatform) { - return KVStoreService.encryptionKey; - } - try { - final value = await _storage.read(key: 'encryption'); - final key = const Uuid().v4(); - - if (value == null) { - await setEncryptionKey(key); - return key; - } - - return value; - } catch (e) { - return KVStoreService.encryptionKey; - } - } - - static Future setEncryptionKey(String key) async { - if (isUnsupportedPlatform) { - await KVStoreService.setEncryptionKey(key); - return; - } - - try { - await _storage.write(key: 'encryption', value: key); - } catch (e) { - await KVStoreService.setEncryptionKey(key); - } finally { - _encryptionKeySync = key; - } - } -} diff --git a/lib/services/kv_store/kv_store.dart b/lib/services/kv_store/kv_store.dart deleted file mode 100644 index e334322e..00000000 --- a/lib/services/kv_store/kv_store.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'dart:convert'; - -import 'package:encrypt/encrypt.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/services/wm_tools/wm_tools.dart'; -import 'package:uuid/uuid.dart'; - -abstract class KVStoreService { - static SharedPreferences? _sharedPreferences; - static SharedPreferences get sharedPreferences => _sharedPreferences!; - - static Future initialize() async { - _sharedPreferences = await SharedPreferences.getInstance(); - } - - static bool get doneGettingStarted => - sharedPreferences.getBool('doneGettingStarted') ?? false; - static Future setDoneGettingStarted(bool value) async => - await sharedPreferences.setBool('doneGettingStarted', value); - - static bool get askedForBatteryOptimization => - sharedPreferences.getBool('askedForBatteryOptimization') ?? false; - static Future setAskedForBatteryOptimization(bool value) async => - await sharedPreferences.setBool('askedForBatteryOptimization', value); - - static List get recentSearches => - sharedPreferences.getStringList('recentSearches') ?? []; - - static Future setRecentSearches(List value) async => - await sharedPreferences.setStringList('recentSearches', value); - - static WindowSize? get windowSize { - final raw = sharedPreferences.getString('windowSize'); - - if (raw == null) { - return null; - } - return WindowSize.fromJson(jsonDecode(raw)); - } - - static Future setWindowSize(WindowSize value) async => - await sharedPreferences.setString( - 'windowSize', - jsonEncode( - value.toJson(), - ), - ); - - static String get encryptionKey { - final value = sharedPreferences.getString('encryption'); - - final key = const Uuid().v4(); - if (value == null) { - setEncryptionKey(key); - return key; - } - - return value; - } - - static Future setEncryptionKey(String key) async { - await sharedPreferences.setString('encryption', key); - } - - static IV get ivKey { - final iv = sharedPreferences.getString('iv'); - final value = IV.fromSecureRandom(8); - - if (iv == null) { - setIVKey(value); - - return value; - } - - return IV.fromBase64(iv); - } - - static Future setIVKey(IV iv) async { - await sharedPreferences.setString('iv', iv.base64); - } - - static double get volume => sharedPreferences.getDouble('volume') ?? 1.0; - static Future setVolume(double value) async => - await sharedPreferences.setDouble('volume', value); - - static bool get hasMigratedToDrift => - sharedPreferences.getBool('hasMigratedToDrift') ?? false; - static Future setHasMigratedToDrift(bool value) async => - await sharedPreferences.setBool('hasMigratedToDrift', value); - - static Map? get _youtubeEnginePaths { - final jsonRaw = sharedPreferences.getString('ytDlpPath'); - - if (jsonRaw == null) { - return null; - } - - return jsonDecode(jsonRaw); - } - - static String? getYoutubeEnginePath(YoutubeClientEngine engine) { - return _youtubeEnginePaths?[engine.name]; - } - - static Future setYoutubeEnginePath( - YoutubeClientEngine engine, - String path, - ) async { - await sharedPreferences.setString( - 'ytDlpPath', - jsonEncode({ - ...?_youtubeEnginePaths, - engine.name: path, - }), - ); - } -} diff --git a/lib/services/logger/logger.dart b/lib/services/logger/logger.dart deleted file mode 100644 index 1f15bf92..00000000 --- a/lib/services/logger/logger.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:isolate'; - -import 'package:flutter/foundation.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide join; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:logger/logger.dart'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:logging/logging.dart' as logging; - -final _loggingToLoggerLevel = { - logging.Level.ALL: Level.all, - logging.Level.FINEST: Level.trace, - logging.Level.FINER: Level.debug, - logging.Level.FINE: Level.info, - logging.Level.CONFIG: Level.info, - logging.Level.INFO: Level.info, - logging.Level.WARNING: Level.warning, - logging.Level.SEVERE: Level.error, - logging.Level.SHOUT: Level.fatal, - logging.Level.OFF: Level.off, -}; - -class AppLogger { - static late final Logger log; - static late final File logFile; - - static initialize(bool verbose) { - log = Logger( - level: kDebugMode || (verbose && kReleaseMode) ? Level.all : Level.info, - ); - } - - static void _initInternalPackageLoggers() { - if (!kDebugMode) return; - logging.hierarchicalLoggingEnabled = true; - logging.Logger('YoutubeExplode.StreamsClient') - ..level = logging.Level.SEVERE - ..onRecord.listen( - (record) { - log.log( - _loggingToLoggerLevel[record.level] ?? Level.info, - record.message, - error: record.error, - stackTrace: record.stackTrace, - time: record.time, - ); - }, - ); - } - - static R? runZoned(R Function() body) { - return runZonedGuarded( - () { - WidgetsFlutterBinding.ensureInitialized(); - - FlutterError.onError = (details) { - reportError(details.exception, details.stack ?? StackTrace.current); - }; - - PlatformDispatcher.instance.onError = (error, stackTrace) { - reportError(error, stackTrace); - return true; - }; - - if (!kIsWeb) { - Isolate.current.addErrorListener( - RawReceivePort((pair) async { - final isolateError = pair as List; - reportError( - isolateError.first.toString(), - isolateError.last, - ); - }).sendPort, - ); - } - - _initInternalPackageLoggers(); - - getLogsPath().then((value) => logFile = value); - - return body(); - }, - (error, stackTrace) { - reportError(error, stackTrace); - }, - ); - } - - static Future getLogsPath() async { - String dir = (await getApplicationDocumentsDirectory()).path; - if (kIsAndroid) { - dir = (await getExternalStorageDirectory())?.path ?? ""; - } - - if (kIsMacOS) { - dir = join((await getLibraryDirectory()).path, "Logs"); - } - - if (kIsLinux) { - dir = join(_getXdgStateHome(), "spotube"); - } - - final file = File(join(dir, ".spotube_logs")); - if (!await file.exists()) { - await file.create(recursive: true); - } - return file; - } - - static Future reportError( - dynamic error, [ - StackTrace? stackTrace, - message = "", - ]) async { - log.e(message, error: error, stackTrace: stackTrace); - - if (kReleaseMode) { - await logFile.writeAsString( - "[${DateTime.now()}]---------------------\n" - "$error\n$stackTrace\n" - "----------------------------------------\n", - mode: FileMode.writeOnlyAppend, - ); - } - } - - static String _getXdgStateHome() { - // path_provider seems does not support XDG_STATE_HOME, - // which is the specification to store application logs on Linux. - // See https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html - // TODO: Use path_provider once it supports XDG_STATE_HOME - if (const bool.hasEnvironment("XDG_STATE_HOME")) { - String xdgStateHomeRaw = Platform.environment["XDG_STATE_HOME"] ?? ""; - if (xdgStateHomeRaw.isNotEmpty) { - return xdgStateHomeRaw; - } - } - return join(Platform.environment["HOME"] ?? "", ".local", "state"); - } -} - -class AppLoggerProviderObserver extends ProviderObserver { - const AppLoggerProviderObserver(); - - @override - void providerDidFail( - ProviderBase provider, - Object error, - StackTrace stackTrace, - ProviderContainer container, - ) { - AppLogger.reportError(error, stackTrace); - } -} diff --git a/lib/services/metadata/apis/localstorage.dart b/lib/services/metadata/apis/localstorage.dart deleted file mode 100644 index 4c511e77..00000000 --- a/lib/services/metadata/apis/localstorage.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:hetu_spotube_plugin/hetu_spotube_plugin.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class SharedPreferencesLocalStorage implements Localstorage { - final SharedPreferences _prefs; - final String pluginSlug; - - SharedPreferencesLocalStorage(this._prefs, this.pluginSlug); - - String prefix(String key) { - return 'spotube_plugin.$pluginSlug.$key'; - } - - @override - Future clear() { - return _prefs.clear(); - } - - @override - Future containsKey(String key) async { - return _prefs.containsKey(prefix(key)); - } - - @override - Future getBool(String key) async { - return _prefs.getBool(prefix(key)); - } - - @override - Future getDouble(String key) async { - return _prefs.getDouble(prefix(key)); - } - - @override - Future getInt(String key) async { - return _prefs.getInt(prefix(key)); - } - - @override - Future getString(String key) async { - return _prefs.getString(prefix(key)); - } - - @override - Future?> getStringList(String key) async { - return _prefs.getStringList(prefix(key)); - } - - @override - Future remove(String key) async { - await _prefs.remove(prefix(key)); - } - - @override - Future setBool(String key, bool value) async { - await _prefs.setBool(prefix(key), value); - } - - @override - Future setDouble(String key, double value) async { - await _prefs.setDouble(prefix(key), value); - } - - @override - Future setInt(String key, int value) async { - await _prefs.setInt(prefix(key), value); - } - - @override - Future setString(String key, String value) async { - await _prefs.setString(prefix(key), value); - } - - @override - Future setStringList(String key, List value) async { - await _prefs.setStringList(prefix(key), value); - } -} diff --git a/lib/services/metadata/endpoints/album.dart b/lib/services/metadata/endpoints/album.dart deleted file mode 100644 index 8a858343..00000000 --- a/lib/services/metadata/endpoints/album.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:hetu_script/hetu_script.dart'; -import 'package:hetu_script/values.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -class MetadataPluginAlbumEndpoint { - final Hetu hetu; - MetadataPluginAlbumEndpoint(this.hetu); - - HTInstance get hetuMetadataAlbum => - (hetu.fetch("metadataPlugin") as HTInstance).memberGet("album") - as HTInstance; - - Future getAlbum(String id) async { - final raw = - await hetuMetadataAlbum.invoke("getAlbum", positionalArgs: [id]) as Map; - - return SpotubeFullAlbumObject.fromJson( - raw.cast(), - ); - } - - Future> tracks( - String id, { - int? offset, - int? limit, - }) async { - final raw = await hetuMetadataAlbum.invoke( - "tracks", - positionalArgs: [id], - namedArgs: { - "offset": offset, - "limit": limit, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject.fromJson( - raw.cast(), - (Map json) => - SpotubeFullTrackObject.fromJson(json.cast()), - ); - } - - Future> releases({ - int? offset, - int? limit, - }) async { - final raw = await hetuMetadataAlbum.invoke( - "releases", - namedArgs: { - "offset": offset, - "limit": limit, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject.fromJson( - raw.cast(), - (Map json) => - SpotubeSimpleAlbumObject.fromJson(json.cast()), - ); - } - - Future save(List ids) async { - await hetuMetadataAlbum.invoke( - "save", - positionalArgs: [ids], - ); - } - - Future unsave(List ids) async { - await hetuMetadataAlbum.invoke( - "unsave", - positionalArgs: [ids], - ); - } -} diff --git a/lib/services/metadata/endpoints/artist.dart b/lib/services/metadata/endpoints/artist.dart deleted file mode 100644 index d008ce61..00000000 --- a/lib/services/metadata/endpoints/artist.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:hetu_script/hetu_script.dart'; -import 'package:hetu_script/values.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -class MetadataPluginArtistEndpoint { - final Hetu hetu; - MetadataPluginArtistEndpoint(this.hetu); - - HTInstance get hetuMetadataArtist => - (hetu.fetch("metadataPlugin") as HTInstance).memberGet("artist") - as HTInstance; - - Future getArtist(String id) async { - final raw = await hetuMetadataArtist - .invoke("getArtist", positionalArgs: [id]) as Map; - - return SpotubeFullArtistObject.fromJson( - raw.cast(), - ); - } - - Future> topTracks( - String id, { - int? offset, - int? limit, - }) async { - final raw = await hetuMetadataArtist.invoke( - "topTracks", - positionalArgs: [id], - namedArgs: { - "offset": offset, - "limit": limit, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject.fromJson( - raw.cast(), - (Map json) => SpotubeFullTrackObject.fromJson( - json.cast(), - ), - ); - } - - Future> albums( - String id, { - int? offset, - int? limit, - }) async { - final raw = await hetuMetadataArtist.invoke( - "albums", - positionalArgs: [id], - namedArgs: { - "offset": offset, - "limit": limit, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject.fromJson( - raw.cast(), - (Map json) => SpotubeSimpleAlbumObject.fromJson( - json.cast(), - ), - ); - } - - Future save(List ids) async { - await hetuMetadataArtist.invoke( - "save", - positionalArgs: [ids], - ); - } - - Future unsave(List ids) async { - await hetuMetadataArtist.invoke( - "unsave", - positionalArgs: [ids], - ); - } - - Future> related( - String id, { - int? offset, - int? limit, - }) async { - final raw = await hetuMetadataArtist.invoke( - "related", - positionalArgs: [id], - namedArgs: { - "offset": offset, - "limit": limit ?? 20, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject.fromJson( - raw.cast(), - (Map json) => SpotubeFullArtistObject.fromJson( - json.cast(), - ), - ); - } -} diff --git a/lib/services/metadata/endpoints/audio_source.dart b/lib/services/metadata/endpoints/audio_source.dart deleted file mode 100644 index d22449c6..00000000 --- a/lib/services/metadata/endpoints/audio_source.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:hetu_script/hetu_script.dart'; -import 'package:hetu_script/values.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -class MetadataPluginAudioSourceEndpoint { - final Hetu hetu; - MetadataPluginAudioSourceEndpoint(this.hetu); - - HTInstance get hetuMetadataAudioSource => - (hetu.fetch("metadataPlugin") as HTInstance).memberGet("audioSource") - as HTInstance; - - List get supportedPresets { - final raw = hetuMetadataAudioSource.memberGet("supportedPresets") as List; - - return raw - .map((e) => SpotubeAudioSourceContainerPreset.fromJson(e)) - .toList(); - } - - Future> matches( - SpotubeFullTrackObject track, - ) async { - final raw = await hetuMetadataAudioSource - .invoke("matches", positionalArgs: [track.toJson()]) as List; - - return raw.map((e) => SpotubeAudioSourceMatchObject.fromJson(e)).toList(); - } - - Future> streams( - SpotubeAudioSourceMatchObject match, - ) async { - final raw = await hetuMetadataAudioSource - .invoke("streams", positionalArgs: [match.toJson()]) as List; - - return raw.map((e) => SpotubeAudioSourceStreamObject.fromJson(e)).toList(); - } -} diff --git a/lib/services/metadata/endpoints/auth.dart b/lib/services/metadata/endpoints/auth.dart deleted file mode 100644 index 7c2077be..00000000 --- a/lib/services/metadata/endpoints/auth.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:desktop_webview_window/desktop_webview_window.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:hetu_script/hetu_script.dart'; -import 'package:hetu_std/hetu_std.dart'; -import 'package:spotube/utils/platform.dart'; - -class MetadataAuthEndpoint { - final Hetu hetu; - - MetadataAuthEndpoint(this.hetu); - - Stream get authStateStream => - hetu.eval("metadataPlugin.auth.authStateStream"); - - Future authenticate() async { - await hetu.eval("metadataPlugin.auth.authenticate()"); - } - - bool isAuthenticated() { - return hetu.eval("metadataPlugin.auth.isAuthenticated()") as bool; - } - - Future logout() async { - await hetu.eval("metadataPlugin.auth.logout()"); - if (kIsMobile) { - WebStorageManager.instance().deleteAllData(); - CookieManager.instance().deleteAllCookies(); - } - if (kIsDesktop) { - await WebviewWindow.clearAll(); - } - } -} diff --git a/lib/services/metadata/endpoints/browse.dart b/lib/services/metadata/endpoints/browse.dart deleted file mode 100644 index c8105ad1..00000000 --- a/lib/services/metadata/endpoints/browse.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:hetu_script/hetu_script.dart'; -import 'package:hetu_script/values.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -class MetadataPluginBrowseEndpoint { - final Hetu hetu; - MetadataPluginBrowseEndpoint(this.hetu); - - HTInstance get hetuMetadataBrowse => - (hetu.fetch("metadataPlugin") as HTInstance).memberGet("browse") - as HTInstance; - - Future>> - sections({ - int? offset, - int? limit, - }) async { - final raw = await hetuMetadataBrowse.invoke( - "sections", - namedArgs: { - "offset": offset, - "limit": limit, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject< - SpotubeBrowseSectionObject>.fromJson( - raw.cast(), - (Map json) => SpotubeBrowseSectionObject.fromJson( - json.cast(), - (json) { - final isPlaylist = json["owner"] != null; - final isAlbum = json["artists"] != null; - if (isPlaylist) { - return SpotubeSimplePlaylistObject.fromJson( - json.cast(), - ); - } else if (isAlbum) { - return SpotubeSimpleAlbumObject.fromJson( - json.cast(), - ); - } else { - return SpotubeFullArtistObject.fromJson( - json.cast(), - ); - } - }, - ), - ); - } - - Future> sectionItems( - String id, { - int? offset, - int? limit, - }) async { - final raw = await hetuMetadataBrowse.invoke( - "sectionItems", - positionalArgs: [id], - namedArgs: { - "offset": offset, - "limit": limit, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject.fromJson( - raw.cast(), - (json) { - final isPlaylist = json["owner"] != null; - final isAlbum = json["artists"] != null; - if (isPlaylist) { - return SpotubeSimplePlaylistObject.fromJson( - json.cast(), - ); - } else if (isAlbum) { - return SpotubeSimpleAlbumObject.fromJson( - json.cast(), - ); - } else { - return SpotubeFullArtistObject.fromJson( - json.cast(), - ); - } - }, - ); - } -} diff --git a/lib/services/metadata/endpoints/core.dart b/lib/services/metadata/endpoints/core.dart deleted file mode 100644 index a8f86128..00000000 --- a/lib/services/metadata/endpoints/core.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:hetu_script/hetu_script.dart'; -import 'package:hetu_script/values.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -class MetadataPluginCore { - final Hetu hetu; - - MetadataPluginCore(this.hetu); - - HTInstance get hetuMetadataPluginUpdater => - (hetu.fetch("metadataPlugin") as HTInstance).memberGet("core") - as HTInstance; - - Future checkUpdate( - PluginConfiguration pluginConfig, - ) async { - final result = await hetuMetadataPluginUpdater.invoke( - "checkUpdate", - positionalArgs: [pluginConfig.toJson()], - ); - - return result == null - ? null - : PluginUpdateAvailable.fromJson( - (result as Map).cast(), - ); - } - - Future get support async { - final result = await hetuMetadataPluginUpdater.memberGet("support"); - - return result as String; - } - - /// [details] is a map containing the scrobble information, such as: - /// - [id] -> The unique identifier of the track. - /// - [title] -> The title of the track. - /// - [artists] -> List of artists - /// - [id] -> The unique identifier of the artist. - /// - [name] -> The name of the artist. - /// - [album] -> The album of the track - /// - [id] -> The unique identifier of the album. - /// - [name] -> The name of the album. - /// - [timestamp] -> The timestamp of the scrobble (optional). - /// - [duration_ms] -> The duration of the track in milliseconds (optional). - /// - [isrc] -> The ISRC code of the track (optional). - Future scrobble(Map details) { - return hetuMetadataPluginUpdater.invoke( - "scrobble", - positionalArgs: [details], - ); - } -} diff --git a/lib/services/metadata/endpoints/playlist.dart b/lib/services/metadata/endpoints/playlist.dart deleted file mode 100644 index c7f20487..00000000 --- a/lib/services/metadata/endpoints/playlist.dart +++ /dev/null @@ -1,135 +0,0 @@ -import 'package:hetu_script/hetu_script.dart'; -import 'package:hetu_script/values.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -class MetadataPluginPlaylistEndpoint { - final Hetu hetu; - MetadataPluginPlaylistEndpoint(this.hetu); - - HTInstance get hetuMetadataPlaylist => - (hetu.fetch("metadataPlugin") as HTInstance).memberGet("playlist") - as HTInstance; - - Future getPlaylist(String id) async { - final raw = await hetuMetadataPlaylist - .invoke("getPlaylist", positionalArgs: [id]) as Map; - - return SpotubeFullPlaylistObject.fromJson( - raw.cast(), - ); - } - - Future> tracks( - String id, { - int? offset, - int? limit, - }) async { - final raw = await hetuMetadataPlaylist.invoke( - "tracks", - positionalArgs: [id], - namedArgs: { - "offset": offset, - "limit": limit, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject.fromJson( - raw.cast(), - (Map json) => - SpotubeFullTrackObject.fromJson(json.cast()), - ); - } - - Future create( - String userId, { - required String name, - String? description, - bool? public, - bool? collaborative, - }) async { - final raw = await hetuMetadataPlaylist.invoke( - "create", - positionalArgs: [userId], - namedArgs: { - "name": name, - "description": description, - "public": public, - "collaborative": collaborative, - }..removeWhere((key, value) => value == null), - ) as Map?; - - if (raw == null) return null; - - return SpotubeFullPlaylistObject.fromJson( - raw.cast(), - ); - } - - Future update( - String playlistId, { - String? name, - String? description, - bool? public, - bool? collaborative, - }) async { - await hetuMetadataPlaylist.invoke( - "update", - positionalArgs: [playlistId], - namedArgs: { - "name": name, - "description": description, - "public": public, - "collaborative": collaborative, - }..removeWhere((key, value) => value == null), - ); - } - - Future addTracks( - String playlistId, { - required List trackIds, - int? position, - }) async { - await hetuMetadataPlaylist.invoke( - "addTracks", - positionalArgs: [playlistId], - namedArgs: { - "trackIds": trackIds, - "position": position, - }..removeWhere((key, value) => value == null), - ); - } - - Future removeTracks( - String playlistId, { - required List trackIds, - }) async { - await hetuMetadataPlaylist.invoke( - "removeTracks", - positionalArgs: [playlistId], - namedArgs: { - "trackIds": trackIds, - }..removeWhere((key, value) => value == null), - ); - } - - Future save(String playlistId) async { - await hetuMetadataPlaylist.invoke( - "save", - positionalArgs: [playlistId], - ); - } - - Future unsave(String playlistId) async { - await hetuMetadataPlaylist.invoke( - "unsave", - positionalArgs: [playlistId], - ); - } - - Future deletePlaylist(String playlistId) async { - return await hetuMetadataPlaylist.invoke( - "deletePlaylist", - positionalArgs: [playlistId], - ); - } -} diff --git a/lib/services/metadata/endpoints/search.dart b/lib/services/metadata/endpoints/search.dart deleted file mode 100644 index c2e14765..00000000 --- a/lib/services/metadata/endpoints/search.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:hetu_script/hetu_script.dart'; -import 'package:hetu_script/values.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -class MetadataPluginSearchEndpoint { - final Hetu hetu; - MetadataPluginSearchEndpoint(this.hetu); - - HTInstance get hetuMetadataSearch => - (hetu.fetch("metadataPlugin") as HTInstance).memberGet("search") - as HTInstance; - - List get chips { - return (hetuMetadataSearch.memberGet("chips") as List).cast(); - } - - Future all(String query) async { - if (query.isEmpty) { - return SpotubeSearchResponseObject( - albums: [], - artists: [], - playlists: [], - tracks: [], - ); - } - - final raw = await hetuMetadataSearch.invoke( - "all", - positionalArgs: [query], - ) as Map; - - return SpotubeSearchResponseObject.fromJson(raw.cast()); - } - - Future> albums( - String query, { - int? limit, - int? offset, - }) async { - if (query.isEmpty) { - return SpotubePaginationResponseObject( - items: [], - total: 0, - limit: limit ?? 20, - hasMore: false, - nextOffset: null, - ); - } - - final raw = await hetuMetadataSearch.invoke( - "albums", - positionalArgs: [query], - namedArgs: { - "limit": limit, - "offset": offset, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject.fromJson( - raw.cast(), - (json) => SpotubeSimpleAlbumObject.fromJson(json.cast()), - ); - } - - Future> artists( - String query, { - int? limit, - int? offset, - }) async { - if (query.isEmpty) { - return SpotubePaginationResponseObject( - items: [], - total: 0, - limit: limit ?? 20, - hasMore: false, - nextOffset: null, - ); - } - - final raw = await hetuMetadataSearch.invoke( - "artists", - positionalArgs: [query], - namedArgs: { - "limit": limit, - "offset": offset, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject.fromJson( - raw.cast(), - (json) => SpotubeFullArtistObject.fromJson( - json.cast(), - ), - ); - } - - Future> - playlists( - String query, { - int? limit, - int? offset, - }) async { - if (query.isEmpty) { - return SpotubePaginationResponseObject( - items: [], - total: 0, - limit: limit ?? 20, - hasMore: false, - nextOffset: null, - ); - } - - final raw = await hetuMetadataSearch.invoke( - "playlists", - positionalArgs: [query], - namedArgs: { - "limit": limit, - "offset": offset, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject< - SpotubeSimplePlaylistObject>.fromJson( - raw.cast(), - (json) => SpotubeSimplePlaylistObject.fromJson( - json.cast(), - ), - ); - } - - Future> tracks( - String query, { - int? limit, - int? offset, - }) async { - if (query.isEmpty) { - return SpotubePaginationResponseObject( - items: [], - total: 0, - limit: limit ?? 20, - hasMore: false, - nextOffset: null, - ); - } - - final raw = await hetuMetadataSearch.invoke( - "tracks", - positionalArgs: [query], - namedArgs: { - "limit": limit, - "offset": offset, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject.fromJson( - raw.cast(), - (json) => SpotubeFullTrackObject.fromJson(json.cast()), - ); - } -} diff --git a/lib/services/metadata/endpoints/track.dart b/lib/services/metadata/endpoints/track.dart deleted file mode 100644 index 31535970..00000000 --- a/lib/services/metadata/endpoints/track.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:hetu_script/hetu_script.dart'; -import 'package:hetu_script/values.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -class MetadataPluginTrackEndpoint { - final Hetu hetu; - MetadataPluginTrackEndpoint(this.hetu); - - HTInstance get hetuMetadataTrack => - (hetu.fetch("metadataPlugin") as HTInstance).memberGet("track") - as HTInstance; - - Future getTrack(String id) async { - final raw = - await hetuMetadataTrack.invoke("getTrack", positionalArgs: [id]) as Map; - - return SpotubeFullTrackObject.fromJson( - raw.cast(), - ); - } - - Future save(List ids) async { - await hetuMetadataTrack.invoke("save", positionalArgs: [ids]); - } - - Future unsave(List ids) async { - await hetuMetadataTrack.invoke("unsave", positionalArgs: [ids]); - } - - Future> radio(String id) async { - final result = await hetuMetadataTrack.invoke( - "radio", - positionalArgs: [id], - ); - - return (result as List) - .map( - (e) => SpotubeFullTrackObject.fromJson( - (e as Map).cast(), - ), - ) - .toList(); - } -} diff --git a/lib/services/metadata/endpoints/user.dart b/lib/services/metadata/endpoints/user.dart deleted file mode 100644 index 3c8f0e42..00000000 --- a/lib/services/metadata/endpoints/user.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'package:hetu_script/hetu_script.dart'; -import 'package:hetu_script/values.dart'; -import 'package:spotube/models/metadata/metadata.dart'; - -class MetadataPluginUserEndpoint { - final Hetu hetu; - MetadataPluginUserEndpoint(this.hetu); - - HTInstance get hetuMetadataUser => - (hetu.fetch("metadataPlugin") as HTInstance).memberGet("user") - as HTInstance; - - Future me() async { - final raw = await hetuMetadataUser.invoke("me") as Map; - - return SpotubeUserObject.fromJson( - raw.cast(), - ); - } - - Future> savedTracks({ - int? offset, - int? limit, - }) async { - final raw = await hetuMetadataUser.invoke( - "savedTracks", - namedArgs: { - "offset": offset, - "limit": limit, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject.fromJson( - raw.cast(), - (Map json) => - SpotubeFullTrackObject.fromJson(json.cast()), - ); - } - - Future> - savedPlaylists({ - int? offset, - int? limit, - }) async { - final raw = await hetuMetadataUser.invoke( - "savedPlaylists", - namedArgs: { - "offset": offset, - "limit": limit, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject< - SpotubeSimplePlaylistObject>.fromJson( - raw.cast(), - (Map json) => - SpotubeSimplePlaylistObject.fromJson(json.cast()), - ); - } - - Future> - savedAlbums({ - int? offset, - int? limit, - }) async { - final raw = await hetuMetadataUser.invoke( - "savedAlbums", - namedArgs: { - "offset": offset, - "limit": limit, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject.fromJson( - raw.cast(), - (Map json) => - SpotubeSimpleAlbumObject.fromJson(json.cast()), - ); - } - - Future> - savedArtists({ - int? offset, - int? limit, - }) async { - final raw = await hetuMetadataUser.invoke( - "savedArtists", - namedArgs: { - "offset": offset, - "limit": limit, - }..removeWhere((key, value) => value == null), - ) as Map; - - return SpotubePaginationResponseObject.fromJson( - raw.cast(), - (Map json) => - SpotubeFullArtistObject.fromJson(json.cast()), - ); - } - - Future isSavedPlaylist(String playlistId) async { - return await hetuMetadataUser.invoke( - "isSavedPlaylist", - positionalArgs: [playlistId], - ) as bool; - } - - Future> isSavedTracks(List ids) async { - final values = await hetuMetadataUser.invoke( - "isSavedTracks", - positionalArgs: [ids], - ); - return (values as List).cast(); - } - - Future> isSavedAlbums(List ids) async { - final values = await hetuMetadataUser.invoke( - "isSavedAlbums", - positionalArgs: [ids], - ) as List; - return values.cast(); - } - - Future> isSavedArtists(List ids) async { - final values = await hetuMetadataUser.invoke( - "isSavedArtists", - positionalArgs: [ids], - ) as List; - - return values.cast(); - } -} diff --git a/lib/services/metadata/errors/exceptions.dart b/lib/services/metadata/errors/exceptions.dart deleted file mode 100644 index 5bb5ac57..00000000 --- a/lib/services/metadata/errors/exceptions.dart +++ /dev/null @@ -1,85 +0,0 @@ -enum MetadataPluginErrorCode { - pluginApiVersionMismatch, - invalidPluginConfiguration, - failedToGetReleaseInfo, - noReleasesFound, - assetUrlNotFound, - pluginConfigJsonNotFound, - unsupportedPluginDownloadWebsite, - pluginDownloadFailed, - duplicatePlugin, - pluginByteCodeFileNotFound, - noDefaultMetadataPlugin, - noDefaultAudiSourcePlugin, -} - -class MetadataPluginException implements Exception { - final String message; - final MetadataPluginErrorCode errorCode; - - MetadataPluginException._(this.message, {required this.errorCode}); - MetadataPluginException.pluginApiVersionMismatch() - : this._( - 'Plugin API version mismatch', - errorCode: MetadataPluginErrorCode.pluginApiVersionMismatch, - ); - MetadataPluginException.invalidPluginConfiguration() - : this._( - 'Invalid plugin configuration', - errorCode: MetadataPluginErrorCode.invalidPluginConfiguration, - ); - MetadataPluginException.failedToGetRelease() - : this._( - 'Failed to get release information', - errorCode: MetadataPluginErrorCode.failedToGetReleaseInfo, - ); - MetadataPluginException.noReleasesFound() - : this._( - 'No releases found for the plugin', - errorCode: MetadataPluginErrorCode.noReleasesFound, - ); - - MetadataPluginException.assetUrlNotFound() - : this._( - 'No asset URL found for the plugin release', - errorCode: MetadataPluginErrorCode.assetUrlNotFound, - ); - MetadataPluginException.pluginConfigJsonNotFound() - : this._( - 'Plugin configuration JSON, plugin.json file not found', - errorCode: MetadataPluginErrorCode.pluginConfigJsonNotFound, - ); - MetadataPluginException.unsupportedPluginDownloadWebsite() - : this._( - 'Unsupported plugin download website. Please use GitHub or Codeberg.', - errorCode: MetadataPluginErrorCode.unsupportedPluginDownloadWebsite, - ); - MetadataPluginException.pluginDownloadFailed() - : this._( - 'Failed to download the plugin. Please check your internet connection or try again later.', - errorCode: MetadataPluginErrorCode.pluginDownloadFailed, - ); - MetadataPluginException.duplicatePlugin() - : this._( - 'Same plugin already exists with the same name and version.', - errorCode: MetadataPluginErrorCode.duplicatePlugin, - ); - MetadataPluginException.pluginByteCodeFileNotFound() - : this._( - 'Plugin byte code file, plugin.out not found. Please ensure the plugin is correctly packaged.', - errorCode: MetadataPluginErrorCode.pluginByteCodeFileNotFound, - ); - MetadataPluginException.noDefaultMetadataPlugin() - : this._( - 'No default metadata plugin is set. Please set a default plugin in the settings.', - errorCode: MetadataPluginErrorCode.noDefaultMetadataPlugin, - ); - MetadataPluginException.noDefaultAudioSourcePlugin() - : this._( - 'No default audio source plugin is set. Please set a default plugin in the settings.', - errorCode: MetadataPluginErrorCode.noDefaultAudiSourcePlugin, - ); - - @override - String toString() => 'MetadataPluginException: $message'; -} diff --git a/lib/services/metadata/metadata.dart b/lib/services/metadata/metadata.dart deleted file mode 100644 index 5860e0d6..00000000 --- a/lib/services/metadata/metadata.dart +++ /dev/null @@ -1,181 +0,0 @@ -import 'dart:typed_data'; - -import 'package:auto_route/auto_route.dart'; -import 'package:hetu_otp_util/hetu_otp_util.dart'; -import 'package:hetu_script/hetu_script.dart'; -import 'package:hetu_spotube_plugin/hetu_spotube_plugin.dart' as spotube_plugin; -import 'package:hetu_spotube_plugin/hetu_spotube_plugin.dart' - hide YouTubeEngine; -import 'package:hetu_std/hetu_std.dart'; -import 'package:pub_semver/pub_semver.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotube/collections/routes.dart'; -import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/services/metadata/apis/localstorage.dart'; -import 'package:spotube/services/metadata/endpoints/album.dart'; -import 'package:spotube/services/metadata/endpoints/artist.dart'; -import 'package:spotube/services/metadata/endpoints/audio_source.dart'; -import 'package:spotube/services/metadata/endpoints/auth.dart'; -import 'package:spotube/services/metadata/endpoints/browse.dart'; -import 'package:spotube/services/metadata/endpoints/playlist.dart'; -import 'package:spotube/services/metadata/endpoints/search.dart'; -import 'package:spotube/services/metadata/endpoints/track.dart'; -import 'package:spotube/services/metadata/endpoints/core.dart'; -import 'package:spotube/services/metadata/endpoints/user.dart'; -import 'package:spotube/services/youtube_engine/youtube_engine.dart'; - -const defaultMetadataLimit = "20"; - -class MetadataPlugin { - static final pluginApiVersion = Version.parse("2.0.0"); - - static Future create( - YouTubeEngine youtubeEngine, - PluginConfiguration config, - Uint8List byteCode, - ) async { - final sharedPreferences = await SharedPreferences.getInstance(); - BuildContext? pageContext; - - final hetu = Hetu(); - hetu.init(); - - HetuStdLoader.loadBindings(hetu); - HetuSpotubePluginLoader.loadBindings( - hetu, - localStorageImpl: SharedPreferencesLocalStorage( - sharedPreferences, - config.slug, - ), - onNavigatorPush: (route) { - return rootNavigatorKey.currentContext?.router - .pushWidget(Builder(builder: (context) { - pageContext = context; - return Scaffold( - headers: const [ - TitleBar( - automaticallyImplyLeading: true, - ) - ], - child: route, - ); - })); - }, - onNavigatorPop: () { - pageContext?.maybePop(); - }, - onShowForm: (title, fields) async { - if (rootNavigatorKey.currentContext == null) { - return []; - } - - return await rootNavigatorKey.currentContext!.router - .push>?>( - SettingsMetadataProviderFormRoute( - title: title, - fields: - fields.map((e) => MetadataFormFieldObject.fromJson(e)).toList(), - ), - ); - }, - createYoutubeEngine: () { - return spotube_plugin.YouTubeEngine( - search: (query) async { - final result = await youtubeEngine.searchVideos(query); - return result - .map((video) => { - 'id': video.id.value, - 'title': video.title, - 'author': video.author, - 'duration': video.duration?.inSeconds, - 'description': video.description, - 'uploadDate': video.uploadDate?.toIso8601String(), - 'viewCount': video.engagement.viewCount, - 'likeCount': video.engagement.likeCount, - 'isLive': video.isLive, - }) - .toList(); - }, - getVideo: (videoId) async { - final video = await youtubeEngine.getVideo(videoId); - return { - 'id': video.id.value, - 'title': video.title, - 'author': video.author, - 'duration': video.duration?.inSeconds, - 'description': video.description, - 'uploadDate': video.uploadDate?.toIso8601String(), - 'viewCount': video.engagement.viewCount, - 'likeCount': video.engagement.likeCount, - 'isLive': video.isLive, - }; - }, - streamManifest: (videoId) { - return youtubeEngine.getStreamManifest(videoId).then( - (manifest) { - final streams = manifest.audioOnly - .map( - (stream) => { - 'url': stream.url.toString(), - 'quality': stream.qualityLabel, - 'bitrate': stream.bitrate.bitsPerSecond, - 'container': stream.container.name, - 'videoId': stream.videoId, - }, - ) - .toList(); - return streams; - }, - ); - }, - ); - }, - ); - - await HetuStdLoader.loadBytecodeFlutter(hetu); - await HetuOtpUtilLoader.loadBytecodeFlutter(hetu); - await HetuSpotubePluginLoader.loadBytecodeFlutter(hetu); - - hetu.loadBytecode(bytes: byteCode, moduleName: "plugin"); - hetu.eval(""" - import "module:plugin" as plugin - - var Plugin = plugin.${config.entryPoint} - - var metadataPlugin = Plugin() - """); - - return MetadataPlugin._(hetu); - } - - final Hetu hetu; - - late final MetadataAuthEndpoint auth; - - late final MetadataPluginAudioSourceEndpoint audioSource; - late final MetadataPluginAlbumEndpoint album; - late final MetadataPluginArtistEndpoint artist; - late final MetadataPluginBrowseEndpoint browse; - late final MetadataPluginSearchEndpoint search; - late final MetadataPluginPlaylistEndpoint playlist; - late final MetadataPluginTrackEndpoint track; - late final MetadataPluginUserEndpoint user; - late final MetadataPluginCore core; - - MetadataPlugin._(this.hetu) { - auth = MetadataAuthEndpoint(hetu); - - audioSource = MetadataPluginAudioSourceEndpoint(hetu); - artist = MetadataPluginArtistEndpoint(hetu); - album = MetadataPluginAlbumEndpoint(hetu); - browse = MetadataPluginBrowseEndpoint(hetu); - search = MetadataPluginSearchEndpoint(hetu); - playlist = MetadataPluginPlaylistEndpoint(hetu); - track = MetadataPluginTrackEndpoint(hetu); - user = MetadataPluginUserEndpoint(hetu); - core = MetadataPluginCore(hetu); - } -} diff --git a/lib/services/sourced_track/exceptions.dart b/lib/services/sourced_track/exceptions.dart deleted file mode 100644 index 4817c9fb..00000000 --- a/lib/services/sourced_track/exceptions.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:spotube/models/metadata/metadata.dart'; - -class TrackNotFoundError extends Error { - final SpotubeTrackObject track; - - TrackNotFoundError(this.track); - - @override - String toString() { - return '[TrackNotFoundError] ${track.name} - ${track.artists.join(", ")}'; - } -} diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart deleted file mode 100644 index 385e5be6..00000000 --- a/lib/services/sourced_track/sourced_track.dart +++ /dev/null @@ -1,374 +0,0 @@ -import 'dart:convert'; - -import 'package:collection/collection.dart'; -import 'package:dio/dio.dart'; -import 'package:drift/drift.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/models/playback/track_sources.dart'; -import 'package:spotube/provider/database/database.dart'; -import 'package:spotube/provider/metadata_plugin/audio_source/quality_presets.dart'; -import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/services/dio/dio.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:spotube/services/metadata/errors/exceptions.dart'; - -import 'package:spotube/services/sourced_track/exceptions.dart'; -import 'package:spotube/utils/service_utils.dart'; - -final officialMusicRegex = RegExp( - r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)", - caseSensitive: false, -); - -class SourcedTrack extends BasicSourcedTrack { - final Ref ref; - - SourcedTrack({ - required this.ref, - required super.info, - required super.query, - required super.source, - required super.siblings, - required super.sources, - }); - - static Future fetchFromTrack({ - required SpotubeFullTrackObject query, - required Ref ref, - }) async { - final audioSource = await ref.read(audioSourcePluginProvider.future); - final audioSourceConfig = await ref.read(metadataPluginsProvider - .selectAsync((data) => data.defaultAudioSourcePluginConfig)); - if (audioSource == null || audioSourceConfig == null) { - throw MetadataPluginException.noDefaultAudioSourcePlugin(); - } - - final database = ref.read(databaseProvider); - final cachedSource = await (database.select(database.sourceMatchTable) - ..where((s) => - s.trackId.equals(query.id) & - s.sourceType.equals(audioSourceConfig.slug)) - ..limit(1) - ..orderBy([ - (s) => - OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc), - ])) - .get() - .then((s) => s.firstOrNull); - - if (cachedSource == null) { - final siblings = await fetchSiblings(ref: ref, query: query); - if (siblings.isEmpty) { - throw TrackNotFoundError(query); - } - - await database.into(database.sourceMatchTable).insert( - SourceMatchTableCompanion.insert( - trackId: query.id, - sourceInfo: Value(jsonEncode(siblings.first)), - sourceType: audioSourceConfig.slug, - ), - ); - - final manifest = await audioSource.audioSource.streams(siblings.first); - - return SourcedTrack( - ref: ref, - siblings: siblings.skip(1).toList(), - info: siblings.first, - source: audioSourceConfig.slug, - sources: manifest, - query: query, - ); - } - final item = SpotubeAudioSourceMatchObject.fromJson( - jsonDecode(cachedSource.sourceInfo), - ); - final manifest = await audioSource.audioSource.streams(item); - - final sourcedTrack = SourcedTrack( - ref: ref, - siblings: [], - sources: manifest, - info: item, - query: query, - source: audioSourceConfig.slug, - ); - - AppLogger.log.i("${query.name}: ${sourcedTrack.url}"); - - return sourcedTrack; - } - - static List rankResults( - List results, - SpotubeFullTrackObject track, - ) { - return results - .map((sibling) { - int score = 0; - - for (final artist in track.artists) { - final isSameChannelArtist = - sibling.artists.any((a) => a.toLowerCase() == artist.name); - - if (isSameChannelArtist) { - score += 1; - } - - final titleContainsArtist = - sibling.title.toLowerCase().contains(artist.name.toLowerCase()); - - if (titleContainsArtist) { - score += 1; - } - } - - final titleContainsTrackName = - sibling.title.toLowerCase().contains(track.name.toLowerCase()); - - final hasOfficialFlag = - officialMusicRegex.hasMatch(sibling.title.toLowerCase()); - - if (titleContainsTrackName) { - score += 3; - } - - if (hasOfficialFlag) { - score += 1; - } - - if (hasOfficialFlag && titleContainsTrackName) { - score += 2; - } - - return (sibling: sibling, score: score); - }) - .sorted((a, b) => b.score.compareTo(a.score)) - .map((e) => e.sibling) - .toList(); - } - - static Future> fetchSiblings({ - required SpotubeFullTrackObject query, - required Ref ref, - }) async { - final audioSource = await ref.read(audioSourcePluginProvider.future); - - if (audioSource == null) { - throw MetadataPluginException.noDefaultAudioSourcePlugin(); - } - - final videoResults = []; - - final searchResults = await audioSource.audioSource.matches(query); - - if (ServiceUtils.onlyContainsEnglish(query.name)) { - videoResults.addAll(searchResults); - } else { - videoResults.addAll(rankResults(searchResults, query)); - } - - return videoResults.toSet().toList(); - } - - Future copyWithSibling() async { - if (siblings.isNotEmpty) { - return this; - } - final fetchedSiblings = await fetchSiblings(ref: ref, query: query); - - return SourcedTrack( - ref: ref, - siblings: fetchedSiblings.where((s) => s.id != info.id).toList(), - source: source, - sources: sources, - info: info, - query: query, - ); - } - - Future swapWithSibling( - SpotubeAudioSourceMatchObject sibling, - ) async { - if (sibling.id == info.id) { - return null; - } - - final audioSource = await ref.read(audioSourcePluginProvider.future); - final audioSourceConfig = await ref.read(metadataPluginsProvider - .selectAsync((data) => data.defaultAudioSourcePluginConfig)); - if (audioSource == null || audioSourceConfig == null) { - throw MetadataPluginException.noDefaultAudioSourcePlugin(); - } - - // a sibling source that was fetched from the search results - final isStepSibling = siblings.none((s) => s.id == sibling.id); - - final newSourceInfo = isStepSibling - ? sibling - : siblings.firstWhere((s) => s.id == sibling.id); - - final newSiblings = siblings.where((s) => s.id != sibling.id).toList() - ..insert(0, info); - - final manifest = await audioSource.audioSource.streams(newSourceInfo); - - final database = ref.read(databaseProvider); - - // Delete the old Entry - await (database.sourceMatchTable.delete() - ..where( - (table) => - table.trackId.equals(query.id) & - table.sourceType.equals(audioSourceConfig.slug), - )) - .go(); - - await database.into(database.sourceMatchTable).insert( - SourceMatchTableCompanion.insert( - trackId: query.id, - sourceInfo: Value(jsonEncode(sibling)), - sourceType: audioSourceConfig.slug, - createdAt: Value(DateTime.now()), - ), - mode: InsertMode.replace, - ); - - return SourcedTrack( - ref: ref, - source: source, - siblings: newSiblings, - sources: manifest, - info: newSourceInfo, - query: query, - ); - } - - Future swapWithSiblingOfIndex(int index) { - return swapWithSibling(siblings[index]); - } - - Future refreshStream() async { - final audioSource = await ref.read(audioSourcePluginProvider.future); - final audioSourceConfig = await ref.read(metadataPluginsProvider - .selectAsync((data) => data.defaultAudioSourcePluginConfig)); - if (audioSource == null || audioSourceConfig == null) { - throw MetadataPluginException.noDefaultAudioSourcePlugin(); - } - - List validStreams = []; - - final stringBuffer = StringBuffer(); - for (final source in sources) { - final res = await globalDio.head( - source.url, - options: - Options(validateStatus: (status) => status != null && status < 500), - ); - - stringBuffer.writeln( - "[${query.id}] ${res.statusCode} ${source.container} ${source.codec} ${source.bitrate}", - ); - - if (res.statusCode! < 400) { - validStreams.add(source); - } - } - - AppLogger.log.d(stringBuffer.toString()); - - if (validStreams.isEmpty) { - validStreams = await audioSource.audioSource.streams(info); - } - - final sourcedTrack = SourcedTrack( - ref: ref, - siblings: siblings, - source: source, - sources: validStreams, - info: info, - query: query, - ); - - AppLogger.log.i("Refreshing ${query.name}: ${sourcedTrack.url}"); - - return sourcedTrack; - } - - String? get url { - final preferences = ref.read(audioSourcePresetsProvider); - - return getUrlOfQuality( - preferences.presets[preferences.selectedStreamingContainerIndex], - preferences.selectedStreamingQualityIndex, - ); - } - - /// Returns the URL of the track based on the codec and quality preferences. - /// If an exact match is not found, it will return the closest match based on - /// the user's audio quality preference. - /// - /// If no sources match the codec, it will return the first or last source - /// based on the user's audio quality preference. - SpotubeAudioSourceStreamObject? getStreamOfQuality( - SpotubeAudioSourceContainerPreset preset, - int qualityIndex, - ) { - if (sources.isEmpty) return null; - - final quality = preset.qualities[qualityIndex]; - - final exactMatch = sources.firstWhereOrNull( - (source) { - if (source.container != preset.name) return false; - - if (quality case SpotubeAudioLosslessContainerQuality()) { - return source.sampleRate == quality.sampleRate && - source.bitDepth == quality.bitDepth; - } else { - return source.bitrate == - (quality as SpotubeAudioLossyContainerQuality).bitrate; - } - }, - ); - - if (exactMatch != null) { - return exactMatch; - } - - // Find the preset with closest quality to the supplied quality - return sources.where((source) { - return source.container == preset.name; - }).reduce((prev, curr) { - if (quality is SpotubeAudioLosslessContainerQuality) { - final prevDiff = ((prev.sampleRate ?? 0) - quality.sampleRate).abs() + - ((prev.bitDepth ?? 0) - quality.bitDepth).abs(); - final currDiff = ((curr.sampleRate ?? 0) - quality.sampleRate).abs() + - ((curr.bitDepth ?? 0) - quality.bitDepth).abs(); - return currDiff < prevDiff ? curr : prev; - } else { - final prevDiff = ((prev.bitrate ?? 0) - - (quality as SpotubeAudioLossyContainerQuality).bitrate) - .abs(); - final currDiff = ((curr.bitrate ?? 0) - quality.bitrate).abs(); - return currDiff < prevDiff ? curr : prev; - } - }); - } - - String? getUrlOfQuality( - SpotubeAudioSourceContainerPreset preset, - int qualityIndex, - ) { - return getStreamOfQuality(preset, qualityIndex)?.url; - } - - SpotubeAudioSourceContainerPreset? get qualityPreset { - final presetState = ref.read(audioSourcePresetsProvider); - return presetState.presets - .elementAtOrNull(presetState.selectedStreamingContainerIndex); - } -} diff --git a/lib/services/wikipedia/wikipedia.dart b/lib/services/wikipedia/wikipedia.dart deleted file mode 100644 index b571f30f..00000000 --- a/lib/services/wikipedia/wikipedia.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:wikipedia_api/wikipedia_api.dart'; - -final wikipedia = WikipediaApi(); diff --git a/lib/services/wm_tools/wm_tools.dart b/lib/services/wm_tools/wm_tools.dart deleted file mode 100644 index f60b4ac9..00000000 --- a/lib/services/wm_tools/wm_tools.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:window_manager/window_manager.dart'; - -class WindowSize { - final double height; - final double width; - final bool maximized; - - WindowSize({ - required this.height, - required this.width, - required this.maximized, - }); - - factory WindowSize.fromJson(Map json) => WindowSize( - height: json["height"], - width: json["width"], - maximized: json["maximized"], - ); - - Map toJson() => { - "height": height, - "width": width, - "maximized": maximized, - }; -} - -class WindowManagerTools with WidgetsBindingObserver { - static WindowManagerTools? _instance; - static WindowManagerTools get instance => _instance!; - - WindowManagerTools._(); - - static Future initialize() async { - await windowManager.ensureInitialized(); - _instance = WindowManagerTools._(); - WidgetsBinding.instance.addObserver(instance); - - await windowManager.waitUntilReadyToShow( - const WindowOptions( - title: "Spotube", - backgroundColor: Colors.transparent, - minimumSize: Size(300, 700), - titleBarStyle: TitleBarStyle.hidden, - center: true, - ), - () async { - final savedSize = KVStoreService.windowSize; - await windowManager.setResizable(true); - if (savedSize?.maximized == true && - !(await windowManager.isMaximized())) { - await windowManager.maximize(); - } else if (savedSize != null) { - await windowManager.setSize(Size(savedSize.width, savedSize.height)); - } - - await windowManager.focus(); - await windowManager.show(); - }, - ); - } - - Size? _prevSize; - - @override - void didChangeMetrics() async { - super.didChangeMetrics(); - if (kIsMobile) return; - final size = await windowManager.getSize(); - final windowSameDimension = - _prevSize?.width == size.width && _prevSize?.height == size.height; - - if (windowSameDimension || _prevSize == null) { - _prevSize = size; - return; - } - final isMaximized = await windowManager.isMaximized(); - await KVStoreService.setWindowSize( - WindowSize( - height: size.height, - width: size.width, - maximized: isMaximized, - ), - ); - _prevSize = size; - } -} diff --git a/lib/services/youtube_engine/newpipe_engine.dart b/lib/services/youtube_engine/newpipe_engine.dart deleted file mode 100644 index c865b66c..00000000 --- a/lib/services/youtube_engine/newpipe_engine.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'package:flutter_new_pipe_extractor/flutter_new_pipe_extractor.dart' - hide Engagement; -import 'package:spotube/services/youtube_engine/youtube_engine.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; -import 'package:http_parser/http_parser.dart'; - -class NewPipeEngine implements YouTubeEngine { - static bool get isAvailableForPlatform => kIsAndroid || kIsDesktop; - - AudioOnlyStreamInfo _parseAudioStream(AudioStream stream, String videoId) { - return AudioOnlyStreamInfo( - VideoId(videoId), - stream.itag, - Uri.parse(stream.content), - StreamContainer.parse(stream.mediaFormat!.mimeType.split("/").last), - FileSize.unknown, - Bitrate(stream.bitrate), - stream.codec, - switch (stream.bitrate) { - > 130 * 1024 => "high", - > 64 * 1024 => "medium", - _ => "low", - }, - [], - MediaType.parse(stream.mediaFormat!.mimeType), - null, - ); - } - - AudioOnlyStreamInfo _parseVideoStream(VideoStream stream, String videoId) { - return AudioOnlyStreamInfo( - VideoId(videoId), - stream.itag, - Uri.parse(stream.content), - StreamContainer.parse(stream.mediaFormat!.mimeType.split("/").last), - FileSize.unknown, - Bitrate(stream.bitrate), - stream.codec, - switch (stream.bitrate) { - > 130 * 1024 => "high", - > 64 * 1024 => "medium", - _ => "low", - }, - [], - MediaType.parse(stream.mediaFormat!.mimeType), - null, - ); - } - - Video _parseVideo(VideoInfo info) { - return Video( - VideoId(info.id), - info.name, - info.uploaderName, - ChannelId(info.uploaderUrl), - info.uploadDate.offsetDateTime, - info.uploadDate.offsetDateTime.toString(), - info.uploadDate.offsetDateTime, - info.description.content ?? "", - Duration(seconds: info.duration), - ThumbnailSet(info.id), - info.tags, - Engagement( - info.viewCount, - info.likeCount, - info.dislikeCount, - ), - !info.streamType.name.toLowerCase().contains("live"), - ); - } - - Video _parseVideoResult(VideoSearchResultItem info) { - final id = Uri.parse(info.url).queryParameters["v"]!; - return Video( - VideoId(id), - info.name, - info.uploaderName, - ChannelId(info.uploaderUrl), - info.uploadDate?.offsetDateTime, - info.uploadDate?.offsetDateTime.toString(), - info.uploadDate?.offsetDateTime, - info.shortDescription ?? "", - Duration(seconds: info.duration), - ThumbnailSet(id), - [], - Engagement(info.viewCount, null, null), - !info.streamType.name.toLowerCase().contains("live"), - ); - } - - @override - Future getStreamManifest(String videoId) async { - final video = await NewPipeExtractor.getVideoInfo(videoId); - - final streams = - video.audioStreams.map((stream) => _parseAudioStream(stream, videoId)); - - if (streams.isEmpty) { - final videoStreams = video.videoStreams - .map((stream) => _parseVideoStream(stream, videoId)); - if (videoStreams.isNotEmpty) { - return StreamManifest(videoStreams); - } - } - - return StreamManifest(streams); - } - - @override - Future